Compare commits

..
103 Commits
Author SHA1 Message Date
Martin AngersandGitHub 5ee0843df1 Merge pull request #604 from PuerkitoBio/dependabot/go_modules/github.com/andybalholm/cascadia-1.3.5
build(deps): bump github.com/andybalholm/cascadia from 1.3.4 to 1.3.5
2026-09-08 11:15:24 -04:00
dependabot[bot]andGitHub dd800704d1 build(deps): bump github.com/andybalholm/cascadia from 1.3.4 to 1.3.5
Bumps [github.com/andybalholm/cascadia](https://github.com/andybalholm/cascadia) from 1.3.4 to 1.3.5.
- [Release notes](https://github.com/andybalholm/cascadia/releases)
- [Commits](https://github.com/andybalholm/cascadia/compare/v1.3.4...v1.3.5)

---
updated-dependencies:
- dependency-name: github.com/andybalholm/cascadia
  dependency-version: 1.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-08 05:52:20 +00:00
Martin AngersandGitHub ef5f2ddf71 Merge pull request #600 from udf2457/manipulateNodes
Clone and reverse nodes slice for safe manipulation
2026-09-07 11:13:08 -04:00
Martin AngersandGitHub 3d22a4dd10 Merge pull request #603 from dualfroz/dualfroz/fix-removeclass-duplicates
RemoveClass leaves a class behind when the source repeats it
2026-09-07 10:29:11 -04:00
dualfroz b4ef68dec8 fix: remove every occurrence of a repeated class
RemoveClass deleted each class with one strings.ReplaceAll pass over the
space-wrapped class list. Two adjacent occurrences share the space
between them, so " a a " holds only one non-overlapping " a " and the
second survived: RemoveClass("a") on class="a a" returned with
HasClass("a") still true. ToggleClass used the same expression and so
toggled such a class the wrong way.

Replace one occurrence at a time, re-scanning from the start, in a helper
shared by both call sites. Whitespace between the remaining classes is
untouched.
2026-09-06 00:44:19 +02:00
Martin AngersandGitHub 738783cbc3 Merge pull request #597 from udf2457/master
Refactor IsMatcher to use slices.ContainsFunc
2026-09-04 11:11:08 -04:00
udf2457andGitHub a80a922806 Clone and reverse nodes slice for safe manipulation
Clone the slice before reversing to avoid modifying the caller's slice.
2026-09-04 16:08:58 +01:00
udf2457andGitHub 8cb3ddf6e3 Refactor IsMatcher to use slices.ContainsFunc 2026-09-04 15:38:40 +01:00
Martin Angers 4a4f1326fa Linkify github usernames 2026-08-27 09:17:53 -04:00
Martin Angers e47f3848de Add new version to readme to prepare 1.13 release 2026-08-27 09:15:43 -04:00
Martin Angers 80a9b7fb55 Run CI on go1.27 (+1.26) 2026-08-27 09:09:59 -04:00
Martin AngersandGitHub ad18e5a957 Merge pull request #596 from ChrisJr404/text-toplevel-func
Add top-level Text function for BeautifulSoup-style text extraction
2026-08-27 09:08:42 -04:00
Chris (ChrisJr404) 4e49d09e18 add tests for empty and fully-filtered selections 2026-08-24 14:00:20 -04:00
Chris (ChrisJr404) 91ed06d7c5 Add top-level Text function for BeautifulSoup-style text extraction
Adds goquery.Text(s, *TextOptions), a package-level counterpart to the
Selection.Text method that gives control over how the text of distinct
text nodes is joined (Separator), whether each node is trimmed of
surrounding whitespace (Trim), and which text nodes are included (Keep).
This covers the common need to extract clean, readable text from a
document - for example joining fragments with a space and dropping the
text of script/style elements - without hand-rolling a node walk.

Passing a nil TextOptions keeps the behaviour identical to Selection.Text.
Includes tests and a runnable example.
2026-08-18 08:54:59 -04:00
Martin AngersandGitHub 105f3990ac Merge pull request #595 from PuerkitoBio/dependabot/go_modules/golang.org/x/net-0.58.0
build(deps): bump golang.org/x/net from 0.57.0 to 0.58.0
2026-08-17 09:17:03 -04:00
dependabot[bot]andGitHub d329e294ad build(deps): bump golang.org/x/net from 0.57.0 to 0.58.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.57.0 to 0.58.0.
- [Commits](https://github.com/golang/net/compare/v0.57.0...v0.58.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.58.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 05:52:35 +00:00
Martin AngersandGitHub 53a9b37ea8 Merge pull request #586 from jvoisin/cachephwc
cleanup: extract cachedParse helper for parse-result reuse
2026-07-31 10:27:25 -04:00
Martin AngersandGitHub 50e9856dda Merge pull request #593 from PuerkitoBio/dependabot/go_modules/golang.org/x/net-0.57.0
build(deps): bump golang.org/x/net from 0.56.0 to 0.57.0
2026-07-17 16:17:28 -04:00
Martin AngersandGitHub 3a8dfd5b5f Merge pull request #592 from PuerkitoBio/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 6 to 7
2026-07-16 13:44:18 -04:00
dependabot[bot]andGitHub a92dca932b build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 17:32:11 +00:00
Martin AngersandGitHub 91fa5c4527 Merge pull request #594 from PuerkitoBio/dependabot/github_actions/actions/setup-go-7
build(deps): bump actions/setup-go from 6 to 7
2026-07-16 13:30:13 -04:00
dependabot[bot]andGitHub a72ee866ea build(deps): bump actions/setup-go from 6 to 7
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 05:52:38 +00:00
dependabot[bot]andGitHub 5b72b928dc build(deps): bump golang.org/x/net from 0.56.0 to 0.57.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.56.0 to 0.57.0.
- [Commits](https://github.com/golang/net/compare/v0.56.0...v0.57.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 05:52:36 +00:00
Martin AngersandGitHub a81d5bb195 Merge pull request #590 from PuerkitoBio/dependabot/go_modules/golang.org/x/net-0.56.0
build(deps): bump golang.org/x/net from 0.55.0 to 0.56.0
2026-06-11 08:58:19 -04:00
dependabot[bot]andGitHub 5facb40e7c build(deps): bump golang.org/x/net from 0.55.0 to 0.56.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.55.0 to 0.56.0.
- [Commits](https://github.com/golang/net/compare/v0.55.0...v0.56.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 05:52:36 +00:00
Martin AngersandGitHub 2bab3fcead Merge pull request #589 from jvoisin/attror
cleanup: inline getAttributeValue into Attr, delegate AttrOr
2026-06-10 09:00:51 -04:00
Martin AngersandGitHub ad7e3a5a32 Merge pull request #588 from jvoisin/deadparam
refactor: remove a dead param in getClassesAndAttr
2026-06-10 08:58:14 -04:00
Martin AngersandGitHub e66583975d Merge pull request #585 from jvoisin/winnow_cleanup
cleanup: thread []*html.Node through winnow, drop temp Selection
2026-06-10 08:56:49 -04:00
Martin AngersandGitHub dfcd45f03f Merge pull request #584 from jvoisin/phwc
cleanup: factor parseHtml through parseHtmlWithContext
2026-06-10 08:53:27 -04:00
Martin AngersandGitHub 6a3f1a4508 Merge pull request #583 from jvoisin/nocon
cleanup: drop redundant FirstChild nil-check in textHelper
2026-06-10 08:51:31 -04:00
jvoisin 3d73db28d1 cleanup: inline getAttributeValue into Attr, delegate AttrOr
Attr was a 4-line wrapper around getAttributeValue, which itself was
a 6-line wrapper around getAttributePtr.

This commit inlines getAttributeValue into Attr and
have AttrOr delegate to Attr. Drops a layer of indirection and one
helper function; both methods get shorter and the empty-selection
check lives in one place.
2026-06-05 23:48:33 +02:00
jvoisin ab25b2a2f9 refactor: remove a dead param in getClassesAndAttr
Every caller of getClassesAndAttr is passing `true` as second parameter, so
let's get rid of it.
2026-06-05 23:23:26 +02:00
jvoisin a7a9c2dced cleanup: extract cachedParse helper for parse-result reuse
WrapHtml, WrapInnerHtml, and eachNodeHtml each open-coded the same
lookup-or-parse-and-store sequence against a map[string][]*html.Node
keyed by the context node's name. Extract that 4-line sequence into
a tiny cachedParseHtmlWithContext(cache, htmlStr, context) helper; each call
site becomes a linear, closure-free loop and the only duplicated bit (the cache
lookup) lives in one place.
2026-06-05 22:51:00 +02:00
jvoisin 36f7b3b68e cleanup: thread []*html.Node through winnow, drop temp Selection
winnow only ever reads sel.Nodes; the *Selection parameter was
incidental. Change its signature to winnow(nodes []*html.Node, ...)
and update its three callers (FilterMatcher, NotMatcher,
filterAndPush). filterAndPush no longer needs to fabricate a
throwaway Selection to satisfy the call.

While this change has o measurable runtime effect, it simplifies the code a bit,
by making winnow's signature clearer, and removes a now-useless line and its
associated comment in filterAndPush
2026-06-05 22:35:48 +02:00
jvoisin e96c2a87e6 cleanup: factor parseHtml through parseHtmlWithContext
parseHtml and parseHtmlWithContext duplicated the ParseFragment call,
the error check, and the panic message. parseHtml only differed by
passing a synthetic ElementNode as the context.

This commit simply delegates parseHtml to parseHtmlWithContext.
2026-06-05 22:29:08 +02:00
jvoisin a202b87b3b cleanup: drop redundant FirstChild nil-check in textHelper
The `for c := n.FirstChild; c != nil; c = c.NextSibling` loop already
handles the nil-FirstChild case.
2026-06-05 22:26:30 +02:00
Martin AngersandGitHub 88920eaad4 Merge pull request #569 from jvoisin/specialized
perf: probe with MatchFirst in HasMatcher to avoid slice allocation
2026-06-04 10:23:08 -04:00
Martin AngersandGitHub 65a48d3886 Merge pull request #575 from jvoisin/nodedup
test: cover children traversals with duplicate source nodes
2026-06-04 10:17:21 -04:00
Martin AngersandGitHub b8d1127ab1 Merge pull request #574 from jvoisin/preact
perf: presize the result slice in Empty()
2026-06-04 09:57:10 -04:00
Martin AngersandGitHub 939767f12c Merge pull request #582 from PuerkitoBio/dependabot/go_modules/github.com/andybalholm/cascadia-1.3.4
build(deps): bump github.com/andybalholm/cascadia from 1.3.3 to 1.3.4
2026-06-04 09:41:47 -04:00
dependabot[bot]andGitHub edf734c843 build(deps): bump github.com/andybalholm/cascadia from 1.3.3 to 1.3.4
Bumps [github.com/andybalholm/cascadia](https://github.com/andybalholm/cascadia) from 1.3.3 to 1.3.4.
- [Release notes](https://github.com/andybalholm/cascadia/releases)
- [Commits](https://github.com/andybalholm/cascadia/compare/v1.3.3...v1.3.4)

---
updated-dependencies:
- dependency-name: github.com/andybalholm/cascadia
  dependency-version: 1.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 22:34:05 +00:00
jvoisin df88dce547 Refactor first-match optimization into singleMatcher
Move the "stop at first match" optimization out of HasMatcher and into
singleMatcher, where it can be reused elsewhere.

singleMatcher now implements MatchFirst, which calls the underlying
Matcher's MatchFirst if it provides one (cascadia-compiled matchers all
do), otherwise falls back to MatchAll and returns its first element.
singleMatcher.MatchAll wraps MatchFirst's result in a single-item slice
to respect the Matcher interface, so external SingleMatcher() users get
the slice signature while internal callers can avoid that allocation.

HasMatcher now constructs a singleMatcher directly to call MatchFirst
without a type assertion, removing the duplicated probe branch.
2026-06-02 14:15:45 +02:00
Martin AngersandGitHub 2955b8bf81 Merge pull request #568 from jvoisin/grnkgnr
perf: collect parent nodes inline in getParentNodes
2026-06-01 11:35:56 -04:00
Martin AngersandGitHub 57abc1b153 Merge pull request #565 from jvoisin/prez
perf: presize set in appendWithoutDuplicates to final expected size
2026-06-01 11:32:54 -04:00
Martin AngersandGitHub feced993c3 Merge pull request #567 from jvoisin/precize
perf: presize result slice in getChildrenWithSiblingType
2026-06-01 11:10:34 -04:00
jvoisin cc35e5b0e5 test: cover children traversals with duplicate source nodes
A Selection's Nodes field is exported, so callers can build a selection that
holds the same node more than once. Add a regression test asserting that
Children, ChildrenFiltered and Contents deduplicate the shared child set
rather than returning it once per duplicate source node.
2026-06-01 09:11:37 +02:00
jvoisin ea09fe9e1c perf: presize the result slice in Empty()
Empty() collected the removed children into a nil slice grown via append,
forcing a series of grow-and-double reallocations proportional to the number
of children.

This commit walks the children once up front to count them, then allocate the
result slice at its final capacity before the removal loop.

The count pass is a cheap, branch-predicted pointer walk that replaces several
slice reallocations with a single allocation.

benchstat (-benchtime=2000x, count=10):

                |   old    |            new            |
                |  allocs  |  allocs    vs base        |
    Empty10-8   |   3.000  |  2.000     -33.33%        |
    Empty100-8  |   6.000  |  2.000     -66.67%        |
    Empty1000-8 |   9.000  |  2.000     -77.78%        |

    B/op:    -47% / -56% / -53%
    sec/op:  Empty10 -53%, Empty100 -58%, Empty1000 ~ (noise)
2026-06-01 01:23:40 +02:00
jvoisin 021fb11910 perf: probe with MatchFirst in HasMatcher to avoid slice allocation
HasMatcher only needs to know whether a child subtree contains a match,
but it tested len(m.MatchAll(c)) > 0 after wrapping the matcher with
SingleMatcher. SingleMatcher.MatchAll computes MatchFirst internally and
then wraps the result in a throwaway []*html.Node{node}, allocating a
one-element slice per matching child subtree just to check a boolean.

When the matcher exposes MatchFirst, probe it directly and test the returned
node against nil, avoiding the allocation entirely. This is the same trick
already used in SingleMatcher.MatchAll, just applied at the HasMatcher call
site so the intermediate slice is never built in the first place. The
SingleMatcher path is kept as a fallback for matchers that don't implement
MatchFirst.

benchstat (count=10):

      | before       |              after   |
      | sec/op       |    sec/op     vs base|
Has-8 | 2.675µ ± 17% | 2.208µ ± 17%  -17.47%|

      | before       |             after    |
      |  B/op        |    B/op     vs base  |
Has-8 | 360.0 ± 0%   | 240.0 ± 0%  -33.33%  |

      | before       |             after    |
      |allocs/op     | allocs/op   vs base  |
Has-8 | 20.00 ± 0%   |  6.000 ± 0%  -70.00% |
2026-05-30 23:18:55 +02:00
jvoisin 116f677017 perf: collect parent nodes inline in getParentNodes
getParentNodes ran through mapNodes with a callback that allocated a
throwaway one-element []*html.Node{n.Parent} for every source node. Since
sibling nodes share the same parent, almost all of those slices were
immediately discarded as duplicates.

This commit collects the parents directly in a single loop, deduplicating via a
set as we go, so no per-node slice is allocated.

benchstat (count=10, all p=0.000):

                 |   before    |             after            |
                 |   sec/op    |    sec/op     vs base        |
Parent-8         | 38.54µ ± 21%|  17.49µ ± 14%  -54.63%       |
ParentFiltered-8 | 46.57µ ± 13%|  20.91µ ± 24%  -55.09%       |

                 |   before    |             after            |
                 |    B/op     |     B/op      vs base        |
Parent-8         | 13.24Ki ± 0%|  10.28Ki ± 0%  -22.36%       |
ParentFiltered-8 | 13.49Ki ± 0%|  10.58Ki ± 0%  -21.60%       |

                 |   before    |            after             |
                 |  allocs/op  | allocs/op   vs base          |
Parent-8         |  384.0 ± 0% | 10.00 ± 0%  -97.40%          |
ParentFiltered-8 |  392.0 ± 0% | 19.00 ± 0%  -95.15%          |
2026-05-30 22:52:34 +02:00
jvoisin f4b3a3e2c4 perf: presize result slice in getChildrenWithSiblingType
For the sibling types that collect every match (siblingAll,
siblingAllIncludingNonElements, siblingPrevAll, siblingNextAll), do a
cheap first-pass pointer walk to count the matches, then make the result
slice with that exact capacity. This removes the repeated append-driven
slice growth that dominated allocations on these hot traversal paths.

The Until cases are left untouched because counting would require running
the user predicate twice, and the single-result Next/Prev cases have
nothing to presize.

This drives Children/Contents/Siblings/Next*/Prev* through fewer
allocations:

                       allocs/op vs base    B/op vs base
    Siblings            -57.23%             -9.23%
    SiblingsFiltered    -54.49%             -8.94%
    NextAll             -59.82%            -10.04%
    NextAllFiltered     -55.83%             -9.65%
    PrevAll             -55.32%            -10.66%
    PrevAllFiltered     -52.53%            -10.40%
    ChildrenFiltered    -15.38%             -6.45%
    Contents             -7.69%             -1.73%
    geomean             -25.07%             -3.88%

The Until and single Next/Prev benchmarks are unchanged (bit-identical
allocs/op), confirming the count pre-pass only touches the collect-all
paths.

The quite verbose comment on top of the change is there so that future
generations won't waste time wondering why this weird loop is here.
2026-05-29 23:45:29 +02:00
jvoisin 9285d9bf28 perf: presize set in appendWithoutDuplicates to final expected size
BenchmarkAddNodesBig (DocW().Find("li") duplicated to ~1500 nodes,
then AddNodes onto an empty selection), -count=20:

                 sec/op             B/op           allocs/op
    before    214.3µs ±12%     27.58Ki ±0%       24
    after     104.8µs ±24%     45.24Ki ±0%       16
                  -51%             +64%          -33%

The B/op increase is the cost of presizing the map to its final
capacity in one shot rather than letting the runtime grow it
through smaller bucket arrays (which are freed but counted in
total bytes allocated). Peak resident memory is comparable.
2026-05-29 22:02:39 +02:00
Martin AngersandGitHub fa0370e85c Merge pull request #564 from jvoisin/hasm
perf: probe subtrees directly in Has / HasMatcher
2026-05-29 13:17:41 -04:00
jvoisin 9082a116aa perf: probe subtrees directly in Has / HasMatcher
Has(selector) and HasMatcher(m) used to call
s.document.Find(selector)/FindMatcher(m), materializing every matching
descendant in the entire document, then iterate s.Nodes x matchedNodes x
treeDepth via nodeContains to keep the elements that contain one.

This commit probes each selection node's subtree directly with SingleMatcher, which
short-circuits on the first descendant match.

BenchmarkHas (DocW().Find("h2").Has(".editsection")), -count=10:

               sec/op       B/op
    before     61.97µs       744 B
    after       3.33µs       360 B
                -94.6%      -51.6%
2026-05-29 17:19:03 +02:00
Martin AngersandGitHub 7a46fb40e9 Merge pull request #563 from jvoisin/precise
perf: presize node-set maps in mapNodes, ClosestNodes and winnowNodes
2026-05-28 10:31:02 -04:00
Martin AngersandGitHub bd4f51414c Merge branch 'master' into precise 2026-05-28 10:21:55 -04:00
Martin AngersandGitHub 59eb2061a0 Merge pull request #561 from jvoisin/noclosure
perf: speed up Selection.Text by outlining recursion
2026-05-28 10:19:23 -04:00
Martin AngersandGitHub 7f540a45a4 Merge pull request #560 from jvoisin/if
perf: pre-check raw class attribute in HasClass
2026-05-28 10:17:55 -04:00
Martin AngersandGitHub d260d2936f Merge pull request #558 from jvoisin/has-nodes-direct
perf: walk s.Nodes directly in HasNodes
2026-05-28 10:15:26 -04:00
Martin AngersandGitHub b2b7542bc5 Merge pull request #557 from jvoisin/winnow-nodes-direct
perf: drop grep/closure indirection in winnowNodes
2026-05-28 10:09:16 -04:00
Martin AngersandGitHub c38303d8bf Merge pull request #550 from jvoisin/nomap
perf: skip dedup map in mapNodes for single-node selections
2026-05-28 10:05:55 -04:00
Martin AngersandGitHub 32e5d34ae4 Merge pull request #547 from jvoisin/idx
perf: replace Index() PrevAll traversal with direct sibling walk
2026-05-28 10:03:04 -04:00
Martin AngersandGitHub 69b9b29a1f Merge pull request #559 from jvoisin/is-matcher-early-return
perf: short-circuit IsMatcher on the first match
2026-05-28 09:52:50 -04:00
jvoisin 5f07eebb27 perf: presize node-set maps in mapNodes, ClosestNodes and winnowNodes
Three internal helpers build a map[*html.Node]bool from a known set of
nodes (or use one as a dedup accumulator over a known number of input
nodes) but allocate the map without a size hint. They then incur
grow-and-double rehashing as entries are inserted.

Pass len(nodes) as the size hint at make time:

- traversal.go mapNodes: dedup map shared by appendWithoutDuplicates
  across every per-node result. mapNodes is the engine behind most
  traversal helpers (Parents, Children, Next/Prev, Siblings, Find*,
  Contents, ...), so the impact is broad.
- traversal.go ClosestNodes: target-node set built from nodes...
- filter.go winnowNodes: large-N path's lookup set built from nodes...

Double-digit performance gains across ~all benchmarks.
2026-05-27 23:11:36 +02:00
jvoisin 25b585d336 perf: skip dedup map in mapNodes for single-node selections
mapNodes always allocated a map[*html.Node]bool for deduplication, even
when called with a single source node where duplicates cannot arise.
Short-circuit to return the callback result directly when len(nodes)==1.
Also add a special case for when there are zero nodes.

This benefits all traversal methods (Find, Children, Parent, Next, Prev,
etc.) when operating on a single-node selection, which is the common
case after First(), Eq(), or directly on a Document.

name      old ns/op   new ns/op   delta
Find-8    23200       14200       -38.8%

name      old B/op    new B/op    delta
Find-8    4632        1496        -67.7%

name      old allocs  new allocs  delta
Find-8    27          13          -51.9%
2026-05-27 21:17:38 +02:00
jvoisin ece69fc2fd perf: speed up Selection.Text by outlining recursion
Replace the recursive closure in Selection.Text with a helper method call.
This keeps behavior unchanged while reducing runtime overhead in the hot path.

Benchmark (BenchmarkText, benchstat, n=12):
- sec/op: 2.133us -> 1.692us (-20.70%, p=0.000)
- B/op: 504 -> 504 (no change)
- allocs/op: 6 -> 6 (no change)
2026-05-27 21:12:36 +02:00
jvoisin 3b71495757 perf: pre-check raw class attribute in HasClass
HasClass normalized every matching element's class attribute with
classTrimReplacer before checking for the target token, even when the
raw attribute value could not possibly contain that class at all.

Add a cheap strings.Contains(attr.Val, rawClass) pre-check and only run
the replacer on plausible matches. This preserves behavior for class
attributes containing tabs/newlines while skipping most of the work on
misses.

BenchmarkHasClass on arm64 over 10 runs:

name         old time/op   new time/op   delta
HasClass-8    9.809µs       2.660µs      -72.89% (p=0.000)
2026-05-27 15:26:22 +02:00
jvoisin 535ec7c4f2 perf: short-circuit IsMatcher on the first match
IsMatcher handled multi-node selections by calling m.Filter(s.Nodes) and
checking whether the returned slice was non-empty, forcing the matcher to walk
the whole selection and materialize all matches even though the API only needs
an existence check.

This commit replaces this with a simple loop over s.Nodes that calls m.Match(n)
and returns true on the first match. This also makes the single-node special
case unnecessary.

Benchmark results on arm64 over 10 runs:

name            old time/op   new time/op   delta
Is-8             4.67µs        1.32µs       -71.7%
IsPositional-8   21.44µs       0.82µs       -96.2%

name            old B/op      new B/op      delta
Is-8             96           80           -16.7%
IsPositional-8   1144         184          -83.9%

name            old allocs    new allocs    delta
Is-8             5            4            -20.0%
IsPositional-8   11           7            -36.4%
2026-05-27 15:19:58 +02:00
jvoisin bfc9c5940d perf: walk s.Nodes directly in HasNodes
HasNodes routed through FilterFunction -> winnowFunction -> grep, which
allocated a single-node Selection and a closure capture per source element,
just to call sel.Contains(n) which is itself a one-iteration sliceContains over
the wrapped node. This commit inlines a direct double loop over s.Nodes x nodes
calling nodeContains, and push the result via pushStack.

name             old allocs/op  new allocs/op  delta
Has-8                  55.00 ± 0%     24.00 ± 0%  -56.36% (p=0.000)
HasNodes-8            752.00 ± 0%      4.00 ± 0%  -99.47% (p=0.000)
HasSelection-8        752.00 ± 0%      3.00 ± 0%  -99.60% (p=0.000)
2026-05-27 12:11:29 +02:00
jvoisin 8ea13ea017 perf: drop grep/closure indirection in winnowNodes
In the same spirit as #545, replaces costly grep() with a loop.

While escape analysis managed to stack-allocate the per-node
Selection through grep's inlining, the closure capture itself (nodes and keep)
was still heap-allocated. Local benchmarks are showing a 30% allocs/op
reduction, and a ~10-20% reduction in sec/op.
2026-05-27 11:52:43 +02:00
Martin AngersandGitHub e2468fa06d Merge pull request #552 from jvoisin/spaceclass
perf: replace regex+Split with strings.Fields in getClassesSlice
2026-05-26 09:12:25 -04:00
Martin AngersandGitHub 5350c00a95 Merge branch 'master' into spaceclass 2026-05-26 09:11:05 -04:00
Martin AngersandGitHub af20c4b0a0 Merge pull request #553 from jvoisin/slyce
perf: call Match/isInSlice directly in Until traversal helpers
2026-05-26 09:00:57 -04:00
Martin AngersandGitHub b27992034a Merge pull request #551 from jvoisin/nore
perf: replace rxClassTrim regexp with strings.Replacer
2026-05-26 08:59:19 -04:00
Martin AngersandGitHub 67dec88260 Merge pull request #548 from jvoisin/shortcircuit
perf: short-circuit IsFunction, IsSelection, and IsNodes
2026-05-26 08:53:05 -04:00
Martin AngersandGitHub cb1755d318 Merge pull request #554 from jvoisin/gpn
perf: drop per-parent Selection allocation in getParentsNodes
2026-05-26 08:49:01 -04:00
jvoisin 5d01e23f35 perf: drop per-parent Selection allocation in getParentsNodes
Mirrors #553 for getSiblingNodes, now extended to the ancestor traversal path.
2026-05-25 20:52:51 +02:00
jvoisin b7030c526f perf: call Match/isInSlice directly in Until traversal helpers
getParentsNodes and getSiblingNodes created a newSingleSelection per
ancestor/sibling node just to call IsMatcher or IsNodes for the
until-stop condition. Since these are always single-node checks, call
stopm.Match(p) and isInSlice(stopNodes, p) directly, avoiding the
Selection allocation and the method dispatch overhead.

name                      old ns/op   new ns/op   delta
ParentsUntil-8            30700       16100       -47.6%
ParentsUntilNodes-8       82700       28400       -65.7%
NextUntil-8               38000       25700       -32.4%
NextUntilNodes-8          13700       5900        -56.9%
PrevUntil-8               109600      82600       -24.6%
PrevUntilNodes-8          11600       4600        -60.3%

name                      old B/op    new B/op    delta
ParentsUntil-8            11528       4248        -63.2%
ParentsUntilNodes-8       39040       10448       -73.2%
NextUntil-8               8592        7712        -10.2%
NextUntilNodes-8          5536        1304        -76.4%
PrevUntil-8               30344       28280       -6.8%
PrevUntilNodes-8          4248        920         -78.3%

name                      old allocs  new allocs  delta
ParentsUntil-8            304         44          -85.5%
ParentsUntilNodes-8       904         60          -93.4%
NextUntil-8               216         106         -50.9%
NextUntilNodes-8          156         27          -82.7%
PrevUntil-8               408         150         -63.2%
PrevUntilNodes-8          129         23          -82.2%
2026-05-24 22:48:16 +02:00
jvoisin c19544b145 perf: replace regex+Split with strings.Fields in getClassesSlice
getClassesSlice used rxClassTrim.ReplaceAllString followed by
strings.Split, which allocated twice and produced empty strings from
leading/trailing spaces. strings.Fields handles all Unicode whitespace
in a single pass with no empty entries.

name             old ns/op   new ns/op   delta
AddClass-8       176700      107500      -39.2%
RemoveClass-8    142900      64600       -54.8%

name             old B/op    new B/op    delta
AddClass-8       12544       7024        -44.0%
RemoveClass-8    6503        1777        -72.7%

name             old allocs  new allocs  delta
AddClass-8       697         374         -46.3%
RemoveClass-8    697         374         -46.3%
2026-05-24 22:38:53 +02:00
jvoisin 41a5c22149 perf: replace rxClassTrim regexp with strings.Replacer
The regex [\t\r\n] only does single-byte substitution, which is exactly
what strings.Replacer's byteReplacer is optimized for. This removes the
regexp dependency from property.go and drops the redundant ContainsAny
fast-path guard in HasClass, since byteReplacer already returns the
original string unchanged (no allocation) when nothing matches.

Benchmark shows ~18% improvement on HasClass with 0 allocs either way.
2026-05-24 22:30:18 +02:00
Martin AngersandGitHub 83a0a8bbd8 Merge pull request #545 from jvoisin/nog
perf: avoid per-node Selection allocation in winnow Not path
2026-05-24 10:19:43 -04:00
Martin AngersandGitHub cb2cc9d25e Merge pull request #549 from jvoisin/lack_of_class
perf: skip regex in getClassesAndAttr when no \t\r\n present
2026-05-24 10:07:42 -04:00
jvoisin 3c647f82fe perf: skip regex in getClassesAndAttr when no \t\r\n present
getClassesAndAttr unconditionally ran rxClassTrim.ReplaceAllString to
normalize tabs, carriage returns, and newlines in class attribute values.
The vast majority of HTML class attributes contain none of these, so add
a strings.ContainsAny fast path that falls back to simple concatenation.

This benefits all callers: HasClass, AddClass, RemoveClass, ToggleClass. Here's
a benchmark of HasClass to illustrate the gains:

name          old ns/op   new ns/op   delta
HasClass-8    222000      45300       -79.6%

name          old B/op    new B/op    delta
HasClass-8    19152       4280        -77.6%

name          old allocs  new allocs  delta
HasClass-8    1300        325         -75.0%
2026-05-23 23:11:00 +02:00
jvoisin d94aeb90e8 perf: short-circuit IsFunction, IsSelection, and IsNodes
These methods were building a full filtered Selection (via
FilterFunction/FilterSelection/FilterNodes -> winnow -> grep) only to
check Length() > 0. This commit replaces with early-return loops that stop at
the first match, significantly speeding them up:

name             old ns/op   new ns/op   delta
IsFunction-8     1530        3.9         -99.7%
IsSelection-8    1890        19.9        -98.9%
IsNodes-8        2016        19.9        -99.0%

name             old B/op    new B/op    delta
IsFunction-8     784         0           -100.0%
IsSelection-8    72          0           -100.0%
IsNodes-8        72          0           -100.0%

name             old allocs  new allocs  delta
IsFunction-8     28          0           -100.0%
IsSelection-8    3           0           -100.0%
IsNodes-8        3           0           -100.0%
2026-05-23 22:57:17 +02:00
jvoisin 5f0afd53e4 perf: replace Index() PrevAll traversal with direct sibling walk
Index() was creating a Selection, calling PrevAll() which traversed
through getSiblingNodes -> mapNodes (allocating a dedup map and
intermediate slices), then calling Length() on the result.

This commit replaces this with a direct PrevSibling pointer walk that counts
element nodes, bringing the number of allocations down to zero instead of a
couple of hundreds on local benchmarks
2026-05-23 22:50:53 +02:00
jvoisin 45d35d506a perf: avoid per-node Selection allocation in winnow Not path
The winnow() "Not" path routed through grep(), which called
newSingleSelection() for every node, allocating a Selection and
a 1-element []*html.Node slice on the heap per node, only to
immediately unwrap it via s.Get(0) for m.Match().
This commit call m.Match(n) directly and pre-allocate the result slice.

It also adds BenchmarkNotMatcher to isolate the winnow path from
compileMatcher overhead.

Here are my local benchmark results, for BenchmarkNotMatcher with 373 <li>
nodes, and a pre-compiled matcher:

old ns/op   new ns/op   delta
22750       19350       ~-15%

old B/op    new B/op    delta
9384        3120        -66.7%

old allocs  new allocs  delta
11          2           -81.8%
2026-05-23 22:02:15 +02:00
Martin AngersandGitHub 33466f5bcd Merge pull request #544 from PuerkitoBio/dependabot/go_modules/golang.org/x/net-0.55.0
Bump golang.org/x/net from 0.54.0 to 0.55.0
2026-05-22 08:52:45 -04:00
dependabot[bot]andGitHub 33ce051cd9 Bump golang.org/x/net from 0.54.0 to 0.55.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.54.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 05:52:54 +00:00
Martin AngersandGitHub 8c2d01c469 Merge pull request #543 from PuerkitoBio/dependabot/go_modules/golang.org/x/net-0.54.0
Bump golang.org/x/net from 0.53.0 to 0.54.0
2026-05-13 21:34:12 -04:00
dependabot[bot]andGitHub 21b9b64897 Bump golang.org/x/net from 0.53.0 to 0.54.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.53.0 to 0.54.0.
- [Commits](https://github.com/golang/net/compare/v0.53.0...v0.54.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 06:41:06 +00:00
Martin AngersandGitHub c92c45163d Merge pull request #542 from PuerkitoBio/dependabot/go_modules/golang.org/x/net-0.53.0
Bump golang.org/x/net from 0.52.0 to 0.53.0
2026-04-11 09:47:15 -04:00
dependabot[bot]andGitHub 43d03d8533 Bump golang.org/x/net from 0.52.0 to 0.53.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.52.0 to 0.53.0.
- [Commits](https://github.com/golang/net/compare/v0.52.0...v0.53.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 05:52:49 +00:00
Martin Angers 401642b253 Update readme to prepare for 1.12 release 2026-03-15 12:26:31 -04:00
Martin AngersandGitHub afd9326761 Merge pull request #540 from PuerkitoBio/dependabot/go_modules/golang.org/x/net-0.52.0 2026-03-15 12:25:04 -04:00
dependabot[bot]andGitHub f799f78b6e Bump golang.org/x/net from 0.50.0 to 0.52.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.50.0 to 0.52.0.
- [Commits](https://github.com/golang/net/compare/v0.50.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-15 16:21:07 +00:00
Martin Angers 805d7b1d5c Update CI to 1.25 and 1.26 2026-03-15 12:19:53 -04:00
Martin AngersandGitHub 7dc7e6463b Merge pull request #538 from PuerkitoBio/dependabot/go_modules/golang.org/x/net-0.50.0 2026-02-13 10:10:41 -05:00
dependabot[bot]andGitHub 3020ebb46d Bump golang.org/x/net from 0.49.0 to 0.50.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.49.0 to 0.50.0.
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-10 05:52:35 +00:00
Martin AngersandGitHub ee4bcdb889 Merge pull request #537 from PuerkitoBio/dependabot/go_modules/golang.org/x/net-0.49.0
Bump golang.org/x/net from 0.48.0 to 0.49.0
2026-01-17 10:29:26 -05:00
dependabot[bot]andGitHub f3bc303dcb Bump golang.org/x/net from 0.48.0 to 0.49.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-13 07:22:25 +00:00
Martin AngersandGitHub 0f22828bca Merge pull request #536 from PuerkitoBio/dependabot/go_modules/golang.org/x/net-0.48.0
Bump golang.org/x/net from 0.47.0 to 0.48.0
2025-12-23 09:13:59 -05:00
dependabot[bot]andGitHub 68de6a15bb Bump golang.org/x/net from 0.47.0 to 0.48.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.47.0 to 0.48.0.
- [Commits](https://github.com/golang/net/compare/v0.47.0...v0.48.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-09 05:05:12 +00:00
Martin AngersandGitHub 8ef6c9c294 Merge pull request #535 from PuerkitoBio/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 5 to 6
2025-11-21 16:01:03 -05:00
dependabot[bot]andGitHub ffba93fcef Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 05:20:57 +00:00
18 changed files with 583 additions and 237 deletions
+3 -3
View File
@@ -8,16 +8,16 @@ jobs:
test:
strategy:
matrix:
go-version: [1.24.x, 1.25.x]
go-version: [1.26.x, 1.27.x]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v7
- name: Install Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ matrix.go-version }}
+5
View File
@@ -24,6 +24,7 @@ Syntax-wise, it is as close as possible to jQuery, with the same function names
Required Go version:
* Starting with version `v1.12.0` of goquery, Go 1.25+ is required due to its dependencies.
* Starting with version `v1.11.0` of goquery, Go 1.24+ is required due to its dependencies.
* Starting with version `v1.10.0` of goquery, Go 1.23+ is required due to the use of function-based iterators.
* For `v1.9.0` of goquery, Go 1.18+ is required due to the use of generics.
@@ -47,6 +48,8 @@ Ongoing goquery development is tested on the latest 2 versions of Go.
**Note that goquery's API is now stable, and will not break.**
* **2026-08-27 (v1.13.0)** : Performance improvements (thanks [@jvoisin][jvs]), add top-level `Text` function with options similar to BeautifulSoup's `get_text` (thanks [@ChrisJr404][chrisjr]), update `go.mod` dependencies, add go1.27 to the test matrix.
* **2026-03-15 (v1.12.0)** : Update `go.mod` dependencies, add go1.26 to the test matrix, **goquery now requires Go version 1.25+**.
* **2025-11-16 (v1.11.0)** : Update `go.mod` dependencies, add go1.25 to the test matrix, **goquery now requires Go version 1.24+**.
* **2025-04-11 (v1.10.3)** : Update `go.mod` dependencies, small optimization (thanks [@myxzlpltk](https://github.com/myxzlpltk)).
* **2025-02-13 (v1.10.2)** : Update `go.mod` dependencies, add go1.24 to the test matrix.
@@ -214,3 +217,5 @@ The [BSD 3-Clause license][bsd], the same as the [Go language][golic]. Cascadia'
[goq]: https://github.com/andrewstuart/goq
[thiemok]: https://github.com/thiemok
[djw]: https://github.com/davidjwilkins
[jvs]: https://github.com/jvoisin
[chrisjr]: https://github.com/ChrisJr404
+7 -1
View File
@@ -81,7 +81,13 @@ func (s *Selection) Get(index int) *html.Node {
// relative to its sibling elements.
func (s *Selection) Index() int {
if len(s.Nodes) > 0 {
return newSingleSelection(s.Nodes[0], s.document).PrevAll().Length()
i := 0
for n := s.Nodes[0].PrevSibling; n != nil; n = n.PrevSibling {
if n.Type == html.ElementNode {
i++
}
}
return i
}
return -1
}
+21
View File
@@ -2,6 +2,8 @@ package goquery
import (
"testing"
"github.com/andybalholm/cascadia"
)
func BenchmarkFilter(b *testing.B) {
@@ -40,6 +42,25 @@ func BenchmarkNot(b *testing.B) {
}
}
func BenchmarkNotMatcher(b *testing.B) {
var n int
b.StopTimer()
sel := DocW().Find("li")
m := cascadia.MustCompile(".toclevel-2")
b.StartTimer()
for i := 0; i < b.N; i++ {
if n == 0 {
n = sel.NotMatcher(m).Length()
} else {
sel.NotMatcher(m)
}
}
if n != 371 {
b.Fatalf("want 371, got %d", n)
}
}
func BenchmarkFilterFunction(b *testing.B) {
var n int
+19
View File
@@ -49,3 +49,22 @@ func BenchmarkHtml(b *testing.B) {
_, _ = sel.Html()
}
}
func BenchmarkAddClass(b *testing.B) {
b.StopTimer()
sel := DocW().Find("li")
b.StartTimer()
for i := 0; i < b.N; i++ {
sel.AddClass("foo bar baz")
}
}
func BenchmarkRemoveClass(b *testing.B) {
b.StopTimer()
sel := DocW().Find("li")
sel.AddClass("foo bar baz")
b.StartTimer()
for i := 0; i < b.N; i++ {
sel.RemoveClass("foo bar baz")
}
}
+38
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/PuerkitoBio/goquery"
"golang.org/x/net/html"
)
// This example scrapes the reviews shown on the home page of metalsucks.net.
@@ -108,3 +109,40 @@ func ExampleSingle() {
// 123
// 1
}
// This example shows how to use the goquery.Text function to extract clean,
// human-readable text from a selection, similar to BeautifulSoup's get_text.
func ExampleText() {
page := `
<html>
<body>
<div id="content">
<h1> Hello </h1>
<p>world</p>
<script>var ignored = 1;</script>
</div>
</body>
</html>
`
doc, err := goquery.NewDocumentFromReader(strings.NewReader(page))
if err != nil {
log.Fatal(err)
}
// Trim each text node, join the remaining ones with a space, and skip the
// text of <script> and <style> elements.
text := goquery.Text(doc.Find("#content"), &goquery.TextOptions{
Separator: " ",
Trim: true,
Keep: func(n *html.Node) bool {
if p := n.Parent; p != nil && p.Type == html.ElementNode {
return p.Data != "script" && p.Data != "style"
}
return true
},
})
fmt.Println(text)
// Output:
// Hello world
}
+53 -24
View File
@@ -12,7 +12,7 @@ func (s *Selection) Filter(selector string) *Selection {
// the given matcher. It returns a new Selection object for this subset
// of matching elements.
func (s *Selection) FilterMatcher(m Matcher) *Selection {
return pushStack(s, winnow(s, m, true))
return pushStack(s, winnow(s.Nodes, m, true))
}
// Not removes elements from the Selection that match the selector string.
@@ -24,7 +24,7 @@ func (s *Selection) Not(selector string) *Selection {
// NotMatcher removes elements from the Selection that match the given matcher.
// It returns a new Selection object with the matching elements removed.
func (s *Selection) NotMatcher(m Matcher) *Selection {
return pushStack(s, winnow(s, m, false))
return pushStack(s, winnow(s.Nodes, m, false))
}
// FilterFunction reduces the set of matched elements to those that pass the function's test.
@@ -79,29 +79,46 @@ func (s *Selection) Intersection(sel *Selection) *Selection {
// that matches the selector.
// It returns a new Selection object with the matching elements.
func (s *Selection) Has(selector string) *Selection {
return s.HasSelection(s.document.Find(selector))
return s.HasMatcher(compileMatcher(selector))
}
// HasMatcher reduces the set of matched elements to those that have a descendant
// that matches the matcher.
// It returns a new Selection object with the matching elements.
func (s *Selection) HasMatcher(m Matcher) *Selection {
return s.HasSelection(s.document.FindMatcher(m))
result := make([]*html.Node, 0, len(s.Nodes))
// Manually create a singleMatcher (rather than using SingleMatcher, which
// returns a Matcher interface) so we can call its MatchFirst method
// directly. MatchFirst probes for the first match in a subtree without
// building a result slice, avoiding a throwaway one-element slice
// allocation for every matching child subtree.
sm := singleMatcher{m}
for _, n := range s.Nodes {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode && sm.MatchFirst(c) != nil {
result = append(result, n)
break
}
}
}
return pushStack(s, result)
}
// HasNodes reduces the set of matched elements to those that have a
// descendant that matches one of the nodes.
// It returns a new Selection object with the matching elements.
func (s *Selection) HasNodes(nodes ...*html.Node) *Selection {
return s.FilterFunction(func(_ int, sel *Selection) bool {
// Add all nodes that contain one of the specified nodes
for _, n := range nodes {
if sel.Contains(n) {
return true
var result []*html.Node
for _, n := range s.Nodes {
for _, candidate := range nodes {
if nodeContains(n, candidate) {
result = append(result, n)
break
}
}
return false
})
}
return pushStack(s, result)
}
// HasSelection reduces the set of matched elements to those that have a
@@ -125,33 +142,45 @@ func (s *Selection) End() *Selection {
// Filter based on the matcher, and the indicator to keep (Filter) or
// to get rid of (Not) the matching elements.
func winnow(sel *Selection, m Matcher, keep bool) []*html.Node {
func winnow(nodes []*html.Node, m Matcher, keep bool) []*html.Node {
// Optimize if keep is requested
if keep {
return m.Filter(sel.Nodes)
return m.Filter(nodes)
}
// Use grep
return grep(sel, func(i int, s *Selection) bool {
return !m.Match(s.Get(0))
})
// Not path: call Match directly on each node, no Selection wrapper needed
result := make([]*html.Node, 0, len(nodes))
for _, n := range nodes {
if !m.Match(n) {
result = append(result, n)
}
}
return result
}
// Filter based on an array of nodes, and the indicator to keep (Filter) or
// to get rid of (Not) the matching elements.
func winnowNodes(sel *Selection, nodes []*html.Node, keep bool) []*html.Node {
if len(nodes)+len(sel.Nodes) < minNodesForSet {
return grep(sel, func(i int, s *Selection) bool {
return isInSlice(nodes, s.Get(0)) == keep
})
var result []*html.Node
for _, n := range sel.Nodes {
if isInSlice(nodes, n) == keep {
result = append(result, n)
}
}
return result
}
set := make(map[*html.Node]bool)
set := make(map[*html.Node]bool, len(nodes))
for _, n := range nodes {
set[n] = true
}
return grep(sel, func(i int, s *Selection) bool {
return set[s.Get(0)] == keep
})
var result []*html.Node
for _, n := range sel.Nodes {
if set[n] == keep {
result = append(result, n)
}
}
return result
}
// Filter based on a function test, and the indicator to keep (Filter) or
+3 -5
View File
@@ -1,10 +1,8 @@
module github.com/PuerkitoBio/goquery
require (
github.com/andybalholm/cascadia v1.3.3
golang.org/x/net v0.47.0
github.com/andybalholm/cascadia v1.3.5
golang.org/x/net v0.58.0
)
go 1.24.0
toolchain go1.24.1
go 1.25.0
+4 -69
View File
@@ -1,69 +1,4 @@
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8=
github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+44 -42
View File
@@ -1,6 +1,7 @@
package goquery
import (
"slices"
"strings"
"golang.org/x/net/html"
@@ -166,8 +167,16 @@ func (s *Selection) Clone() *Selection {
// Empty removes all children nodes from the set of matched elements.
// It returns the children nodes in a new Selection.
func (s *Selection) Empty() *Selection {
var nodes []*html.Node
// Count the children first so nodes can be presized, avoiding append's
// repeated reallocations.
count := 0
for _, n := range s.Nodes {
for c := n.FirstChild; c != nil; c = c.NextSibling {
count++
}
}
nodes := make([]*html.Node, 0, count)
for _, n := range s.Nodes {
for c := n.FirstChild; c != nil; c = n.FirstChild {
n.RemoveChild(c)
@@ -374,20 +383,14 @@ func (s *Selection) WrapSelection(sel *Selection) *Selection {
//
// It returns the original set of elements.
func (s *Selection) WrapHtml(htmlStr string) *Selection {
nodesMap := make(map[string][]*html.Node)
for _, context := range s.Nodes {
var parent *html.Node
if context.Parent != nil {
parent = context.Parent
} else {
cache := make(map[string][]*html.Node)
for _, n := range s.Nodes {
parent := n.Parent
if parent == nil {
parent = &html.Node{Type: html.ElementNode}
}
nodes, found := nodesMap[nodeName(parent)]
if !found {
nodes = parseHtmlWithContext(htmlStr, parent)
nodesMap[nodeName(parent)] = nodes
}
newSingleSelection(context, s.document).wrapAllNodes(cloneNodes(nodes)...)
nodes := cachedParseHtmlWithContext(cache, htmlStr, parent)
newSingleSelection(n, s.document).wrapAllNodes(cloneNodes(nodes)...)
}
return s
}
@@ -522,14 +525,10 @@ func (s *Selection) WrapInnerSelection(sel *Selection) *Selection {
//
// It returns the original set of elements.
func (s *Selection) WrapInnerHtml(htmlStr string) *Selection {
nodesMap := make(map[string][]*html.Node)
for _, context := range s.Nodes {
nodes, found := nodesMap[nodeName(context)]
if !found {
nodes = parseHtmlWithContext(htmlStr, context)
nodesMap[nodeName(context)] = nodes
}
newSingleSelection(context, s.document).wrapInnerNodes(cloneNodes(nodes)...)
cache := make(map[string][]*html.Node)
for _, n := range s.Nodes {
nodes := cachedParseHtmlWithContext(cache, htmlStr, n)
newSingleSelection(n, s.document).wrapInnerNodes(cloneNodes(nodes)...)
}
return s
}
@@ -562,13 +561,7 @@ func (s *Selection) wrapInnerNodes(ns ...*html.Node) *Selection {
}
func parseHtml(h string) []*html.Node {
// Errors are only returned when the io.Reader returns any error besides
// EOF, but strings.Reader never will
nodes, err := html.ParseFragment(strings.NewReader(h), &html.Node{Type: html.ElementNode})
if err != nil {
panic("goquery: failed to parse HTML: " + err.Error())
}
return nodes
return parseHtmlWithContext(h, &html.Node{Type: html.ElementNode})
}
func parseHtmlWithContext(h string, context *html.Node) []*html.Node {
@@ -626,11 +619,11 @@ func (s *Selection) manipulateNodes(ns []*html.Node, reverse bool,
// net.Html doesn't provide document fragments for insertion, so to get
// things in the correct order with After() and Prepend(), the callback
// needs to be called on the reverse of the nodes.
// needs to be called on the reverse of the nodes. Clone the slice before
// reversing so the caller's slice is not modified.
if reverse {
for i, j := 0, len(ns)-1; i < j; i, j = i+1, j-1 {
ns[i], ns[j] = ns[j], ns[i]
}
ns = slices.Clone(ns)
slices.Reverse(ns)
}
for i, sn := range s.Nodes {
@@ -654,10 +647,9 @@ func (s *Selection) manipulateNodes(ns []*html.Node, reverse bool,
// isParent can be used to indicate that the elements of the selection should be treated as the parent for the parsed html.
// A cache is used to avoid parsing the html multiple times should the elements of the selection result in the same context.
func (s *Selection) eachNodeHtml(htmlStr string, isParent bool, mergeFn func(n *html.Node, nodes []*html.Node)) *Selection {
// cache to avoid parsing the html for the same context multiple times
nodeCache := make(map[string][]*html.Node)
var context *html.Node
cache := make(map[string][]*html.Node)
for _, n := range s.Nodes {
var context *html.Node
if isParent {
context = n.Parent
} else {
@@ -666,14 +658,24 @@ func (s *Selection) eachNodeHtml(htmlStr string, isParent bool, mergeFn func(n *
}
context = n
}
if context != nil {
nodes, found := nodeCache[nodeName(context)]
if !found {
nodes = parseHtmlWithContext(htmlStr, context)
nodeCache[nodeName(context)] = nodes
}
mergeFn(n, cloneNodes(nodes))
if context == nil {
continue
}
nodes := cachedParseHtmlWithContext(cache, htmlStr, context)
mergeFn(n, cloneNodes(nodes))
}
return s
}
// cachedParseHtmlWithContext returns parseHtmlWithContext(htmlStr, context), reusing a prior
// result when context's nodeName has already been seen. Callers pass their own
// cache map so the cache lifetime matches the caller's loop scope.
func cachedParseHtmlWithContext(cache map[string][]*html.Node, htmlStr string, context *html.Node) []*html.Node {
key := nodeName(context)
if nodes, ok := cache[key]; ok {
return nodes
}
nodes := parseHtmlWithContext(htmlStr, context)
cache[key] = nodes
return nodes
}
+57 -53
View File
@@ -1,36 +1,31 @@
package goquery
import (
"regexp"
"strings"
"golang.org/x/net/html"
)
var rxClassTrim = regexp.MustCompile("[\t\r\n]")
var classTrimReplacer = strings.NewReplacer("\t", " ", "\r", " ", "\n", " ")
// Attr gets the specified attribute's value for the first element in the
// Selection. To get the value for each element individually, use a looping
// construct such as Each or Map method.
func (s *Selection) Attr(attrName string) (val string, exists bool) {
if len(s.Nodes) == 0 {
return
func (s *Selection) Attr(attrName string) (string, bool) {
if len(s.Nodes) != 0 {
if attr := getAttributePtr(attrName, s.Nodes[0]); attr != nil {
return attr.Val, true
}
}
return getAttributeValue(attrName, s.Nodes[0])
return "", false
}
// AttrOr works like Attr but returns default value if attribute is not present.
func (s *Selection) AttrOr(attrName, defaultValue string) string {
if len(s.Nodes) == 0 {
return defaultValue
if val, exists := s.Attr(attrName); exists {
return val
}
val, exists := getAttributeValue(attrName, s.Nodes[0])
if !exists {
return defaultValue
}
return val
return defaultValue
}
// RemoveAttr removes the named attribute from each element in the set of matched elements.
@@ -60,27 +55,23 @@ func (s *Selection) SetAttr(attrName, val string) *Selection {
// elements, including their descendants.
func (s *Selection) Text() string {
var builder strings.Builder
// Slightly optimized vs calling Each: no single selection object created
var f func(*html.Node)
f = func(n *html.Node) {
if n.Type == html.TextNode {
// Keep newlines and spaces, like jQuery
builder.WriteString(n.Data)
}
if n.FirstChild != nil {
for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}
}
for _, n := range s.Nodes {
f(n)
s.textHelper(n, &builder)
}
return builder.String()
}
func (s *Selection) textHelper(n *html.Node, builder *strings.Builder) {
if n.Type == html.TextNode {
// Keep newlines and spaces, like jQuery
builder.WriteString(n.Data)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
s.textHelper(c, builder)
}
}
// Size is an alias for Length.
func (s *Selection) Size() int {
return s.Length()
@@ -122,7 +113,7 @@ func (s *Selection) AddClass(class ...string) *Selection {
tcls := getClassesSlice(classStr)
for _, n := range s.Nodes {
curClasses, attr := getClassesAndAttr(n, true)
curClasses, attr := getClassesAndAttr(n)
for _, newClass := range tcls {
if !strings.Contains(curClasses, " "+newClass+" ") {
curClasses += newClass + " "
@@ -138,11 +129,20 @@ func (s *Selection) AddClass(class ...string) *Selection {
// HasClass determines whether any of the matched elements are assigned the
// given class.
func (s *Selection) HasClass(class string) bool {
rawClass := class
class = " " + class + " "
for _, n := range s.Nodes {
classes, _ := getClassesAndAttr(n, false)
if strings.Contains(classes, class) {
return true
if n.Type != html.ElementNode {
continue
}
if attr := getAttributePtr("class", n); attr != nil {
if !strings.Contains(attr.Val, rawClass) {
continue
}
val := classTrimReplacer.Replace(attr.Val)
if strings.Contains(" "+val+" ", class) {
return true
}
}
}
return false
@@ -165,9 +165,9 @@ func (s *Selection) RemoveClass(class ...string) *Selection {
if remove {
removeAttr(n, "class")
} else {
classes, attr := getClassesAndAttr(n, true)
classes, attr := getClassesAndAttr(n)
for _, rcl := range rclasses {
classes = strings.ReplaceAll(classes, " "+rcl+" ", " ")
classes = removeClassFromClasses(classes, rcl)
}
setClasses(n, attr, classes)
@@ -189,11 +189,10 @@ func (s *Selection) ToggleClass(class ...string) *Selection {
tcls := getClassesSlice(classStr)
for _, n := range s.Nodes {
classes, attr := getClassesAndAttr(n, true)
classes, attr := getClassesAndAttr(n)
for _, tcl := range tcls {
spaceAroundTcl := " " + tcl + " "
if strings.Contains(classes, spaceAroundTcl) {
classes = strings.ReplaceAll(classes, spaceAroundTcl, " ")
if strings.Contains(classes, " "+tcl+" ") {
classes = removeClassFromClasses(classes, tcl)
} else {
classes += tcl + " "
}
@@ -205,6 +204,20 @@ func (s *Selection) ToggleClass(class ...string) *Selection {
return s
}
// removeClassFromClasses removes every occurrence of cl from the normalized
// class string. One ReplaceAll pass is not enough: adjacent occurrences
// share the space between them, so " a a " holds only one " a ".
func removeClassFromClasses(classes, cl string) string {
target := " " + cl + " "
for {
replaced := strings.Replace(classes, target, " ", 1)
if replaced == classes {
return classes
}
classes = replaced
}
}
func getAttributePtr(attrName string, n *html.Node) *html.Attribute {
if n == nil {
return nil
@@ -218,21 +231,12 @@ func getAttributePtr(attrName string, n *html.Node) *html.Attribute {
return nil
}
// Private function to get the specified attribute's value from a node.
func getAttributeValue(attrName string, n *html.Node) (val string, exists bool) {
if a := getAttributePtr(attrName, n); a != nil {
val = a.Val
exists = true
}
return
}
// Get and normalize the "class" attribute from the node.
func getClassesAndAttr(n *html.Node, create bool) (classes string, attr *html.Attribute) {
func getClassesAndAttr(n *html.Node) (classes string, attr *html.Attribute) {
// Applies only to element nodes
if n.Type == html.ElementNode {
attr = getAttributePtr("class", n)
if attr == nil && create {
if attr == nil {
n.Attr = append(n.Attr, html.Attribute{
Key: "class",
Val: "",
@@ -244,14 +248,14 @@ func getClassesAndAttr(n *html.Node, create bool) (classes string, attr *html.At
if attr == nil {
classes = " "
} else {
classes = rxClassTrim.ReplaceAllString(" "+attr.Val+" ", " ")
classes = classTrimReplacer.Replace(" " + attr.Val + " ")
}
return
}
func getClassesSlice(classes string) []string {
return strings.Split(rxClassTrim.ReplaceAllString(" "+classes+" ", " "), " ")
return strings.Fields(classes)
}
func removeAttr(n *html.Node, attrName string) {
+59
View File
@@ -250,3 +250,62 @@ func TestToggleClass(t *testing.T) {
t.Errorf("Expected #nf1 to have no classes, have %q", a)
}
}
func TestRemoveClassRepeatedInSource(t *testing.T) {
cases := []struct {
class string
remove string
expected string
}{
{"a a", "a", ""},
{"a a a", "a", ""},
{"a b a", "a", "b"},
{"a b a b", "a", "b b"},
{"a b a b", "a b", ""},
{"a a b", "a", "b"},
{"b a a", "a", "b"},
}
for _, c := range cases {
doc, err := NewDocumentFromReader(strings.NewReader(`<div id="t" class="` + c.class + `"></div>`))
if err != nil {
t.Fatal(err)
}
sel := doc.Find("#t")
sel.RemoveClass(c.remove)
got, _ := sel.Attr("class")
if got != c.expected {
t.Errorf("class=%q RemoveClass(%q): got class %q, want %q", c.class, c.remove, got, c.expected)
}
for _, removed := range strings.Fields(c.remove) {
if sel.HasClass(removed) {
t.Errorf("class=%q RemoveClass(%q): still has class %q", c.class, c.remove, removed)
}
}
}
}
func TestToggleClassRepeatedInSource(t *testing.T) {
doc, err := NewDocumentFromReader(strings.NewReader(`<div id="t" class="a a b"></div>`))
if err != nil {
t.Fatal(err)
}
sel := doc.Find("#t")
sel.ToggleClass("a")
if sel.HasClass("a") {
t.Error("expected #t to not have class a after toggling it off")
}
if !sel.HasClass("b") {
t.Error("expected #t to keep class b")
}
sel.ToggleClass("a")
if !sel.HasClass("a") {
t.Error("expected #t to have class a after toggling it back on")
}
}
+22 -12
View File
@@ -1,6 +1,10 @@
package goquery
import "golang.org/x/net/html"
import (
"slices"
"golang.org/x/net/html"
)
// Is checks the current matched set of elements against a selector and
// returns true if at least one of these elements matches.
@@ -11,32 +15,38 @@ func (s *Selection) Is(selector string) bool {
// IsMatcher checks the current matched set of elements against a matcher and
// returns true if at least one of these elements matches.
func (s *Selection) IsMatcher(m Matcher) bool {
if len(s.Nodes) > 0 {
if len(s.Nodes) == 1 {
return m.Match(s.Nodes[0])
}
return len(m.Filter(s.Nodes)) > 0
}
return false
return slices.ContainsFunc(s.Nodes, m.Match)
}
// IsFunction checks the current matched set of elements against a predicate and
// returns true if at least one of these elements matches.
func (s *Selection) IsFunction(f func(int, *Selection) bool) bool {
return s.FilterFunction(f).Length() > 0
for i, n := range s.Nodes {
if f(i, newSingleSelection(n, s.document)) {
return true
}
}
return false
}
// IsSelection checks the current matched set of elements against a Selection object
// and returns true if at least one of these elements matches.
func (s *Selection) IsSelection(sel *Selection) bool {
return s.FilterSelection(sel).Length() > 0
if sel == nil {
return false
}
return s.IsNodes(sel.Nodes...)
}
// IsNodes checks the current matched set of elements against the specified nodes
// and returns true if at least one of these elements matches.
func (s *Selection) IsNodes(nodes ...*html.Node) bool {
return s.FilterNodes(nodes...).Length() > 0
for _, n := range s.Nodes {
if isInSlice(nodes, n) {
return true
}
}
return false
}
// Contains returns true if the specified Node is within,
+42 -20
View File
@@ -149,7 +149,7 @@ func (s *Selection) ClosestMatcher(m Matcher) *Selection {
// ClosestNodes gets the first element that matches one of the nodes by testing the
// element itself and traversing up through its ancestors in the DOM tree.
func (s *Selection) ClosestNodes(nodes ...*html.Node) *Selection {
set := make(map[*html.Node]bool)
set := make(map[*html.Node]bool, len(nodes))
for _, n := range nodes {
set[n] = true
}
@@ -537,10 +537,7 @@ func (s *Selection) PrevMatcherUntilNodes(filter Matcher, nodes ...*html.Node) *
// Filter and push filters the nodes based on a matcher, and pushes the results
// on the stack, with the srcSel as previous selection.
func filterAndPush(srcSel *Selection, nodes []*html.Node, m Matcher) *Selection {
// Create a temporary Selection with the specified nodes to filter using winnow
sel := &Selection{nodes, srcSel.document, nil}
// Filter based on matcher and push on stack
return pushStack(srcSel, winnow(sel, m, true))
return pushStack(srcSel, winnow(nodes, m, true))
}
// Internal implementation of Find that return raw nodes.
@@ -562,13 +559,12 @@ func findWithMatcher(nodes []*html.Node, m Matcher) []*html.Node {
func getParentsNodes(nodes []*html.Node, stopm Matcher, stopNodes []*html.Node) []*html.Node {
return mapNodes(nodes, func(i int, n *html.Node) (result []*html.Node) {
for p := n.Parent; p != nil; p = p.Parent {
sel := newSingleSelection(p, nil)
if stopm != nil {
if sel.IsMatcher(stopm) {
if stopm.Match(p) {
break
}
} else if len(stopNodes) > 0 {
if sel.IsNodes(stopNodes...) {
if isInSlice(stopNodes, p) {
break
}
}
@@ -589,13 +585,9 @@ func getSiblingNodes(nodes []*html.Node, st siblingType, untilm Matcher, untilNo
if st == siblingNextUntil || st == siblingPrevUntil {
f = func(n *html.Node) bool {
if untilm != nil {
// Matcher-based condition
sel := newSingleSelection(n, nil)
return sel.IsMatcher(untilm)
return untilm.Match(n)
} else if len(untilNodes) > 0 {
// Nodes-based condition
sel := newSingleSelection(n, nil)
return sel.IsNodes(untilNodes...)
return isInSlice(untilNodes, n)
}
return false
}
@@ -662,6 +654,20 @@ func getChildrenWithSiblingType(parent *html.Node, st siblingType, skipNode *htm
}
}
// For the cases that collect every matching sibling, count them in a
// cheap pointer walk first so the result slice can be sized exactly,
// avoiding repeated slice growth. The Until cases are skipped (counting
// would require running the predicate twice) and so are the single-result
// Next/Prev cases.
switch st {
case siblingAll, siblingAllIncludingNonElements, siblingPrevAll, siblingNextAll:
n := 0
for c := iter(nil); c != nil; c = iter(c) {
n++
}
result = make([]*html.Node, 0, n)
}
for c := iter(nil); c != nil; c = iter(c) {
// If this is an ...Until case, test before append (returns true
// if the until condition is reached)
@@ -681,12 +687,21 @@ func getChildrenWithSiblingType(parent *html.Node, st siblingType, skipNode *htm
// Internal implementation of parent nodes that return a raw slice of Nodes.
func getParentNodes(nodes []*html.Node) []*html.Node {
return mapNodes(nodes, func(i int, n *html.Node) []*html.Node {
if n.Parent != nil && n.Parent.Type == html.ElementNode {
return []*html.Node{n.Parent}
// Collect parents inline rather than going through mapNodes, which would
// allocate a throwaway one-element slice per source node. Many source
// nodes (e.g. siblings) share the same parent, so deduplicate as we go.
var result []*html.Node
set := make(map[*html.Node]bool, len(nodes))
for _, n := range nodes {
p := n.Parent
if p == nil || p.Type != html.ElementNode || set[p] {
continue
}
return nil
})
set[p] = true
result = append(result, p)
}
return result
}
// Internal map function used by many traversing methods. Takes the source nodes
@@ -694,7 +709,14 @@ func getParentNodes(nodes []*html.Node) []*html.Node {
// Returns an array of nodes mapped by calling the callback function once for
// each node in the source nodes.
func mapNodes(nodes []*html.Node, f func(int, *html.Node) []*html.Node) (result []*html.Node) {
set := make(map[*html.Node]bool)
switch len(nodes) {
case 0:
return nil
case 1:
return f(0, nodes[0])
}
set := make(map[*html.Node]bool, len(nodes))
for i, n := range nodes {
if vals := f(i, n); len(vals) > 0 {
result = appendWithoutDuplicates(result, vals, set)
+17
View File
@@ -3,6 +3,8 @@ package goquery
import (
"strings"
"testing"
"golang.org/x/net/html"
)
func TestFind(t *testing.T) {
@@ -51,6 +53,21 @@ func TestChildren(t *testing.T) {
assertLength(t, sel.Nodes, 5)
}
func TestChildrenDuplicateSourceNodes(t *testing.T) {
// A Selection's Nodes field is exported, so callers can construct a
// selection that contains the same node more than once. Children (and the
// other children-based traversals) must still deduplicate so that the
// shared child set is not returned multiple times.
base := Doc().Find(".pvk-content")
node := base.Nodes[0]
expected := base.Eq(0).Children().Length()
dup := &Selection{Nodes: []*html.Node{node, node}, document: base.document}
assertLength(t, dup.Children().Nodes, expected)
assertLength(t, dup.ChildrenFiltered("*").Nodes, expected)
assertLength(t, dup.Contents().Nodes, base.Eq(0).Contents().Length())
}
func TestChildrenRollback(t *testing.T) {
sel := Doc().Find(".pvk-content")
sel2 := sel.Children().End()
+16 -7
View File
@@ -175,22 +175,31 @@ type singleMatcher struct {
Matcher
}
func (m singleMatcher) MatchAll(n *html.Node) []*html.Node {
// MatchFirst returns the first node that matches, or nil if none does. It uses
// the underlying Matcher's MatchFirst method if it provides one (cascadia-
// compiled matchers all do), avoiding building a result slice. Otherwise it
// falls back to MatchAll and returns its first element.
func (m singleMatcher) MatchFirst(n *html.Node) *html.Node {
// Optimized version - stops finding at the first match (cascadia-compiled
// matchers all use this code path).
if mm, ok := m.Matcher.(interface{ MatchFirst(*html.Node) *html.Node }); ok {
node := mm.MatchFirst(n)
if node == nil {
return nil
}
return []*html.Node{node}
return mm.MatchFirst(n)
}
// Fallback version, for e.g. test mocks that don't provide the MatchFirst
// method.
nodes := m.Matcher.MatchAll(n)
if len(nodes) > 0 {
return nodes[:1:1]
return nodes[0]
}
return nil
}
func (m singleMatcher) MatchAll(n *html.Node) []*html.Node {
// Wrap MatchFirst's single result in a slice to respect the Matcher
// interface's MatchAll signature.
if node := m.MatchFirst(n); node != nil {
return []*html.Node{node}
}
return nil
}
+67 -1
View File
@@ -57,6 +57,72 @@ func nodeName(node *html.Node) string {
}
}
// TextOptions controls the behaviour of the Text function.
type TextOptions struct {
// Separator is inserted between the contents of the text nodes that are
// included in the result. It defaults to the empty string, which
// concatenates the text nodes without any separator.
Separator string
// Trim, when true, removes leading and trailing whitespace from the
// content of each text node and omits text nodes that are empty once
// trimmed. This is useful to discard the insignificant whitespace that
// comes from the indentation of the source HTML.
Trim bool
// Keep, when non-nil, is called for each text node encountered while
// traversing the selection. The content of the text node is included in
// the result only if Keep returns true. It can be used, for example, to
// drop the text of <script> and <style> elements by inspecting the node's
// parent. When Keep is nil, every text node is included (subject to Trim).
Keep func(node *html.Node) bool
}
// Text returns the combined text contents of the nodes in the selection,
// including their descendants, in document order. It is a package-level
// function - and not a method on the Selection, because it is not part of the
// jQuery API - that offers control over how the text of distinct text nodes is
// joined and which text nodes are included, in the same spirit as Python's
// BeautifulSoup get_text.
//
// With a nil opts (or a zero-value TextOptions), Text behaves like calling the
// Selection.Text method on the selection. Setting TextOptions.Separator inserts
// a separator between the text nodes, Trim strips the surrounding whitespace of
// each text node (dropping the ones that become empty), and Keep filters which
// text nodes contribute to the result.
func Text(s *Selection, opts *TextOptions) string {
if opts == nil {
opts = &TextOptions{}
}
var parts []string
var collect func(*html.Node)
collect = func(n *html.Node) {
if n.Type == html.TextNode {
if opts.Keep != nil && !opts.Keep(n) {
return
}
text := n.Data
if opts.Trim {
text = strings.TrimSpace(text)
if text == "" {
return
}
}
parts = append(parts, text)
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
collect(c)
}
}
for _, n := range s.Nodes {
collect(n)
}
return strings.Join(parts, opts.Separator)
}
// Render renders the HTML of the first item in the selection and writes it to
// the writer. It behaves the same as OuterHtml but writes to w instead of
// returning the string.
@@ -143,7 +209,7 @@ func appendWithoutDuplicates(target []*html.Node, nodes []*html.Node, targetSet
// if a targetSet is passed, then assume it is reliable, otherwise create one
// and initialize it with the current target contents.
if targetSet == nil {
targetSet = make(map[*html.Node]bool, len(target))
targetSet = make(map[*html.Node]bool, len(target)+len(nodes))
for _, n := range target {
targetSet[n] = true
}
+106
View File
@@ -81,6 +81,112 @@ func TestNodeNameMultiSel(t *testing.T) {
}
}
var textNodes = `<!doctype html>
<html>
<body>
<div id="content">
<h1> Hello </h1>
<p>world</p>
<!-- a comment -->
<script>var ignored = 1;</script>
</div>
</body>
</html>`
func TestText_NilOptions(t *testing.T) {
doc, err := NewDocumentFromReader(strings.NewReader(textNodes))
if err != nil {
t.Fatal(err)
}
sel := doc.Find("#content")
// A nil options value must behave exactly like the Text method.
if got, want := Text(sel, nil), sel.Text(); got != want {
t.Errorf("nil options: want %q, got %q", want, got)
}
}
func TestText_SeparatorAndTrim(t *testing.T) {
doc, err := NewDocumentFromReader(strings.NewReader(textNodes))
if err != nil {
t.Fatal(err)
}
// Trim drops the whitespace-only text nodes coming from indentation, and
// Separator joins the remaining fragments. The <script> text is included
// because no Keep filter is provided.
got := Text(doc.Find("#content"), &TextOptions{Separator: "|", Trim: true})
want := "Hello|world|var ignored = 1;"
if got != want {
t.Errorf("want %q, got %q", want, got)
}
}
func TestText_Keep(t *testing.T) {
doc, err := NewDocumentFromReader(strings.NewReader(textNodes))
if err != nil {
t.Fatal(err)
}
// Keep skips the text of <script> (and <style>) elements.
keep := func(n *html.Node) bool {
if n.Parent != nil && n.Parent.Type == html.ElementNode {
switch n.Parent.Data {
case "script", "style":
return false
}
}
return true
}
got := Text(doc.Find("#content"), &TextOptions{Separator: " ", Trim: true, Keep: keep})
want := "Hello world"
if got != want {
t.Errorf("want %q, got %q", want, got)
}
}
func TestText_MultiSelection(t *testing.T) {
doc, err := NewDocumentFromReader(strings.NewReader(textNodes))
if err != nil {
t.Fatal(err)
}
got := Text(doc.Find("h1, p"), &TextOptions{Separator: ",", Trim: true})
want := "Hello,world"
if got != want {
t.Errorf("want %q, got %q", want, got)
}
}
func TestText_EmptySelection(t *testing.T) {
doc, err := NewDocumentFromReader(strings.NewReader(textNodes))
if err != nil {
t.Fatal(err)
}
// An empty selection has no text nodes to walk, so the result is empty
// and the call must not panic.
got := Text(doc.Find(".no-such-thing"), &TextOptions{Separator: " ", Trim: true})
if got != "" {
t.Errorf("want empty string, got %q", got)
}
}
func TestText_KeepFiltersAll(t *testing.T) {
doc, err := NewDocumentFromReader(strings.NewReader(textNodes))
if err != nil {
t.Fatal(err)
}
// When Keep rejects every text node, nothing is joined and the result is
// empty rather than a string of separators.
keep := func(n *html.Node) bool { return false }
got := Text(doc.Find("#content"), &TextOptions{Separator: " ", Trim: true, Keep: keep})
if got != "" {
t.Errorf("want empty string, got %q", got)
}
}
func TestOuterHtml(t *testing.T) {
doc, err := NewDocumentFromReader(strings.NewReader(allNodes))
if err != nil {