Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

251 lines
7.6 KiB
Go

package languages
import (
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
"github.com/zzet/gortex/internal/parser/tsitter/html"
)
// HTMLExtractor extracts HTML files into graph nodes and edges. Inline
// <script> bodies are delegated to the JavaScript extractor so their
// functions / classes / calls land in the graph, and id-anchored elements
// become navigable DocSection (KindDoc) nodes.
type HTMLExtractor struct {
lang *sitter.Language
js *JavaScriptExtractor
}
func NewHTMLExtractor() *HTMLExtractor {
return &HTMLExtractor{lang: html.GetLanguage(), js: NewJavaScriptExtractor()}
}
func (e *HTMLExtractor) Language() string { return "html" }
func (e *HTMLExtractor) Extensions() []string { return []string{".html", ".htm"} }
func (e *HTMLExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
tree, err := parser.ParseFile(src, e.lang)
if err != nil {
return nil, err
}
defer tree.Close()
root := tree.RootNode()
result := &parser.ExtractionResult{}
fileNode := &graph.Node{
ID: filePath, Kind: graph.KindFile, Name: filePath,
FilePath: filePath, StartLine: 1, EndLine: int(root.EndPoint().Row) + 1,
Language: "html",
}
result.Nodes = append(result.Nodes, fileNode)
// Walk the AST manually since HTML tree-sitter queries can be quirky.
e.walkNode(root, src, filePath, fileNode.ID, result)
return result, nil
}
func (e *HTMLExtractor) walkNode(node *sitter.Node, src []byte, filePath, fileID string, result *parser.ExtractionResult) {
nodeType := node.Type()
switch nodeType {
case "script_element":
e.extractScriptImport(node, src, filePath, fileID, result)
case "element":
e.extractElement(node, src, filePath, fileID, result)
}
// Recurse into children.
for i, _nc := 0, int(node.ChildCount()); i < _nc; i++ {
child := node.Child(i)
if child != nil {
e.walkNode(child, src, filePath, fileID, result)
}
}
}
// extractScriptImport handles a script_element: a `src` attribute is an
// external import; an inline body (no src) is delegated to the
// JavaScript extractor so its functions / classes / calls join the graph.
func (e *HTMLExtractor) extractScriptImport(node *sitter.Node, src []byte, filePath, fileID string, result *parser.ExtractionResult) {
startTag := findChildByType(node, "start_tag")
selfClosing := false
if startTag == nil {
// Self-closing script tag.
startTag = findChildByType(node, "self_closing_tag")
selfClosing = true
}
if startTag == nil {
return
}
if srcAttr := findAttribute(startTag, "src", src); srcAttr != "" {
result.Edges = append(result.Edges, &graph.Edge{
From: fileID,
To: "unresolved::import::" + srcAttr,
Kind: graph.EdgeImports,
FilePath: filePath,
Line: int(node.StartPoint().Row) + 1,
})
return
}
if selfClosing {
return
}
// Inline <script>: parse the body as JavaScript when the type marks
// it as a script (the HTML default, or an explicit JS / module type).
if !htmlScriptIsJS(findAttribute(startTag, "type", src)) {
return
}
body := findChildByType(node, "raw_text")
if body == nil {
return
}
e.delegateInlineScript(body, src, filePath, fileID, result)
}
// htmlScriptIsJS reports whether a <script type=...> value denotes
// JavaScript (so its body is worth parsing). An empty type is JavaScript
// by the HTML default; data blocks (application/json, text/template, …)
// are not.
func htmlScriptIsJS(scriptType string) bool {
switch strings.ToLower(strings.TrimSpace(scriptType)) {
case "", "module", "text/javascript", "application/javascript",
"text/ecmascript", "application/ecmascript", "text/babel", "text/jsx":
return true
}
return false
}
// delegateInlineScript parses an inline <script> body with the JavaScript
// extractor and folds the resulting symbols into the HTML file's graph.
// Node IDs are already scoped to a per-script virtual path
// (<file>#script:<line>), file-defines edges are re-pointed at the HTML
// file node, and every line number is shifted by the body's offset within
// the page so navigation lands on the real source line.
func (e *HTMLExtractor) delegateInlineScript(body *sitter.Node, src []byte, filePath, fileID string, result *parser.ExtractionResult) {
lineOffset := int(body.StartPoint().Row)
delegateInlineScriptSlice(e.js, []byte(body.Content(src)), lineOffset, filePath, fileID, "", result)
}
// extractElement checks elements for link tags (stylesheet imports) and id attributes.
func (e *HTMLExtractor) extractElement(node *sitter.Node, src []byte, filePath, fileID string, result *parser.ExtractionResult) {
startTag := findChildByType(node, "start_tag")
if startTag == nil {
startTag = findChildByType(node, "self_closing_tag")
}
if startTag == nil {
return
}
tagName := findChildByType(startTag, "tag_name")
if tagName == nil {
return
}
tag := tagName.Content(src)
// Link/stylesheet imports.
if tag == "link" {
href := findAttribute(startTag, "href", src)
if href != "" {
result.Edges = append(result.Edges, &graph.Edge{
From: fileID,
To: "unresolved::import::" + href,
Kind: graph.EdgeImports,
FilePath: filePath,
Line: int(node.StartPoint().Row) + 1,
})
}
}
// Elements with id attributes become navigable DocSection anchors —
// a deep-link target whose visible text is indexed for prose search.
idVal := findAttribute(startTag, "id", src)
if idVal != "" {
id := filePath + "::doc:#" + idVal
meta := map[string]any{
"tag": tag,
"html_anchor": true,
}
if text := htmlElementText(node, src); text != "" {
meta["section_text"] = text
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id, Kind: graph.KindDoc, Name: "#" + idVal,
FilePath: filePath, StartLine: int(node.StartPoint().Row) + 1, EndLine: int(node.EndPoint().Row) + 1,
Language: "html", Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: fileID, To: id, Kind: graph.EdgeDefines,
FilePath: filePath, Line: int(node.StartPoint().Row) + 1,
})
}
}
// htmlElementText returns the collapsed visible text of an element
// subtree (its descendant "text" nodes), capped, for use as a
// DocSection's searchable body.
func htmlElementText(node *sitter.Node, src []byte) string {
var b strings.Builder
var walk func(n *sitter.Node)
walk = func(n *sitter.Node) {
if n == nil {
return
}
if n.Type() == "text" {
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(n.Content(src))
}
for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ {
walk(n.Child(i))
}
}
walk(node)
text := strings.Join(strings.Fields(b.String()), " ")
const maxLen = 240
if len(text) > maxLen {
text = text[:maxLen]
}
return text
}
// findChildByType finds the first child node with the given type.
func findChildByType(node *sitter.Node, typeName string) *sitter.Node {
for i, _nc := 0, int(node.ChildCount()); i < _nc; i++ {
child := node.Child(i)
if child != nil && child.Type() == typeName {
return child
}
}
return nil
}
// findAttribute looks for an attribute with the given name in a start_tag node
// and returns its unquoted value.
func findAttribute(startTag *sitter.Node, attrName string, src []byte) string {
for i, _nc := 0, int(startTag.ChildCount()); i < _nc; i++ {
child := startTag.Child(i)
if child == nil || child.Type() != "attribute" {
continue
}
nameNode := findChildByType(child, "attribute_name")
if nameNode == nil || nameNode.Content(src) != attrName {
continue
}
valNode := findChildByType(child, "quoted_attribute_value")
if valNode == nil {
continue
}
val := valNode.Content(src)
val = strings.Trim(val, `"'`)
return val
}
return ""
}