From 108ab636d001c56c74c88b1c3c2af810dbada0aa Mon Sep 17 00:00:00 2001 From: Danathar Date: Sun, 25 Oct 2026 06:29:34 +0000 Subject: [PATCH] fix(intake): drop style, script, template and comment text from HTML StripTags only removed the <...> tokens and kept the text between them, so an uploaded HTML page's inline CSS and JS, and any comment containing '>', came back as idea text and used up the MaxReturnedRunes budget before the real content. Comments are now skipped as a whole, and so are the contents of script, style, noscript and template (case-insensitive; template may nest). Ordinary tags, spacing and entity unescaping are unchanged. Hive-Run: hivecommons/dibs#474 Hive-Plan: issue-474 Hive-Spec: issue-474#acceptance Co-Authored-By: Claude Opus 5.5 Signed-off-by: Danathar --- pkg/intake/intake.go | 77 ++++++++++++++++++++++++++++++++++++++- pkg/intake/intake_test.go | 39 ++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/pkg/intake/intake.go b/pkg/intake/intake.go index 128f316..63f67cc 100644 --- a/pkg/intake/intake.go +++ b/pkg/intake/intake.go @@ -252,11 +252,23 @@ func ExtractDOCX(data []byte) (string, error) { return "", fmt.Errorf("word/document.xml not found") } +// StripTags reduces HTML to its text. Comments and the contents of +// htmlSkippedElements are dropped as a whole, since they are not rendered +// text and a "Save as HTML" page is mostly inline CSS and JS. func StripTags(s string) string { var b strings.Builder inTag := false spacePending := false - for _, r := range s { + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + if r == '<' && !inTag { + if end := skipHTMLRegion(s, i); end > i { + i = end + spacePending = true + continue + } + } + i += size switch r { case '<': inTag = true @@ -279,6 +291,69 @@ func StripTags(s string) string { return html.UnescapeString(b.String()) } +// htmlSkippedElements hold no rendered document text. Only template can +// nest; the others are raw text and end at their first closing tag. +var htmlSkippedElements = []string{"script", "style", "noscript", "template"} + +// skipHTMLRegion returns the index just past the comment or skipped element +// that starts at s[i] (a '<'), the end of input if it is unterminated, or i +// if s[i] starts neither. +func skipHTMLRegion(s string, i int) int { + if strings.HasPrefix(s[i:], "" and "" where a browser does. + if end := strings.Index(s[i+2:], "-->"); end >= 0 { + return i + 2 + end + len("-->") + } + return len(s) + } + for _, name := range htmlSkippedElements { + if !htmlTagAt(s, i+1, name) { + continue + } + depth := 0 + for j := i; j < len(s); j++ { + if s[j] != '<' { + continue + } + switch { + case j+1 < len(s) && s[j+1] == '/' && htmlTagAt(s, j+2, name): + depth-- + case j == i || (name == "template" && htmlTagAt(s, j+1, name)): + depth++ + default: + continue + } + end := strings.IndexByte(s[j:], '>') + if end < 0 { + return len(s) + } + if depth == 0 { + return j + end + 1 + } + j += end + } + return len(s) + } + return i +} + +// htmlTagAt reports whether the tag name starting at s[i] is name, matched +// case-insensitively and ending at whitespace, '/', '>' or end of input. +func htmlTagAt(s string, i int, name string) bool { + if len(s)-i < len(name) || !strings.EqualFold(s[i:i+len(name)], name) { + return false + } + if i+len(name) == len(s) { + return true + } + switch s[i+len(name)] { + case ' ', '\t', '\n', '\r', '\f', '/', '>': + return true + } + return false +} + func StripRTF(s string) string { var b strings.Builder for i := 0; i < len(s); { diff --git a/pkg/intake/intake_test.go b/pkg/intake/intake_test.go index 0cb5325..e35abd1 100644 --- a/pkg/intake/intake_test.go +++ b/pkg/intake/intake_test.go @@ -30,6 +30,16 @@ func TestExtractTextFormats(t *testing.T) { if !strings.Contains(got, "Idea") || !strings.Contains(got, "Help & ship") || strings.Contains(got, "

") { t.Fatalf("html stripped to %q", got) } + got, err = ExtractDocument(".html", []byte(` + + +

Idea: add a dark mode

`)) + if err != nil { + t.Fatal(err) + } + if got := cleanText(got); got != "Idea: add a dark mode" { + t.Fatalf("html with style/script/comment extracted %q", got) + } got, err = ExtractDocument(".rtf", []byte(`{\rtf1\ansi This is \b bold\b0 idea\par done}`)) if err != nil { t.Fatal(err) @@ -39,6 +49,35 @@ func TestExtractTextFormats(t *testing.T) { } } +func TestStripTagsSkipsNonTextRegions(t *testing.T) { + t.Parallel() + cases := []struct { + name, in, want string + }{ + {"style", `

a

b

`, "a b"}, + {"script", `

a

b

`, "a b"}, + {"noscript", `

a

b

`, "a b"}, + {"template", `

a

b

`, "a b"}, + {"nested template", `ab`, "a b"}, + {"comment with gt", `ab`, "a b"}, + {"empty comments", `abc`, "a b c"}, + {"uppercase", `ab`, "a b"}, + {"similar names kept", `ab`, "a b"}, + {"unterminated comment", `a