From 4d7a8d3f5c8f8d4510939603f62bf8d79e9d6bc4 Mon Sep 17 00:00:00 2001 From: sergioperezcheco Date: Thu, 1 Oct 2026 16:06:52 +0800 Subject: [PATCH 1/2] Keep non-ASCII whitespace in class helpers and split only ASCII tokens HTML class tokens are separated by ASCII whitespace only (WHATWG HTML 2.3.7); any other character is part of the token. #552 switched getClassesSlice to strings.Fields, which also splits on Unicode whitespace, so a single class name containing NBSP/EM SPACE/NARROW NBSP was read as several tokens by Add/Remove/ToggleClass while HasClass and the class-attribute normalization treat those characters as token content. The result: AddClass re-appends the split pieces as new classes, RemoveClass and ToggleClass cannot remove the name, and setClasses trimmed Unicode whitespace from attribute edges. Restore ASCII-only boundaries: getClassesSlice uses FieldsFunc over the five ASCII whitespace characters, and the entry/exit trimming in the helpers and setClasses trims the same set so Unicode whitespace at attribute edges is preserved verbatim. Form feed stays a separator, and the tokenizer change keeps the no-empty-entries behavior of #552. Benchmarks (darwin/arm64, Go 1.26.4, -count=3 means vs master): AddClass 27279 -> 28776 ns/op (+5.5%), RemoveClass 28166 -> 29047 ns/op (+3.1%); B/op and allocs/op unchanged (7024/1776 B, 374 allocs). Signed-off-by: sergioperezcheco --- property.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/property.go b/property.go index 0a8e46d..b4df4b8 100644 --- a/property.go +++ b/property.go @@ -108,7 +108,7 @@ func (s *Selection) Html() (ret string, e error) { // AddClass adds the given class(es) to each element in the set of matched elements. // Multiple class names can be specified, separated by a space or via multiple arguments. func (s *Selection) AddClass(class ...string) *Selection { - classStr := strings.TrimSpace(strings.Join(class, " ")) + classStr := strings.Trim(strings.Join(class, " "), " \t\r\n\f") if classStr == "" { return s @@ -157,7 +157,7 @@ func (s *Selection) HasClass(class string) bool { func (s *Selection) RemoveClass(class ...string) *Selection { var rclasses []string - classStr := strings.TrimSpace(strings.Join(class, " ")) + classStr := strings.Trim(strings.Join(class, " "), " \t\r\n\f") remove := classStr == "" if !remove { @@ -183,7 +183,7 @@ func (s *Selection) RemoveClass(class ...string) *Selection { // ToggleClass adds or removes the given class(es) for each element in the set of matched elements. // Multiple class names can be specified, separated by a space or via multiple arguments. func (s *Selection) ToggleClass(class ...string) *Selection { - classStr := strings.TrimSpace(strings.Join(class, " ")) + classStr := strings.Trim(strings.Join(class, " "), " \t\r\n\f") if classStr == "" { return s @@ -258,7 +258,10 @@ func getClassesAndAttr(n *html.Node) (classes string, attr *html.Attribute) { } func getClassesSlice(classes string) []string { - return strings.Fields(classes) + // HTML class tokens are separated by ASCII whitespace, not Unicode whitespace. + return strings.FieldsFunc(classes, func(r rune) bool { + return r == ' ' || r == '\t' || r == '\r' || r == '\n' || r == '\f' + }) } func removeAttr(n *html.Node, attrName string) { @@ -272,7 +275,7 @@ func removeAttr(n *html.Node, attrName string) { } func setClasses(n *html.Node, attr *html.Attribute, classes string) { - classes = strings.TrimSpace(classes) + classes = strings.Trim(classes, " \t\r\n\f") if classes == "" { removeAttr(n, "class") return From 36c04bfcadd8d1c2da4ca838599ad0ecc1f9f6a2 Mon Sep 17 00:00:00 2001 From: sergioperezcheco Date: Thu, 1 Oct 2026 16:06:52 +0800 Subject: [PATCH 2/2] Add class-helper regressions for Unicode whitespace boundaries Cover Add/Remove/ToggleClass over class names containing NBSP, EM SPACE, NARROW NBSP and vertical tab, mid-name and at attribute edges, plus direct tokenizer expectations that only the five ASCII whitespace characters split tokens. Attribute round-trips verify the stored value byte-for-byte. These cases fail on master after #552 and pass with the preceding change. Signed-off-by: sergioperezcheco --- property_unicode_class_test.go | 75 ++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 property_unicode_class_test.go diff --git a/property_unicode_class_test.go b/property_unicode_class_test.go new file mode 100644 index 0000000..5caa39e --- /dev/null +++ b/property_unicode_class_test.go @@ -0,0 +1,75 @@ +package goquery + +import ( + "strings" + "testing" +) + +func TestClassOperationsPreserveNonASCIIWhitespace(t *testing.T) { + for _, whitespace := range []string{"\u00a0", "\u2003", "\u202f", "\v"} { + for _, class := range []string{"a" + whitespace + "b", whitespace + "a", "a" + whitespace, whitespace} { + for _, op := range []struct { + name string + run func(*Selection, string) *Selection + want string + }{ + {"add", func(s *Selection, c string) *Selection { return s.AddClass(c) }, class + " keep"}, + {"remove", func(s *Selection, c string) *Selection { return s.RemoveClass(c) }, "keep"}, + {"toggle", func(s *Selection, c string) *Selection { return s.ToggleClass(c) }, "keep"}, + } { + t.Run(op.name+"/"+class, func(t *testing.T) { + doc, err := NewDocumentFromReader(strings.NewReader("
")) + if err != nil { + t.Fatal(err) + } + sel := doc.Find("div").SetAttr("class", class+" keep") + if !sel.HasClass(class) { + t.Fatal("existing class not recognized") + } + if op.run(sel, "\t"+class+"\r\n") != sel { + t.Fatal("lost chaining") + } + if got := sel.AttrOr("class", ""); got != op.want { + t.Fatalf("class = %q, want %q", got, op.want) + } + markup, err := OuterHtml(sel) + if err != nil { + t.Fatal(err) + } + reopened, err := NewDocumentFromReader(strings.NewReader(markup)) + if err != nil { + t.Fatal(err) + } + if got := reopened.Find("div").AttrOr("class", ""); got != op.want { + t.Fatalf("round trip = %q, want %q", got, op.want) + } + }) + } + } + } +} + +func TestClassTokensASCIIWhitespace(t *testing.T) { + for _, sep := range []string{" ", "\t", "\r", "\n", "\f"} { + got := getClassesSlice(sep + "a\u00a0b" + sep + sep + "keep" + sep) + if len(got) != 2 || got[0] != "a\u00a0b" || got[1] != "keep" { + t.Errorf("separator %q: %q", sep, got) + } + } +} + +func TestClassOperationsKeepUnicodeAtAttributeEdges(t *testing.T) { + doc, err := NewDocumentFromReader(strings.NewReader("
")) + if err != nil { + t.Fatal(err) + } + sel := doc.Find("div") + sel.AddClass("\u00a0a\u00a0").AddClass("keep").RemoveClass("keep") + if got := sel.AttrOr("class", ""); got != "\u00a0a\u00a0" { + t.Fatalf("class = %q", got) + } + sel.RemoveClass() + if _, exists := sel.Attr("class"); exists { + t.Fatal("RemoveClass() did not clear attribute") + } +}