// Package parse turns stored Compass pages into structured items (AGENTS.md
// §4 FR-3/FR-4/FR-5, §11 Phase 3). It is a deterministic, dependency-free HTML
// reader: the Compass pages are machine-generated, so parsing the markup is more
// reliable than asking a model to transcribe a table.
package parse
import (
"bytes"
"html"
"strings"
)
// voidElements never have children and are therefore never pushed on the stack.
var voidElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
"hr": true, "img": true, "input": true, "link": true, "meta": true,
"param": true, "source": true, "track": true, "wbr": true,
}
// rawTextElements have text content that must not be tokenised as markup.
var rawTextElements = map[string]bool{
"script": true, "style": true, "textarea": true,
}
// node is one element in the parsed document. Text content is stored as
// "#text" children so that text and elements keep their document order; use
// Text for the concatenated descendant text.
type node struct {
Tag string
Attrs map[string]string
Kids []*node
Parent *node
text string
}
// Class returns the element's class attribute.
func (n *node) Class() string { return n.Attrs["class"] }
// hasClass reports whether the element carries the exact class.
func (n *node) hasClass(cls string) bool {
for _, f := range strings.Fields(n.Class()) {
if f == cls {
return true
}
}
return false
}
// hasClassPart reports whether any class token contains part.
func (n *node) hasClassPart(part string) bool {
for _, f := range strings.Fields(n.Class()) {
if strings.Contains(f, part) {
return true
}
}
return false
}
// hasAttr reports whether the attribute is present with a non-empty value.
func (n *node) hasAttr(key string) bool { return strings.TrimSpace(n.Attrs[key]) != "" }
// Text returns the concatenated text of the element and all descendants,
// whitespace-collapsed.
func (n *node) Text() string {
var b strings.Builder
n.writeText(&b)
return collapse(b.String())
}
func (n *node) writeText(b *strings.Builder) {
b.WriteString(n.text)
for _, k := range n.Kids {
k.writeText(b)
}
}
func collapse(s string) string { return strings.Join(strings.Fields(s), " ") }
// textUntil returns the concatenated text of the node's descendants up to and
// including the first matching direct-or-descendant element, exclusive of that
// element's subtree.
func (n *node) textUntil(pred func(*node) bool) string {
var b strings.Builder
var w func(x *node) bool
w = func(x *node) bool {
if pred(x) {
return false
}
b.WriteString(x.text)
for _, k := range x.Kids {
if !w(k) {
return false
}
}
return true
}
w(n)
return collapse(b.String())
}
// parseHTML tokenises and nests the byte slice. It never fails: malformed
// markup, stray tags and unclosed elements are tolerated, because ExtJS emits
// group headers whose
is not closed before the next table starts.
func parseHTML(data []byte) *node {
root := &node{Tag: "#root", Attrs: map[string]string{}}
stack := []*node{root}
n := len(data)
i := 0
top := func() *node { return stack[len(stack)-1] }
for i < n {
if data[i] != '<' {
j := bytes.IndexByte(data[i:], '<')
if j < 0 {
j = n - i
}
appendText(top(), string(data[i:i+j]))
i += j
continue
}
switch {
case bytes.HasPrefix(data[i:], []byte(""))
if end < 0 {
i = n
continue
}
i += end + 3
case bytes.HasPrefix(data[i:], []byte("')
if end < 0 {
i = n
continue
}
i += end + 1
case bytes.HasPrefix(data[i:], []byte("")):
k := i + 2
start := k
for k < n && isNameChar(data[k]) {
k++
}
name := strings.ToLower(string(data[start:k]))
gt := bytes.IndexByte(data[k:], '>')
if gt < 0 {
i = n
continue
}
i = k + gt + 1
stack = closeTag(stack, name)
default:
k := i + 1
start := k
for k < n && isNameChar(data[k]) {
k++
}
name := strings.ToLower(string(data[start:k]))
if name == "" { // a bare '<'
appendText(top(), "<")
i++
continue
}
attrs, selfClose, next := parseAttrs(data, k)
i = next
child := &node{Tag: name, Attrs: attrs, Parent: top()}
top().Kids = append(top().Kids, child)
if selfClose || voidElements[name] {
continue
}
stack = append(stack, child)
if rawTextElements[name] {
// Skip the content; scripts and styles are not parsed as markup.
if idx := indexFold(data[i:], ""+name); idx >= 0 {
i += idx
} else {
i = n
}
}
}
}
return root
}
// parseAttrs reads tag attributes starting at p (just past the tag name). It
// returns the attributes, whether the tag closed with "/>", and the index just
// past the closing '>'.
func parseAttrs(data []byte, p int) (map[string]string, bool, int) {
attrs := map[string]string{}
selfClose := false
n := len(data)
for p < n {
for p < n && isSpace(data[p]) {
p++
}
if p >= n {
break
}
if data[p] == '>' {
p++
break
}
if data[p] == '/' {
selfClose = true
p++
continue
}
start := p
for p < n && !isSpace(data[p]) && data[p] != '=' && data[p] != '>' && data[p] != '/' {
p++
}
name := strings.ToLower(string(data[start:p]))
q := p
for q < n && isSpace(data[q]) {
q++
}
var value string
if q < n && data[q] == '=' {
q++
for q < n && isSpace(data[q]) {
q++
}
if q < n && (data[q] == '"' || data[q] == '\'') {
quote := data[q]
q++
v := q
for q < n && data[q] != quote {
q++
}
value = string(data[v:q])
if q < n {
q++
}
} else {
v := q
for q < n && !isSpace(data[q]) && data[q] != '>' {
q++
}
value = string(data[v:q])
}
p = q
}
if name != "" {
attrs[name] = html.UnescapeString(value)
}
}
return attrs, selfClose, p
}
// appendText appends an entity-decoded text node to the parent, keeping its
// position in document order.
func appendText(n *node, s string) {
if s == "" {
return
}
n.Kids = append(n.Kids, &node{Tag: "#text", text: html.UnescapeString(s), Parent: n})
}
// closeTag pops elements up to and including the nearest matching tag and
// returns the remaining stack.
func closeTag(stack []*node, name string) []*node {
for i := len(stack) - 1; i > 0; i-- {
if stack[i].Tag == name {
return stack[:i]
}
}
return stack
}
func isNameChar(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' ||
c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':' || c == '.'
}
func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' }
// indexFold finds needle in haystack, ignoring ASCII case.
func indexFold(haystack []byte, needle string) int {
if len(needle) == 0 {
return 0
}
lower := bytes.ToLower(haystack)
return bytes.Index(lower, []byte(strings.ToLower(needle)))
}
// walk visits n and its descendants in document order. When fn returns false the
// node's children are skipped; siblings are still visited. To abort a whole
// traversal, the predicate must track its own stop flag.
func walk(n *node, fn func(*node) bool) {
if !fn(n) {
return
}
for _, k := range n.Kids {
walk(k, fn)
}
}
// find returns the first descendant (including n) matching pred.
func find(n *node, pred func(*node) bool) *node {
if pred(n) {
return n
}
for _, k := range n.Kids {
if got := find(k, pred); got != nil {
return got
}
}
return nil
}
// findAll returns every descendant (including n) matching pred, in document order.
func findAll(n *node, pred func(*node) bool) []*node {
var out []*node
walk(n, func(x *node) bool {
if pred(x) {
out = append(out, x)
}
return true
})
return out
}
|