// 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("') 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:], "= 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 }