Documentation
¶
Overview ¶
Package lolhtml provides Go bindings for lol-html, Cloudflare's streaming HTML rewriter.
The rewriter walks HTML in a single pass, without building a document tree, and invokes your handlers as it encounters content matching a CSS selector. Memory use is bounded by the largest element it has to buffer rather than by document size, which makes it suitable for rewriting responses of unknown length on the fly.
Streaming ¶
NewWriter returns an io.WriteCloser, so a rewrite composes with the rest of the io package:
w, err := lolhtml.NewWriter(os.Stdout,
lolhtml.OnElement("a[href]", func(e *lolhtml.Element) error {
href, _ := e.Attribute("href")
return e.SetAttribute("href", absolutise(href))
}),
)
if err != nil {
return err
}
if _, err := io.Copy(w, resp.Body); err != nil {
return err
}
return w.Close()
Close finishes the document and flushes the tail of the output; skipping it truncates the result. Chunk boundaries never affect handler behaviour, so input may arrive however the network delivers it.
For a document already in memory, Rewrite and RewriteString wrap the same machinery.
One rewriter per document, not one per fragment ¶
The guarantee above is about chunks of one stream, and it does not extend to splitting the work. Rewriting two fragments with two rewriters and joining the outputs is not the same as rewriting the whole, because a fragment that ends inside a tag is invisible to every handler and is emitted verbatim.
Measured on <p>a</p><script>alert(1)</script><p>b</p>, removing every script, over all forty places the document can be cut in two:
one rewriter, whole document saw p script p <p>a</p><p>b</p> one rewriter, two writes, any cut saw p script p <p>a</p><p>b</p> two rewriters, cuts 9 to 15 saw p and p the script, whole two rewriters, cut 16 saw p script, p alert(1) as text two rewriters, any other cut the script is removed
The seven cuts are the ones strictly inside the eight bytes of <script>. The first fragment ends mid-tag, so no element handler and no text handler runs for it and the bytes pass through; the second begins mid-name, so its remainder is text and passes through too; and the join reassembles an element that neither pass inspected. Cut 16 is the boundary immediately after the complete start tag and fails differently: the first pass does remove the script, but its content was in the other fragment, so the payload survives as text beside a stray end tag.
Two different things are going on, and it is worth keeping them apart.
The first is that a tag is the only thing a document can end inside and have nothing report it. Measured, on inputs that end where they say:
<p <p attr <p attr="v <p/ </p <script
no handler of any kind
<!- <!-- <!-- x <! <?php <![CDATA[x
a comment, with the text so far
<!DOCTYPE a doctype
<script> <script>var a <style>p{ the element, and its text if any
So the blind spot is exactly the tag, and its bytes still reach the output. A stray end tag - </div> with nothing open - is unreported too, and harmless: it swallows nothing.
The second is which unfinished constructs swallow what follows them, which is a wider set and the one that decides whether a join is safe. Appending <x-sentinel> to each of these and asking whether the element is still reported:
<p <p attr <p attr="v </p swallowed
<!-- c <! <?php <!DOCTYPE swallowed
<script> <script>var a <style>p{ swallowed
<textarea>x <title>t swallowed
<ul><li>a <p>a </div> a < b reported: nothing is open that absorbs
which is the same set the documentation for DocumentEnd.Append describes for the same reason: an unfinished construct has no end until one arrives, and the next thing written becomes part of it. An *element* left open is not one of them - more content is simply more content.
So a fragment is safe to join if nothing is unfinished at its end. An open element is fine; an open tag, comment, doctype or raw-text element is not.
No call errors in any of this, and the output looks like a document. For a sanitiser that is a hole, for an inventory an undercount, and for a rewrite that only adds things a corruption.
So a document assembled from pieces should be fed to one rewriter as successive writes - which the second row shows is correct at every boundary - and a rewrite that must work on fragments has to be able to say where a fragment may be cut. Element boundaries are safe; byte offsets are not. examples/gip/inventory measures this and examples/gip/split cuts only at boundaries it chose.
A caller who has to accept fragments from elsewhere can test one rather than trust it, and without reimplementing the tokenizer: append a sentinel element to the fragment, rewrite it, and see whether a handler for the sentinel runs. If it does not, something at the end of that fragment swallowed it.
Asking the same question by scanning for a "<" after the last ">" does not work, and it fails in the direction that matters: over a fixed set of 4000 generated fragments it says "safe" for 1007 that are not, and never the other way. It cannot know that <!DOCTYPE does not begin with a letter, that an open <script> has its last ">" behind it, or that a bare "</" at the end is an unfinished end tag. Pinned in fragment_test.go.
An insertion can only go where the rewriter has not been yet ¶
The output is produced as the input is consumed, so a handler can insert content only at a position the rewriter has not passed. That is obvious said plainly and easy to walk into, because it constrains the shape of a rewrite rather than any single call: whatever a rewrite decides has to be decided before the position it wants to write to.
Some things are therefore not one-pass rewrites at all. Head content derived from the body is the common one - a rel=next link from a pagination nav, a canonical URL from the page's own content, a table of contents built from the headings - because the head has closed before the evidence arrives. Nothing reports this: the handler that would insert simply never runs, or runs before it knows what to say.
Where the evidence and the position both exist, pick the position that is after every place the evidence could be. That is usually an end tag rather than a start tag, and the choice is not cosmetic: a program that decides at the first element what to insert cannot know about a second candidate further on, and the usual result is inserting and then also rewriting - two of the thing it was meant to leave one of.
Where they do not both exist, the answer is two passes, and the cost is worth knowing before choosing it. A second pass roughly doubles the allocation count, at every size:
elements one pass two passes
2 24 52
40 45 98
800 425 866
The ratio holds at about two everywhere, and the reason is not that the fixed cost of building a rewriter dominates - at 800 elements it plainly does not. It is that the second pass re-parses the whole document and runs every handler again, so the per-element work doubles along with the fixed part. Two passes over a large document are not cheap because the overhead is amortised; they cost twice.
What grows with it is memory - but only for the kind of second pass that needs what the first pass learned. A table of contents or a canonical URL derived from the body has to be complete before the second pass starts, so the document is held, and that is what stops it being a streaming rewrite. examples/gip/pagenav and examples/gip/glossary both do this, with the second pass behind a flag or skipped entirely when the first pass found nothing to do.
A second pass that is just another rewrite needs none of that. A Writer is an io.Writer, so it can be another Writer's destination:
second, _ := lolhtml.NewWriter(dst, annotate...) first, _ := lolhtml.NewWriter(second, insert...) io.Copy(first, src) first.Close() // upstream first: its tail flushes into second second.Close()
Both stages run at once, the downstream one seeing bytes before the upstream one has been given the whole document, and neither holds it: measured, peak heap above the baseline was 2.8 MB piping 1 MB and 3.5 MB piping 4 MB and 16 MB. The allocation cost is the same doubling as the buffered form - 831 for one pass over 400 anchors, 1645 piped, 1655 buffered - and the output is identical, so the pipeline is the buffered form without the document.
Two things to know about the shape. Close upstream first, because each stage's Close flushes into the next: the wrong order truncates the tail and reports ErrClosed from the upstream Close, on a document that has a tail. And an error in any stage reaches the caller through the stages above it with its identity intact, so a pipeline needs no error plumbing of its own. examples/gip/pipeline is the whole thing in one file; pipeline_test.go gates it.
Gated as a ratio in alloc_test.go, since the absolute numbers move with the toolchain and the doubling does not.
An end tag is a token, not a fact about the element ¶
HTML lets a document leave many end tags out. A list item is closed by the next list item, a table cell by the next row, a paragraph by anything that cannot be inside one. In a browser's tree those elements are closed exactly like any other. Here there is no tree, and an element ends where the next end tag token is - which, for an element whose own end tag was omitted, is the enclosing element's.
So the element the library hands a handler is bigger than the element the page describes, and everything positioned at its end goes somewhere else. Measured on <ul><li>a<li>b<li>c</ul>, one operation applied to every item:
Prepend <ul><li>[1]a<li>[2]b<li>[3]c</ul> correct Before <ul>[1]<li>a[2]<li>b[3]<li>c</ul> correct Append <ul><li>a<li>b<li>c[1]</ul> one survives, at the end of the list After <ul><li>a<li>b<li>c</ul>[1] one survives, outside the list SetInner <ul><li>[1]</ul> items b and c are gone Replace <ul>[1] every item is gone
The one exception is inserting through the end tag rather than through the element. All three handlers run at the single `</ul>`, innermost first, and every insertion survives:
EndTag.Before <ul><li>a<li>b<li>c[3][2][1]</ul> all three, at the end of the list
The position is no more correct than Append's - the content belongs at each item's own end, and the source has no such position - but nothing is silently dropped. For a rewrite whose whole job is to add something, that is the difference between a misplaced insertion and a missing one, and it is the reason to prefer EndTag.Before over Element.Append where either would do. examples/gip/shadow is built on it.
And Element.Remove on the *first* item alone empties the whole list:
<ul><li>a<li>b<li>c</ul> -> <ul>
No call returns an error and nothing in the output looks damaged, which is what makes this the worst trap in the library. The same program on <ul><li>a</li><li>b</li><li>c</li></ul> is correct in every row, so it works on the pages written one way and destroys content on the pages written the other.
Positions taken from the start tag are safe: Prepend, Before, SetAttribute and anything read from the element. Positions taken from the end are not.
Element.OnEndTag has the same shape, and it is the one place the mismatch can be detected. The handler still runs - against the tag that closed the element, which has a different name:
tag := e.TagName()
e.OnEndTag(func(t *lolhtml.EndTag) error {
if t.Name() != tag {
return nil // closed implicitly; this position is not this element's
}
return t.Before("<span class=\"marker\"></span>", lolhtml.HTML)
})
An end tag closes the nearest open element of its name, which is the element itself, so a name that matches is this element's end tag and a name that differs is not. Measured against what the source spells at that position, over every shape in endtagposition_test.go.
If nothing closes the element - <p>a<p>b at the top level - the handler does not run at all, and Append and After produce nothing. That is at least a silence rather than a wrong answer.
What to do about the other branch is the rewrite's decision. Doing nothing is honest. Where the rewrite must be right on both kinds of page, the answer is the same as everywhere else evidence arrives too late: read the document twice, and let the first pass find out which elements have their own end tags.
Writing is not the only thing that acts on that token. Removing an element removes it - Element.Remove and Element.Replace take the content up to it as well, and Element.RemoveAndKeepContent takes the token alone, so the element it belonged to never closes. Renaming an element writes over it, so <h1>a <em>b</h1> renamed to i becomes <h1>a <i>b</i>, with the heading left open. Each of those methods says so; the general rule is this one, and the name guard above is what detects it in all of them.
The same token rule decides which handlers fire, not only where content goes. The selector engine pops its stack on end tags, and a start tag never pops anything, so a descendant selector goes on matching after the element has ended:
<ul><li><video><li><track></ul> "video track" matches the track in the tree the track is in the second item, with no video above it
Measured over a second list item, paragraph, table cell, row, definition and option in differential/impliedclose_test.go. The over-match runs until something explicit closes the ancestor - the enclosing </ul> ends it - and catches everything after it at any depth, so "li p" on a page written without </li> is a selector for the rest of the list. This is the worse half of the two: a position taken from a missing end tag is at least silent, and this one runs the handler on an element that is not there.
The child combinator cannot be fooled into over-matching this way, because the start tag that ended the element is also the parent of whatever comes next. Where the thing being looked for can only be a child, "a > b" is the more precise question - examples/gip/captions asks "video > track" for that reason. It fails the other way instead, under-matching when the omitted end tag is the one being selected through: see the next section. Where neither combinator will do, the answer is the caller's own stack of open elements with the implied end tags applied.
A handler that only wants to know the element is over, rather than to write at its position, needs a finer distinction than the name gives. A foreign end tag is where the element ended when an ancestor's end tag closed it, and later than where it ended when a sibling's start tag did - in <ul><li><em>a<li>b</ul> the em's callback arrives after "b" has been reported. Nothing in the callback separates those two, so anything accumulating has to keep the stack of open elements itself and apply the implied end tags. See Element.OnEndTag, and examples/gip/markdown for what that costs.
Structural selectors count tokens, not children ¶
The selector engine pushes on a start tag and pops on an end tag, so the tree it matches against is the nesting the tokens describe. HTML lets a document leave most end tags out, and then the nesting is not the page's: the second list item is inside the first rather than beside it. Every selector that depends on position or parentage answers a different question from the one it looks like:
<ul><li>a<li>b<li>c</ul> with </li> spelled ul > li 1 of 3 3 li > li 2 0 li:first-child all 3 1 li:nth-child(2) none 1 li:nth-of-type(2) none 1
So a rewrite keyed on position is right on the pages written one way and wrong on the pages written the other, and a document that mixes the two - some items closed, some not - is partly right, which is harder to notice. Measured over that list, over a list whose items hold an element, and over paragraphs, table cells, table rows and definition lists, in differential/structural_test.go.
It is not an off-by-one that could be corrected for. The count is of whatever the tokens nested, so in <ul><li><img><li><img></ul> the selector "li:nth-child(2)" matches the second item - as the second child of the first item, after the image. The same selector on the same list with its end tags matches the same element for the right reason. Nothing in the callback distinguishes those two.
What to do about it depends on what the position was for. Numbering, striping and "every third" want a counter in a handler, incremented per match, which counts the elements the rewrite actually sees; that is what examples/gip/shard uses a hash for instead, needing stability rather than position. Selecting the *first* of something is the one position that survives, since ":first-child" over-matching still includes the element a page would call first. And a rewrite that must be exact about position on documents it did not write has the same answer as everything else here: buffer the input, and let a first pass find out what the tree is.
Handler lifetime ¶
The value passed to a handler is valid only until that handler returns. lol-html reuses the underlying storage, so golol-html detaches the wrapper on the way out and every later method call returns ErrDetached. Copy out what you need rather than retaining the unit:
lolhtml.OnElement("img", func(e *lolhtml.Element) error {
src, _ := e.Attribute("src") // fine: a Go string
seen = append(seen, e) // useless: detached once this returns
return nil
})
Handler order ¶
More than one handler can see the same unit, and the order they run in follows two rules.
Within one kind of registration, handlers run in the order they were registered: two OnElement handlers whose selectors both match, three OnDocumentEnd handlers, several Element.OnEndTag handlers on one element. Each sees what the previous one did, so a handler reading an attribute gets the value an earlier handler wrote to it.
How many times a handler runs on one element is decided by how the rules were spelled, and the two spellings differ. A selector list is one selector: the handler runs once for an element, however many parts of the list match it. Separate handlers are separate: each runs. Measured on <a href="/x" class="t">, with a handler that appends to an attribute:
OnElement(`a[href], a.t`, set) one call data-n="x" OnElement(`a[href]`, set), OnElement(`a.t`, set) two calls data-n="xx" OnElement(`a`, set), OnElement(`a`, set) two calls data-n="xx" OnElement(`a[href], a.t, a`, set) one call
So merging rules into one list is the way to say "at most once per element", which is usually what a rewrite wants and is also the cheapest form - see the section on cost. Keep them separate when each rule really does need its own call. Pinned in selectorlist_test.go.
Selectors do not. Matching is decided against the document as it arrived, before any handler runs, so an edit never changes which handlers fire:
OnElement(".a", func(e *Element) error { return e.SetAttribute("class", "b") }),
OnElement(".b", ...) // does not fire
and neither does renaming a tag, in either registration order. The reverse holds too: removing the class an already-matched selector needed does not un-fire it, so a handler on ".a" still runs even if an earlier handler took the attribute away.
That is worth relying on: there is no cascade and no way for a rewrite to trigger itself, so the set of handlers that will run is fixed by the document before any of them does anything.
What they read is a different matter. Handlers on one element share the element, and a later one sees an earlier one's edits - its attribute values and its tag name:
OnElement("img[src]", ... SetAttribute("src", v+"?v=1")),
OnElement("[src]", ... SetAttribute("src", v+"?v=2")),
<img src="/a.js"> -> <img src="/a.js?v=1?v=2">
Both selectors matched, both handlers ran in registration order, and each did its job on the other's output. Swapping the two registrations swaps the result, so there is order-dependence in what comes out even though there is none in what fires. Two selectors that can match the same element and write the same attribute are the shape to watch for: examples/gip/bust uses one handler and one selector list for exactly that reason.
What a later handler cannot do is match on the edit - a class an earlier handler added does not make a ".new" selector fire, and a renamed tag does not make a handler on the new name fire, though the later handler sees the new name when it asks. Acting on produced *markup* still needs a second pass. Measured in handlerstate_test.go.
Between kinds, every selector-associated handler runs before every document-level handler for the same unit, whatever order the options were written in. OnComment runs before OnDocumentComment and OnText before OnDocumentText even when the document-level one was registered first, because lol-html keeps the two in separate lists. A rewrite that needs to see a unit before anything else does has to register a selector-associated handler, not a document-level one.
Which selectors are supported ¶
One rule covers almost all of it: a selector can be used if the rewriter can decide it when it sees the start tag. It has no tree to look at and it cannot wait, so anything that depends on what comes after the element is out.
Supported:
div * .cls #id type, universal, class, id
a, b a selector list
div p div > p descendant and child combinators, though a
descendant one keeps matching after an
omitted end tag: see end tags above
[a] [a=v] [a~=v] [a|=v] attribute presence and matching
[a^=v] [a$=v] [a*=v]
[a=v i] [a=v s] case-sensitivity flags
:not(x) one simple selector only, and no combinator
inside it at all, see below
:first-child :nth-child(2n+1) odd, even and an+b all work, over the
nesting the tokens describe: see structural
selectors above
:first-of-type :nth-of-type(n)
*|name any namespace
Not supported, because deciding them needs what follows the start tag:
:last-child :only-child :empty :last-of-type :nth-last-child(n) :nth-last-of-type(n)
Not supported for other reasons - state a stream does not have, or simply unimplemented:
x + y x ~ y sibling combinators :root :scope :host :checked :disabled :hover :is(...) :where(...) :has(...) ::before ::first-line ::marker any pseudo-element ns|name an explicit namespace other than *|
An empty operand does not mean what CSS says it means, and this is worth knowing before a selector is built from a string that might be empty. The specification says a substring operator with an empty value "does not represent anything" - matches nothing at all. Measured here, three of the six do something else:
[a=""] an empty value as the specification says [a|=""] an empty value, or one starting "-" as the specification says [a*=""] nothing as the specification says [a^=""] every non-empty value the specification says nothing [a$=""] every non-empty value the specification says nothing [a~=""] every value with no words in it the specification says nothing
So a rewrite that interpolates a prefix into a[href^="..."] and is handed an empty prefix does not match nothing, it matches every link on the page. The i and s flags make no difference, and an operand omitted altogether - a[href^=] - is a SelectorError, which is the one shape that fails loudly.
The library does not refuse the empty operand because it does not parse selectors - lol-html does, and this is what it decides. Check the value before building the selector, which is a line of code and the only thing that helps. Measured in emptyoperand_test.go.
Tag and attribute names are matched case-insensitively, so "LI" and "li" are the same selector and [CLASS=a] matches class="a". An attribute selector matches a present-but-empty attribute: [style] matches style="".
Case-insensitively means ASCII, which is what HTML means by it. Both the name and the selector have their ASCII letters folded and everything else left alone, so a non-ASCII letter has to match in case:
<DÉTAIL> matched by "DÉTAIL", "dÉtail", "DÉtail"
not matched by "détail" or "DéTAIL"
The É is the same character on both sides or it is not the same selector. So <DÉTAIL> is the element "dÉtail" - see Element.TagName - and the spelling a caller would reach for, all lower case, matches nothing. Nothing warns: a selector that matches no element is a valid selector. Custom element names may contain non-ASCII letters, so this is reachable from a template written in a language that has accents rather than only from a deliberately odd document.
A tag name still has to *begin* with an ASCII letter to be a tag at all: "<ÉTAT>" is text, not an element, so no selector reaches it.
Attribute values are a different rule, and it is not uniform. HTML matches the value case-insensitively for a fixed list of attributes and case-sensitively for everything else, and the rewriter follows that list exactly:
[rel="canonical"] matches rel="CANONICAL" [name="foo"] does not match name="Foo"
The list is the one in the HTML specification's section on selector case-sensitivity, all 46 of them:
accept accept-charset align alink axis bgcolor charset checked clear codetype color compact declare defer dir direction disabled enctype face frame hreflang http-equiv lang language link media method multiple nohref noresize noshade nowrap readonly rel rev rules scope scrolling selected shape target text type valign valuetype vlink
Everything else is matched exactly, including id, class, href, src, alt, title, name, value, style, content, role, srcset, integrity and every data-* attribute. So are the .cls and #id shorthands: ".Foo" does not match class="foo".
Where that matters, say which you want rather than relying on the default: [a=v i] is case-insensitive and [a=v s] is exact, and both work for any attribute. The i flag folds ASCII too, and only ASCII: [data-x="é" i] does not match data-x="É", so the flag a caller reaches for when case matters is the one that will not help with the case that is hardest to spot. Measured in asciicase_test.go.
An unsupported selector is rejected by NewWriter, not silently ignored, with a SelectorError naming it and saying which part it could not use.
A colon, a dot or a leading digit in a name has to be escaped ¶
A selector is CSS, so a punctuation character in a tag or attribute name is read as CSS punctuation unless it is escaped with a backslash. The two that come up are the colon, in the namespace-prefixed names that Edge Side Includes and SVG's xlink attributes use, and the dot, in a class or id that contains one. Measured:
esi:include Unsupported pseudo-class or pseudo-element in selector esi\:include matches <esi:include> [xlink:href] Unexpected token in the attribute selector [xlink\:href] matches <a xlink:href="x"> .a.b parses, matches nothing: two classes, "a" and "b" .a\.b matches class="a.b" my-element matches; a hyphen needs no escape
The first two rows are the ones worth knowing, because the message names a pseudo-class the caller did not write. SelectorError adds the answer when the selector it rejected contains an unescaped colon. The dot has no such help: it parses, so nothing fails - the handler simply never runs, which is the quietest failure in this list.
A digit is a third case, with a rule of its own: a CSS identifier cannot begin with one, so a class or id that does cannot be written after "#" or "." at all. Generated ids and utility class names land here regularly, and the message is no help either - lol-html reports "The selector is empty", which describes what its parser had left rather than what the caller wrote:
#1a The selector is empty #\31 a matches id="1a" #\31a parses, matches nothing: \31a is U+031A, one character #\000031a matches: six hex digits need no terminator [id="1a"] matches, and needs no escaping at all .2xl\:hidden The selector is empty: a digit and a colon at once [class~="2xl:hidden"] matches that one
The space after "\31" is what ends the escape, and leaving it out is the quiet version of this mistake rather than a syntax error. SelectorError carries both answers for a rejected selector whose class or id starts with a digit, written so they can be copied; the attribute-selector form is the one to reach for, since it needs no escaping at all. Measured in digitident_test.go, including that both suggestions match the element they are suggested for.
Everything else about matching is case-insensitive as usual, so `ESI\:INCLUDE` matches too.
Selectors do not consider namespaces ¶
A tag name in a selector matches that name in any namespace, so "a[href]" matches an HTML anchor, an SVG <a> and a MathML <a> alike, and "title" matches both a document title and an SVG tooltip:
<html><head><title>page</title></head>
<body><svg><title>tooltip</title></svg></body></html>
OnText("title", ...) // fires for "page" and for "tooltip"
Element.NamespaceURI does not settle it, because it reports the namespace an element's children are parsed in rather than the element's own, and SVG's title, desc and foreignObject are HTML integration points - so they report the HTML namespace, exactly like the document title. Same for MathML's mi, mo, mn, ms and mtext.
Two things do work. A selector that names the context is exact:
OnText("svg title", ...) // only the tooltip
and its complement is not, because a selector cannot say "not inside svg": "head title" and "head > title" find the document title only when the input actually contains <head>, and <head> is optional in HTML - given "<title>page</title><p>x</p>" they match nothing at all.
So a handler that must act on the document title and not on tooltips has to match "title" and track the context itself, which is one more handler and a stack. examples/gip/envbadge counts <svg> and <math> depth, which is enough when the only question is "am I in foreign content".
When the question is "which namespace is this element in", a depth counter is not enough, because an integration point switches back: the <p> in <svg><foreignObject><p> is an ordinary HTML paragraph and a counter says SVG. What works is a stack of Element.NamespaceURI values - an element is parsed in the namespace its parent's children are parsed in, which is what the parent reported. The method's own documentation has the shape of it, and examples/gip/histogram uses it to keep an HTML <a> and an SVG <a> in separate rows.
:not() is wrong for anything but a single simple selector ¶
This one is not a limitation but a defect, and it is silent, so it is worth knowing exactly.
:not() is correct when its argument is one simple selector - :not(div), :not(.a), :not([href]), :not(:first-child). Give it a compound selector and it negates each part separately and requires all of them, which is the wrong half of De Morgan's law: :not(div.a) is evaluated as :not(div):not(.a).
On the document
<div class="a">1</div><div class="b">2</div><span class="a">3</span><span class="b">4</span>
:not(div.a) should match everything except the first, three elements. It matches one, span.b - the same as :not(div):not(.a). A selector list inside is affected too: :not(div.a, span.b) matches nothing at all.
So a rewrite meant to process everything except trusted anchors, written OnElement(":not(a.trusted)"), skips every anchor and everything carrying that class. For a filter that is a hole rather than a nuisance.
Until it is fixed upstream, use :not() with a single simple selector, or match positively and decide inside the handler:
lolhtml.OnElement("a", func(e *lolhtml.Element) error {
if cls, _ := e.Attribute("class"); strings.Contains(cls, "trusted") {
return nil
}
...
})
A combinator inside :not() is a separate matter: it is not wrong, it is rejected, and the rule above does not predict it. "Supported if the rewriter can decide it at the start tag" is satisfied by :not(div p) - whether an element is inside a div is exactly what the plain descendant selector div p decides, at the start tag, and that one works. Measured, the whole boundary:
:not(div) :not(.a) :not(#i) accepted :not([a]) :not([a=v]) :not(*) accepted :not(div.a) :not(div, span) accepted, and wrong as described above :not(:first-child) accepted :not(:nth-child(2)) accepted :not(:not(div)) accepted :not(div p) rejected :not(div > p) rejected :not(div + p) rejected :not(div ~ p) rejected
The sibling combinators are unsupported anywhere, so those two are no surprise. The descendant and child ones are supported everywhere except here.
The error message does not say so. All four report
Unsupported pseudo-class or pseudo-element in selector.
which names :not() rather than what is inside it, and follows it with the advice about escaping a colon in a tag name. Neither part points at the combinator. If a selector with :not() in it is rejected and the colon is not the problem, look for a space or a > inside the parentheses.
There is no selector for "not inside an X", then. Keep a stack instead: push at the start tag, pop in the end-tag handler, and read the stack in the handler - examples/gip/islands does exactly that, and has the two further traps that come with it.
An attribute can appear twice ¶
The HTML parsing specification calls a repeated attribute a parse error and requires the parser to keep the first and drop the rest, so a browser's DOM never has two. lol-html keeps them all, and the API is split over what to do about that:
<p a="x" a="v">
[a="x"] matches, [a="v"] does not the first only
Attribute("a") is "x" the first only
SetAttribute("a", "z") replaces the first, leaves a="v"
RemoveAttribute("a") removes both
Attributes(), AttributeList() yield a="x" and a="v"
Three of those act on the first, which is the copy a browser would have kept. Two do not, and both for a reason. Removal takes every copy, because a filter that left one behind would be a filter that does not filter. Iteration yields every copy, because a program reporting on a document should be able to see what is actually in it.
The consequence for a reader is worth stating plainly: iterating attributes shows you attributes that nothing downstream will act on. A tool extracting microdata, or Open Graph tags, or anything else keyed on attribute names, has to decide which copy counts - and the answer that matches what a browser does is the first. examples/gip/microdata does that.
The consequence for a rewrite is smaller but sharper: reading a value, deciding from it, and writing it back is consistent, because all three use the first. Reading through the iterator and writing back is not.
Character references are not decoded ¶
Text, comment text and attribute values are reported as raw source: the href of <a href="?a=1&b=2"> is "?a=1&b=2". lol-html has to be able to re-emit what it read, so it does not decode on the way in, and correspondingly escapes what you write. Reading a value and writing it back unchanged is therefore correct; comparing one against a decoded Go string is not.
The rule: decide on the decoded form, rewrite the raw one. Use html.UnescapeString for the first and leave the value alone for the second - with the caveat below, because for an attribute value that decoder is not the parser's.
One more difference, and it runs the other way: html.UnescapeString decodes more of an attribute value than a browser does. A named reference without its semicolon is not a reference in an attribute when the next character is "=" or ASCII alphanumeric, so "?a=1©=2" keeps its copy parameter in a browser and grows a copyright sign in the standard library. A filter deciding on that decoded form is deciding about a URL nobody will request, and a rewrite that decodes, edits and re-encodes produces a different one. In text the two agree; the rule is an attribute rule. Measured in differential/attrrefs_test.go, and implemented in examples/gip/references.
Getting that the wrong way round is how a filter acquires a hole, because a browser decodes before it acts. These three hrefs all execute:
javascript:x() java	script:x() javascript:x()
A check on the raw string catches only the first: the others read as schemes called "java	script" and "javascript". Decode first and all three are the same URL. The same applies to any decision taken on a value - an allow-list of protocols, a comparison against an expected filename, a test for a marker in text.
It cuts the other way too. Having decoded a value to decide about it, do not write the decoded form back unless you mean to: SetAttribute takes raw source, so writing "a&b" produces an attribute whose value is "a&b" to a parser, and writing back the "a&b" you were given round-trips exactly.
Source is not only undecoded, it is unpreprocessed ¶
References are the well-known half. The other half is that HTML normalises some bytes before the tokenizer ever sees them, and a rewriter that re-emits what it read cannot do that and still be a rewriter. So four more things differ between what a handler is handed and what a parser has, measured against golang.org/x/net/html in differential/preprocess_test.go:
a CR or a CRLF is a LF to a parser, in text, in a comment and in an
attribute value; reported here as written
a NUL in element content is dropped by a parser; reported here as written
a NUL in raw text or a comment is U+FFFD to a parser
a NUL in an attribute value is kept as a NUL
a leading LF in <pre>, <listing> or <textarea> is dropped by a parser - one
of them, not all; reported here as written
Two consequences. A comparison against a value that came from a browser, a DOM or another parser can fail on bytes neither side chose: "a\r\nb" here is "a\nb" there. And a value written into an attribute is read back changed if it contains a CR - to write one, write " ", which is a reference and survives.
A rewrite that only copies values around is unaffected, because both sides are source. One that compares, hashes, or reports what a page says has to decide which form it means, the same way it does for references.
Inserting content ¶
Four things about insertion are worth knowing before relying on any of it, and each has its own section below: two calls of the same kind do not always come out in call order; nothing inserted is dispatched back to your handlers; neither content type is right inside a <script> or a <style>; and markup you build yourself is the only thing here that is not escaped for you.
What Text guarantees, and what it does not ¶
Text escapes the three characters that could begin markup, so nothing it writes becomes a tag. That is checkable, and it is checked: over every document and value the generator can produce, an insertion as Text leaves the sequence of tags in the output exactly as it was. properties/text_structure_test.go holds that, and the same for a streamed insertion and for EscapeText used by hand.
The guarantee is about the markup, not about the tree a browser builds from it. Tree construction responds to the presence of text, so one character can change the tree while adding no tag at all. Measured on a formatting element misnested across a block boundary:
<p><a><div></div></a></p> tree: <p><a></a></p><div></div><p></p> <p><a><div>x</div></a></p> tree: <p><a></a></p><div><a></a></div><p></p>
The second tree has an <a> the markup does not contain, because inserting a character makes the parser reconstruct the active formatting elements at that point. Appending "x" as Text through this library does the same thing. Pinned in differential/textstructure_test.go, along with the shapes where it does not happen - well-nested documents are unaffected.
So "this rewrite cannot change the structure" is true of the bytes and false of the tree, and a program promising the stronger version is promising something the format does not allow.
Two insertions of the same kind ¶
Every insertion goes immediately adjacent to the unit, and the one rule has a consequence that catches people: two calls to the same method do not always come out in the order they were made.
Three calls inserting "1", "2" then "3":
Before 123<p>t</p> in order After <p>t</p>321 reversed Prepend <p>321t</p> reversed Append <p>t123</p> in order
The rule is the same in all four: the newest insertion is the one closest to the unit. For Before and Append that puts it last in reading order; for After and Prepend it puts it first. EndTag.Before and EndTag.After follow the same pattern, as does Comment.After.
It matters most when several calls assemble one thing. Building a comment out of three After calls - the delimiters and the text between them - emits them backwards and produces "-->text<!--". Pass the whole string in one call, or use Before, where the order reads as written.
DocumentEnd.Append is in order, like the other Append.
Inserted content is not re-parsed ¶
Nothing a handler inserts is dispatched to any handler, including the one that inserted it and including handlers on other selectors in the same rewrite. It goes into the output as written.
Two of the consequences are conveniences. There is no loop hazard: a handler that inserts an element matching its own selector fires once. And an accumulator is safe, so a text handler collecting a heading's text does not also collect a label an element handler prepended, which is what lets a rewrite read and write the same element without compounding.
The third is a hazard. A rewrite that removes every <script> does not remove one that another of its own handlers inserted:
lolhtml.OnElement("script", func(e *lolhtml.Element) error { e.Remove(); return nil }),
lolhtml.OnElement("div", func(e *lolhtml.Element) error {
return e.Prepend(untrusted, lolhtml.HTML) // never seen by the remover
})
The document's own scripts go; the inserted one stays, in either registration order. Anything you insert has to be safe before it goes in - use Text for values you did not author, and see the section on inserting into a script for where even that is not enough.
The same rule reaches the content that was already there, through Element.SetTagName: a rename writes over the tag and leaves the content alone, and whoever parses the output applies the new name's content model to it. Renaming a div that holds a paragraph to a table fosters the paragraph out of it, and renaming it to a select deletes the paragraph and a span beside it, merging their text. So a rename is safe when the new element accepts what the old one held. See differential/rename_test.go.
Inserting into a script or a style ¶
Neither ContentType is right for the inside of a <script> or a <style>, and the failures are quiet in opposite directions.
Those two are *raw text* elements: an HTML parser does not read their content as markup and does not decode character references in it. Seven more elements behave the same way - iframe, noembed, noframes, noscript and xmp, plus textarea and title, which do decode references - and plaintext, which is raw text that runs to the end of the input and cannot be closed at all. Ten element names in total; the list is measured rather than quoted, in rawtext_test.go, and IsRawText answers it for a tag name so a caller who has to decide - a sanitiser unwrapping unknown elements, a text handler under a wide selector - does not have to copy it out of this paragraph.
So Text, which escapes <, > and &, produces content that is inert but no longer says what it said:
e.SetInnerContent(`if (a < b && c > d) {}`, lolhtml.Text)
// <script>if (a < b && c > d) {}</script>
The document is valid, nothing returns an error, and the script throws a syntax error in the browser. Element.Attribute and the HTML around it look exactly as intended, which is why this is easy to ship.
HTML would insert the text as written, and the element would end wherever the content says it does:
e.SetInnerContent(`var s = "</script><img src=1 onerror=alert(1)>";`, lolhtml.HTML)
That is a working injection out of a string literal, so it is refused: inserting into the content of one of these elements returns ErrRawTextBreakout when the content would close it. The check is the tokenizer's rule, so "</scriptx" is fine and "</script foo>" is not, and it covers Element.Prepend, Element.Append, Element.SetInnerContent and EndTag.Before on any of the nine that can be closed. Writing outside the element - Before, After, Replace - is ordinary markup and is not checked, and neither are the streaming insertions or the TextChunk ones; ErrRawTextBreakout says why, and a text handler editing a script has to guard itself.
There is still no combination of the two that makes arbitrary text safe here. The refusal stops the injection; it does not give you a way to say what you meant. Escaping correctly needs to know where in the JavaScript the content lands - inside a string literal, "</script" has to become "<\/script", which is a JavaScript transformation rather than an HTML one, and JSON's own escaping of "/" exists for exactly this. So: build script and style bodies from values you control, and if untrusted data has to reach a script, put it in a data attribute or a JSON <script type="application/json"> block and read it from there.
Comment.SetText refuses a comment-closing sequence for the same reason, and was the only such check for a while; ErrRawTextBreakout is the other half.
A textarea and a title are *escapable* raw text, where references are decoded, so Text behaves normally in them. In the other five - iframe, noembed, noframes, noscript, xmp - references are not decoded and there is no inner language either, so there is no way to write the closing sequence inside the element and the content itself has to change.
One more way this goes wrong, and it does not involve escaping at all. When the document's encoding cannot represent a character, WithEncoding emits a numeric character reference instead - which is right everywhere a reference is decoded, and raw text is where it is not:
WithEncoding("windows-1252")
e.SetInnerContent(`var s = '日'`, lolhtml.Text)
// <script>var s = '日'</script>
The script now holds those eight characters instead of the one that was meant. Both rules were followed - the content type is right for the position and the fallback is the documented one - and the result is still wrong, so there is nothing to fix in the call. Either keep the script body inside the document's encoding, using an escape the target language understands - "\u65e5" for JavaScript, "\65e5" for CSS - or serve the document as UTF-8, where the question does not arise. Pinned in encoding_test.go.
Rewriting text that is already there has the same two problems and one more. TextChunk.Replace with Text escapes what raw text must not have escaped, so a stylesheet's ".a > .b" comes back as ".a > .b" - a selector that matches nothing - and a script's "a < b" as "a < b". HTML is therefore the right content type for editing a script body or a stylesheet, which reads backwards and is worth knowing. And the breakout guard does not cover it: an Element method knows which element it is writing into and refuses a "</script>", while a TextChunk does not - lol-html hands a chunk over with no way to ask - so TextChunk.Before, TextChunk.After and TextChunk.Replace write it out. A handler registered as OnText("style") knows the tag it asked for and can apply the check itself with CheckRawText. Measured in rawtextrewrite_test.go.
A table wrapper inside a paragraph depends on the doctype ¶
The rule below - that a wrapper is two insertions and the parser decides what they wrap - has one case whose answer is not a property of the markup at all. A <table> start tag closes an open <p> in a standards-mode document and not in a quirks-mode one, so wrapping content that sits inside a paragraph puts the table beside the paragraph or inside it depending on whether the document has a doctype. Measured against x/net/html:
wrapper no doctype (quirks) <!doctype html> <table> stays in the <p> leaves the <p> <div> leaves leaves <section> leaves leaves <ul> leaves leaves <span> stays stays
Every other wrapper is mode-independent: a block one leaves the paragraph in both modes, an inline one stays in both. The table is the exception, and it is the wrapper a converter to email markup uses for everything - on documents that frequently have no doctype or a doctype from 1999, which is also quirks. So the same input gives two different trees and nothing reports it.
Either put the wrapper somewhere a paragraph is not open, or know the document's mode. examples/gip/tablelayout refuses the conversion inside a paragraph and says how many it refused; differential/tablewrap_test.go has the matrix.
A wrapper is two insertions and the parser decides whether they wrap ¶
Putting a container around an element is Element.Before with an opening tag and a closing tag at the element's end. Both insertions succeed, the output looks exactly as intended, and whether the result is a container around the element is decided afterwards by whoever parses it. Inside a paragraph it often is not:
<p>text <iframe src="a"></iframe> more</p> wrapped in a div p > "text", div > iframe, "more", p wrapped in a span p > "text", span > iframe, "more"
A div closes an open paragraph, so it takes the element out of the paragraph, leaves the text that followed it outside as well, and turns the source's </p> into a second, empty paragraph. Three changes to the tree from an edit that only meant to add a container, and nothing in the bytes looks wrong.
A span does not close a paragraph - and cannot hold an element that does:
<p>text<pre>code</pre></p> wrapped in a span p > "text", span, then pre outside it: the span is empty wrapped in a div p > "text", div > pre, p
So the question is not whether the wrapper is a block or an inline element, it is whether the wrapped element closes a paragraph by starting. Where it does, the div is right, because it leaves the paragraph with the element - which the element was doing anyway. Where it does not, the span is the only one that wraps.
For a <table> the answer depends on the doctype, because a table closes a paragraph only outside quirks mode:
<p>text<table>…</table></p> span holds the table, div moves it <!DOCTYPE html> the same document div holds the table, span comes out empty
The doctype arrives before any element, so a rewrite can read it with OnDoctype and decide, which is what examples/gip/scrollwrap does.
A wrapper around a table-internal element wraps nothing at all: it is fostered out to just before the table while the cell stays where it was. Measured, along with everything above, in differential/wrap_test.go.
The closing half has the end-tag rule on top of all this: the position only belongs to the element when the end tag is the element's own, and an element nothing closes has no position at all. See Element.OnEndTag.
Building markup yourself makes you the serialiser ¶
Every path that writes a value for you escapes it. Element.SetAttribute escapes the double quote, which is the character that could end the attribute; ContentType Text escapes the three characters that would be markup. The one path that escapes nothing is markup you construct and pass as HTML - and that is the tempting route for turning one element into another.
A document-derived value dropped into an attribute you wrote yourself is an injection. A single-quoted attribute may contain a bare double quote, and it reads back as one:
<iframe title='" onload=alert(1) x="'> e.Replace(`<div data-x="`+title+`">`, lolhtml.HTML) // <div data-x="" onload=alert(1) x=""></div>
The div now has a working event handler that came from the document. The same value through SetAttribute is inert:
e.SetAttribute("data-x", title)
// data-x="" onload=alert(1) x=""
So prefer changing the element to replacing it. Element.SetTagName, SetAttribute and Element.RemoveAttribute between them turn an <iframe> into a <div> carrying whatever attributes you want, with every value escaped on the way out, and the result is less code than assembling a string.
When you do have to build markup - a wrapper, a template, an element that does not exist yet and so has no handler to hold it - EscapeText and EscapeAttribute do that escaping for you.
EscapeText is byte for byte what the library applies for Text, asserted against the library rather than assumed, so a value built into markup keeps the guarantee it would have had.
EscapeAttribute is not the same as what SetAttribute applies, and the difference is the point rather than an oversight. SetAttribute escapes the double quote alone, because the library writes the quotes and knows which ones they are. EscapeAttribute escapes five characters:
value SetAttribute EscapeAttribute a"b a"b a"b a'b a'b a'b a<b a<b a<b a&b a&b a&b a&b a&b a&amp;b
because the markup being built might use single quotes, and because an unescaped "&" in it could begin a reference the caller did not write. The last row is the one to watch: a value that came from the document is already source, so SetAttribute passes it through and EscapeAttribute escapes it again. Pinned in escape_test.go, which asserts the difference rather than assuming it.
e.Replace(`<div data-x="`+EscapeAttribute(title)+`">`+EscapeText(s)+`</div>`, HTML)
Two things they do not do. They do not sanitise: a URL is still a URL after escaping, so EscapeAttribute will happily produce a well-formed href of "javascript:alert(1)", and deciding which schemes to allow is a separate job. And they are not idempotent, because nothing that escapes "&" can be: a value that came from the document is already raw source, so escaping it again turns "&" into "&amp;". Decode it first, or leave it raw and do not escape it; see the section on character references.
"Already raw source" is source for the context it came from, and moving a value between contexts is where that bites. Each context lets through the character the other one ends on:
<span title="<img src=x onerror=alert(1)>"> an attribute may hold a raw "<" <h2>a" onload=alert(1) x="b</h2> text may hold a raw quote
Both are inert where they sit. Move the title into an element's text unescaped - the obvious way to turn an alt into a <title>, or a label into a caption - and the img is an element with a working onerror. Move the heading's text into an attribute unescaped, and the div being built gets an onload. Measured both directions against golang.org/x/net/html in differential/context_test.go, by counting what the tree has rather than by reading the output.
So a move needs the destination's terminator escaped and nothing else: the "<" for text, the quote for a double-quoted attribute. That is what Text and SetAttribute apply, and it is why an attribute value that has to become an attribute again can go through unchanged. Where the value has to be built into markup by hand, escaping only that character keeps the value's own references intact - EscapeText and EscapeAttribute are for a value that is not already source, and on one that is they escape its "&" a second time. The other answer, and usually the better one, is not to move it: keep a name in an attribute (aria-label rather than a <title> child), which is what examples/gip/sprite does.
There is a third context and it has no escaper, because it cannot have one. A comment ends at "-->" or at "--!>", and nothing inside it is a reference, so there is no spelling of those four characters that a comment can hold:
e.Append("<!-- "+title+" -->", lolhtml.HTML)
title = `--><img src=x onerror=alert(1)><!--`
// <!-- --><img src=x onerror=alert(1)><!-- -->
and the image is an element. Both closing sequences work, measured in differential/comment_test.go. Passing the value through EscapeText does stop it - "-->" is not a closing sequence - and it also changes what the comment says, since a comment holds characters rather than references. So the choice is between a comment that is wrong and one that is dangerous, which is why Comment.SetText refuses instead: it is the only path that writes a comment's text for you, and it rejects a closing sequence rather than escaping one. Where the comment already exists, use it. Where it does not, remove the sequence from the value yourself and say in the comment that you did.
Reading an element's whole text ¶
OnText fires for every text chunk inside the matched element, including text inside its descendants, and TextChunk.IsLastInTextNode marks the end of a text node rather than the end of the element's content. Those are the same thing only when the element contains no markup.
<a href="/x">click <b>here</b></a>
has two text nodes, "click " and "here". A handler that accumulates to IsLastInTextNode and replaces there runs twice and produces "REPLACED<b>REPLACED</b>". Tested on a document without nested markup, the same code looks correct.
To act on an element's whole text, accumulate in the text handler and finish in Element.OnEndTag - and decode what you accumulated before writing it back, which is the part this example got wrong for a long time:
lolhtml.OnElement("a", func(e *lolhtml.Element) error {
acc.Reset()
return e.OnEndTag(func(t *lolhtml.EndTag) error {
return t.Before(rewrite(html.UnescapeString(acc.String())), lolhtml.Text)
})
}),
lolhtml.OnText("a", func(tc *lolhtml.TextChunk) error {
acc.WriteString(tc.Text())
tc.Remove()
return nil
})
TextChunk.Text is source, so the accumulator holds "café" and not "café". Writing that back as Text escapes the ampersand a second time, and the page shows the escaping. Measured on <a href="/x">café <b>& more</b></a> with rewrite = strings.ToUpper:
without UnescapeString CAF&EACUTE; &AMP; MORE with it CAFÉ & MORE
The first renders as the literal text "CAF&EACUTE; & MORE". See TextChunk.Text for the two other ways to write text back and why neither is this one.
That leaves the descendant elements behind as empty shells - "<b></b>" - since removing text does not remove markup. Add a handler on "a *" calling Element.RemoveAndKeepContent if the whole content is to be replaced rather than only its text.
The alternative is to remove the element in its own handler and rebuild it at the end tag with ContentType HTML, which also lets you change its tag and attributes - at the cost of re-serialising those yourself, escaping included.
A table can contain things that are not in it ¶
A parser moves content that cannot be inside a table to just before the table, which the specification calls foster parenting. There is no tree here to move anything in, so that content is reported where it was written - inside the table - and emitted there. The output is byte-identical, because a browser reading it fosters the content out again, so nothing looks wrong:
<table>stray<tr><td>a</table> in the tree "stray" is a sibling before the table here a text handler on "table" is given it
Measured for text before the first row, text inside a row, text after a cell, and an inline or block element in any of those places, in differential/table_test.go.
Two things follow. Collecting an element's text is the wrong question to ask of a table this way, because the answer includes content that is not in it. And removing the table removes that content, where a tree-based edit would keep it:
<p>before</p><table>stray<tr><td>a</table><p>after</p> Element.Remove here <p>before</p><p>after</p> the same edit on a <p>before</p>stray<p>after</p> tree
A table extractor should therefore take cell content from cells rather than text from the table, which is what examples/gip/tablecsv does.
The third thing that follows is the one that bites a rewrite rather than a reader: an insertion goes where the markup says, and tree construction may put it somewhere else. Measured against golang.org/x/net/html, prepending <input name="csrf"> to a form:
<form method=post><p>x</p> form > input where it was put <table><tr><td><form method=post> td > form > input the same <table><form method=post><tr> table > input outside the form <table><tbody><form method=post><tr> tbody > input outside the form <select><form method=post> body > input outside everything
The bytes say the field is inside the form and the tree says it is beside it. For a hidden field carrying a token, a nonce on a script, or anything else whose position is the whole point, "the markup looks right" is not the test - and a rewrite that cannot tell the shapes apart should refuse the ones it cannot, which is what examples/gip/csrf does. Pinned in differential/table_test.go.
<image> is a spelling of <img> ¶
The parser renames one element. An <image> start tag in HTML content builds an img element, carrying every attribute it had - so a browser fetches the file and runs its onerror - while the rewriter reports what the document spelled:
<image src="x.png" onerror="alert(1)"> in the tree img src="x.png" onerror="alert(1)" here TagName() == "image", and "img" matches nothing
So every rewrite keyed on img has a hole in it: a sanitiser stripping event handlers, a URL rewriter, a mixed-content checker. The fix is to match both names and to check Element.NamespaceURI on the second, because SVG has an image element of its own that keeps its name and is not an img at all:
OnElement("img,image", func(e *Element) error {
if e.TagName() == "image" && e.NamespaceURI() != NamespaceHTML {
return nil // an SVG image
}
…
})
Renaming it with Element.SetTagName is the tidiest answer where a rewrite is editing the document anyway: the output then says what the browser was going to build. Nothing else on the obsolete list is renamed - center, font, marquee, acronym, applet, keygen, isindex and the rest all reach the tree under their own names - so this is one alias rather than a habit. Measured against golang.org/x/net/html in differential/imagealias_test.go, and reported by examples/gip/deprecated.
An HTML tag name inside an <svg> ends the svg ¶
Foreign content is not a container the way an element is. The parser breaks out of SVG and MathML when it meets an HTML tag name, and 44 names do it - b, big, blockquote, body, br, center, code, dd, div, dl, dt, em, embed, h1 to h6, head, hr, i, img, li, listing, menu, meta, nobr, ol, p, pre, ruby, s, small, span, strong, strike, sub, sup, table, tt, u, ul, var - plus font, which breaks out only when it carries a color, face or size attribute:
<svg><rect/><p>x</p><circle/></svg> in the tree svg > rect, then p and circle beside the svg, both HTML elements
Everything after the offending tag is document content rather than image content. That is the whole problem for a rewrite that inlines a file into an <svg>: a file holding one <p> puts the rest of itself in the page. Measured over the full list, including the font condition, in differential/foreign_test.go.
The library's two views of this disagree, and both are reported from the same document at the same moment. Element.NamespaceURI follows the break-out and reports HTML for what comes after it. The selector engine does not: "svg circle" and even "svg > circle" match a circle the tree puts outside the svg, because the engine pops its stack on end tags and this was a start tag. So neither a selector nor a namespace check answers "is this still inside the image", and a rewrite that needs to know has to look for the names itself - which is what examples/gip/inlinesvg does before inlining a file at all.
A template is markup that is not on the page ¶
Handlers fire inside a <template> exactly as they do anywhere else, at any depth of nesting, and a descendant selector crosses the boundary - "template video" matches, and so does a bare "video". The content is parsed as markup, so a rewrite reaches every element in it.
What it does not reach is the page. A template's content is inert until a script clones it: no video plays, no image loads, no script runs. So a match in there is a rewrite of a blueprint, and a count that adds the two together is a count of nothing in particular - a report saying "6 videos" for a page with two and a carousel template is wrong twice over. Decide, and count separately; that is what examples/gip/controls does with a depth counter, because the selector cannot tell you.
The content also follows the template's own parsing rules rather than the surrounding document's, and this is where it stops being a curiosity. A template may hold table rows with no table around them:
<template><tr><td>x</td></tr></template> template > tr > td > "x" <div><tr><td>x</td></tr></div> div > "x": the tags are dropped
The rewriter fires a td handler in both, because it is reading tokens - so a handler call is not evidence that a cell exists. Measured in differential/template_test.go.
A template is also the one element that a table does not foster out, so an insertion into it lands where the bytes say. The trade is the other way round from the table above: there the insertion moves and the content survives, and here the insertion stays and the content can be thrown away. A template holding rows is parsed in a mode that the first inserted *element* ends, and the rows go with it:
<table><template><tr><td>x</td></tr></template></table>
Prepend("<input>", HTML) table > template > input > "x" the rows are gone
Append("<input>", HTML) table > template > tr > td > "x" > input
Prepend("<!--c-->", HTML) table > template > tr > td > "x"
Prepend("hello", Text) table > template > "hello" > tr > td > "x"
Before("<input>", HTML) table > input, table > template > tr > td > "x"
It is the parser's rule and not the insertion's fault - the same bytes written by hand lose the rows too - but a rewrite that prepends anything to elements it matched has no reason to expect it. Prepending a comment or text is safe, appending is safe, and for an element the safe positions are after the content or outside the template.
Removal suppresses output, not handler calls ¶
Element.Remove takes the element and its content out of the output. It does not stop handlers running for that content: a text handler still sees the text of a removed element, and an element handler still runs for its descendants. Their edits are discarded along with everything else, but a handler that accumulates - collecting a document's visible text, counting what it rewrote - has to notice for itself that the content it is looking at is on its way to being dropped. Element.IsRemoved is how an element handler checks.
One corner does not behave the way Remove's description suggests. Removal decides the fate of the element's inner content at the moment it is called, so content inserted inside the element *after* that still reaches the output, with the element's tags no longer around it:
e.Remove()
e.Append("x", lolhtml.HTML) // "x" is emitted, as a child of the parent
e.Append("x", lolhtml.HTML)
e.Remove() // "x" is discarded
The two orders disagree, and only the second does what Remove promises. It matters most when two handlers share a selector, because then the order is decided by which option was written first rather than by either handler: one removing a <script> and one appending inside it will, in one of the two orders, emit the appended content as document markup. Insert first and remove last, or check Element.IsRemoved before inserting inside an element.
Element.Before, Element.After and Element.Replace position content outside the element, and surviving its removal is what they are for.
Whether a handler can tell that it is inside a removed element depends on which handler it is, and the answer is not uniform:
an element handler [Element.IsRemoved] is true for a descendant of a
removed element, so nothing has to be tracked
a text handler [TextChunk.IsRemoved] is false: it reports only the
chunk's own removal
a comment handler the same
An insertion made by a descendant's handler is discarded along with the rest of the subtree - measured for every position - so the "insert first, remove last" hazard above is about one element and its own handlers rather than about a subtree. What a text handler cannot do is count: it is handed the text of a removed element with nothing to say so, so anything accumulating needs an element handler to tell it:
depth := 0
opts := []lolhtml.Option{
lolhtml.OnElement("*", func(e *lolhtml.Element) error {
if !e.IsRemoved() || !e.CanHaveContent() {
return nil
}
depth++
return e.OnEndTag(func(*lolhtml.EndTag) error { depth--; return nil })
}),
lolhtml.OnDocumentText(func(t *lolhtml.TextChunk) error {
if depth > 0 {
return nil // on its way out; not this document's text
}
// count it
return nil
}),
}
which works because an end-tag handler still runs for an element inside a removed one. Measured in removedsubtree_test.go.
What counts as a comment ¶
A comment handler fires for what an HTML parser calls a comment, which is more than the "<!-- ... -->" the name suggests. The spec turns several malformed constructs into "bogus comments", and those arrive as comments here:
<?php echo "hi"; ?> text: ?php echo "hi"; ? <?xml version="1.0"?> text: ?xml version="1.0"? <!bogus> text: bogus <! spaced> text: spaced
So a rewrite that removes every comment removes PHP blocks, XML declarations and processing instructions too - silently, since each of them is a well-formed comment as far as the parser is concerned.
The first two can be told apart by their text, which keeps the "?" that opened them. The last two cannot: "<!x>" and "<!--x-->" both have the text "x", so nothing in the text distinguishes them.
The delimiters do, and their length is knowable without the input. A comment's text is reported as raw source bytes - a carriage return and a NUL are passed through, not normalised - so the source range from Comment.SourceLocation is the text plus exactly the delimiters:
End - Start - len(Text) == 7 the document spelled it <!--...--> anything else it did not
which is the test a stripper wants, and it works from a stream. 3 is a bogus comment or a CDATA section, 2 a processing instruction, 8 a comment closed by "--!>", and 4, 5 and 6 the truncated and short-empty forms; two of the values collide, so what can be told reliably is the ordinary form from the rest rather than the unusual ones from each other. Comment has the measured table. Slicing the input at that range and looking for "<!--" is the other way, for a caller who has the input.
Conditional comments are not one comment either. The downlevel-revealed form
<!--[if !IE]><!--><p>modern</p><!--<![endif]-->
is two comments with real markup between them, and only the first contains "[if". A filter keyed on "[if" keeps that one, drops the closing half, and leaves markup that no longer means what it did.
Not comments: the contents of <script>, <style> and <textarea>, which are raw text, so "<!--x-->" inside one of those is text and no handler sees it. Nor is a stray end tag like "</bogus end tag>", nor a second <!DOCTYPE>. A nested comment ends at the first "-->", leaving the remainder as text.
Writing a comment has a rule of its own, and it is not escaping. Character references are not decoded inside comment data, so EscapeText does not protect a comment - it prevents the break-out and corrupts the text doing it:
SetText("a --> b") // comment data is "a ", and " b -->" becomes text
SetText("a --> b") // comment data is literally "a --> b"
SetText("a - -> b") // comment data is "a - -> b", which is what was meant
What ends a comment is two hyphens, so what keeps one intact is not letting two hyphens sit together. A comment must also not begin with ">" or "->": "<!-->" and "<!--->" are both empty comments, with everything after them left as text.
Cost ¶
A rewrite's cost tracks how many times your handlers run, not how long the document is. Passthrough with no handlers allocates a fixed amount however much goes through it, because the output sink hands the destination a slice over lol-html's own buffer rather than copying it, and a registered handler that never matches costs nothing per byte either.
Per invocation, measured and gated by alloc_test.go:
the unit wrapper 1 allocation each string read or written 1 more [Element.SourceLocation] free, it is two ints [Element.AttributeList], Attributes 4 per attribute
So a handler that reads one attribute costs two allocations per match, one that reads the same attribute twice costs three - nothing is cached - and one that lists every attribute to find a single one costs four times the number of attributes on the element.
How much that costs depends on the shape of the document and not much on its size, and the spread is wide. Held at 200 KB with two element selectors and the document-level text and comment handlers, measured on an M3 Pro, fastest of seven passes:
shape ns/byte alloc B/KB calls a list of <li> with no </li> 103.565 19,667.8 120,000 unclosed <div>s 50.974 6,561.0 40,000 anchors with three attributes 42.233 3,284.0 17,646 <p>ab</p> repeated 41.267 7,290.0 44,444 table rows 35.122 5,180.9 31,578 <div></div> repeated 29.497 2,986.3 18,181 nesting 18,000 deep 25.376 2,986.9 18,183 stray </div>s 3.144 8.5 0 one 200 KB attribute value 0.055 8.6 1
A factor of about 1900 at the same byte count. The worst shape is a list of items without closing tags, at three calls each - the element, its text, and the empty chunk that ends the text node - and it is a navigation menu rather than a pathological document. The floor of 8.5 bytes per KB is what a document nothing matches costs; a page of stray end tags sits there because no handler ever sees one at all. examples/gip/worstshape is the harness, pointed at your own handlers.
A text handler starts at two calls per text node - the content and its empty boundary marker - and two is a floor for a node whose bytes decode rather than a floor in general. A node that is nothing but a truncated multi-byte sequence is one call: fed "<p>\xe9</p>" or "<p>\xc3</p>" as UTF-8, the single call is the node's last chunk and carries the replacement character. A standalone invalid byte is still two, because it is replaced inside the content chunk - see TextChunk.IsLastInTextNode. The writes split a node, and so does the tokenizer: a "<" in text that does not begin a tag is delivered as a chunk of its own, so "3 < 4 and 5 < 6" is six calls from one write. See TextChunk.Text.
Registering selectors has its own cost, paid once per NewWriter. Measured with the options built beforehand, so what is counted is registration rather than the caller's slice:
handlers all distinct all the same selector
0 13 13
1 21 22
2 30 28
4 43 38
8 67 57
So the marginal cost is around seven allocations per distinct selector, and it falls as more are registered because the slices behind them grow in steps. A repeated selector costs about one and a half fewer, since each distinct selector is parsed once and reused - which is the part worth relying on: the saving is real but small, and registering the same selector twice to keep two handlers separate is not something to avoid on cost grounds.
Paid once per NewWriter matters more than it sounds for a workload of many small documents, because a Writer cannot be reused - Writer.Close ends it, there is no reset, and a parsed selector belongs to the Writer that parsed it. So a queue pays the whole registration per item. Measured as the allocations for a complete rewrite:
selectors empty document 1 KB 16 KB
1 23 26 26
10 105 106 106
50 406 408 408
Read down a column and the cost is the rule set; read across and the document adds almost nothing where nothing matches. In time, on an M3 Pro, fifty selectors is about 38 microseconds of construction, which is more than rewriting a one-kilobyte document with them costs - so under about sixteen kilobytes a document, a fifty-selector rewrite spends more time being built than running. Fewer selectors or bigger documents are the two ways out; there is no third.
How many goroutines to run such a queue on is a property of the machine and not of this library, and it is lower than the core count: measured on a twelve-thread M3 Pro, 400 one-kilobyte documents with fifty selectors took 39 ms on one worker, 12.1 ms on four, and 20.2 ms on eight - the peak is at four and it gets worse above it, because a rewrite that is mostly allocation contends. The large-document case saturates rather than declining. examples/gip/queue measures both for a caller's own workload, and fixedcost_test.go gates the shape.
That cost is per handler and not per selector, and a selector list is one selector. Measured over 500 elements that match nothing, so the numbers are registration and matching with no handler ever running:
no handlers 16 allocations one selector 24 a twelve-clause list 24 twelve separate registrations 96
So naming twelve elements in one OnElement costs what naming one costs, while twelve OnElement calls cost eight times as much. The list is not free at match time - it was measured slower per element than a single clause and faster than twelve registrations - but it allocates nothing extra, and a rewrite that has a list of elements to look at should say so in one selector. That is what examples/gip/origins does with the twenty places a URL can hide. Gated in reportshape_test.go.
Matching cost grows with the number registered as well, on every element - there is no index by tag or class - so a tool that registers one handler per rule in a stylesheet pays for all of them at every element of the document.
It is still much cheaper than the alternative. A selector that does not match costs matching; a handler that runs costs a unit wrapper and whatever it reads, so a broad selector that lets the handler decide pays per element of the document rather than per element it cares about. Measured over a 2000-element page where about a tenth of the elements match:
three narrow selectors, all matching 439 allocations one selector list "code,kbd,samp" 424 one "*" handler with a switch 4,228
and where nothing matches at all, fifty narrow selectors still win by an order of magnitude:
fifty narrow selectors, none matching 351 allocations one "*" handler with a fifty-name set 4,031 no handlers 16
So: prefer the narrowest selector that says what the rule is, and where one handler has to cover several names, a selector list is both the cheapest and the clearest way to write it. A "*" handler is for when the rule really is about every element - keeping a depth counter, say. Gated as a comparison rather than as numbers in alloc_test.go, since the figures move with the toolchain.
The numbers above are gated by alloc_test.go as a range rather than a value, because they move with the toolchain: what is asserted is that the marginal cost is single-digit and that a repeat is cheaper than a distinct one.
Writer.Write allocates nothing of its own, whatever the size of the write, so an allocation count measured with one big write is the count a caller streaming from a socket sees too. What a small write costs is the crossing into C: about 100 ns each on an M3 Pro, which makes a byte-at-a-time rewrite of a 64 KB page roughly eight times the time of the same page written whole - a constant factor rather than a change in shape. The per-byte cost is flat from 4 KB to 64 KB, including while the rewriter is buffering an unclosed tag, which is the cheap case rather than the expensive one: a pending tag produces no tokens to hand back. Releases up to v0.1.1 documented that case as quadratic; it is not, and bytecost_test.go gates the shape.
The destination has a cost of its own, and it is not the one a reader of the above would guess. The number of writes it receives is decided by the rewrite rather than by how the document arrives, and what decides it first is *matching* rather than editing. A handler that does nothing at all splits the output around every element it matched. Measured on 200 anchors written as one 6200-byte Write:
no handlers 1 write a selector matching nothing 1 write a handler that does nothing 400 writes the same, reading an attribute 400 writes an end-tag handler 600 writes RemoveAttribute 1200 writes SetAttribute 2600 writes, mostly of one byte
Editing multiplies it again, because a mutated start tag is re-serialised piece by piece: 2000 elements turn one 132 KB write into 22,001 writes of median size one byte.
So the case to watch is the one that looks free: a read-only instrumentation pass - a counter, an audit, a linter - over a rewrite streaming to an unbuffered destination turns one write per document into two per matched element. The output is identical, which is what makes it easy to miss; the write pattern is not.
Wrap an unbuffered destination in a bufio.Writer and all of it collapses to the number of buffer-fulls - two or three writes for the document above. The library does not do that for you because a buffer is a promise not to write yet, and only the caller knows whether the thing at the other end is a browser waiting for a page or a file. See NewWriter; measured in sinkwrites_test.go and examples/gip/backpressure.
Errors ¶
A handler returning a non-nil error stops the rewrite; the error surfaces from the Writer.Write or Writer.Close that was running at the time, wrapped in a HandlerError you can unwrap. A handler that panics does not unwind through Rust: the panic is caught at the boundary and re-raised on the goroutine that called Write or Close.
A value from outside the program can fail an insertion on its own: every path that takes content or a name refuses bytes that are not valid UTF-8, and that fails the rewrite rather than the insertion. The document path does not refuse them - they pass through, or become U+FFFD if a text handler is registered - so a rewrite can carry bytes it cannot write. ErrInvalidUTF8 is the match, and says what to do about it.
lol-html cannot resume after an error, so a Writer that has failed is poisoned and every later Write returns ErrPoisoned. A Writer that panics releases its native resources on the way out, so a caller who recovers does not leak them, but Close should still be deferred as a matter of course.
Rewriting an HTTP response ¶
A rewrite in a proxy or a middleware is four lines. Deciding which responses to apply it to is the rest of the work, and each of the four headers below is a way to break a site.
Content-Encoding first, because it is the one that destroys responses. A compressed body is not text, and what a rewrite does to it depends on something a reader would not expect - whether a text handler is registered:
body element handlers only with a text handler gzip of a small page identical longer, and not gzip any more a PNG header identical two bytes longer every byte value, 0x00 to 0xFF identical 512 bytes valid UTF-8 identical identical
A text handler decodes and re-encodes, so a byte that is not valid in the declared encoding becomes U+FFFD - three bytes where there was one. With only element handlers nothing decodes and the body passes through untouched, which means the rewrite silently did nothing. Neither reports an error. So either ask upstream not to compress, or decompress before the rewriter and recompress after.
Content-Type decides whether this is a document at all. Only text/html; a JSON body survives a rewrite unchanged and that makes the mistake look harmless until a body arrives that is not valid UTF-8.
The charset parameter is the encoding the bytes are in, and it is the authority: a meta in the document is ordinary markup to the rewriter. Pass it to WithEncoding. A label the library cannot use is a reason to pass the body through rather than to guess, and the way to find out is to build the rewriter - NewWriter returns an EncodingError both for a label it does not know and for one that is not ASCII-compatible. The second matters here: "utf-16le" is a real encoding and a real Content-Type, and a rewriter cannot work in it at all.
Content-Length has to go. The rewrite changes the length, and a Content-Length that disagrees with the body is a protocol error - the client truncates the page or waits for bytes that never come. Nothing in net/http fixes this for you.
Then streaming, which is the reason to rewrite in a proxy rather than in a template: flush the response as output arrives, and set FlushInterval on an httputil.ReverseProxy. In a middleware, where the rewriter wraps the http.ResponseWriter rather than the body, the same thing takes three: forward Flush so the handler's own flushes are not stranded, implement Unwrap so http.ResponseController can still find Hijack and the deadline setters through the wrapper, and delete Content-Length in WriteHeader, since after that the header map has gone. Then close the rewriter after the handler returns, which is the one thing an ordinary io.Writer chain does not need. Measured, a handler writing five chunks forty milliseconds apart reaches the client's first byte after 411 microseconds through a streaming middleware and after 210 milliseconds through one that buffers; examples/gip/middleware is both. And a failure partway has already sent a prefix of the page, headers included, so a broken rewrite cannot become a 502 - see "Stopping early" for what the client is left holding.
examples/gip/proxy is all of this in one file; lossytext_test.go gates the table.
Stopping early ¶
A rewrite over a stream that does not end - or one that only needs its first few kilobytes - has two ways to stop, and they answer different questions.
Return an error from a handler when the condition is a place in the document. The error identity survives: wrap a sentinel, and errors.Is finds it in what Write returns and in what Close returns, the latter under ErrPoisoned. Every later Write is refused and still carries the cause, so a caller in a loop does not have to break out of it on the first error.
What has reached the sink at that point is more than a truncation. It is byte for byte what a fresh rewriter produces from that many bytes of the input: no half-serialised element, no tag cut in the middle, and the unit whose handler stopped is not emitted at all. So the partial output can be kept or served. Where the prefix ends depends on which handler stopped:
an element handler the bytes before that element's start tag an end-tag handler the bytes before that end tag a comment handler the bytes before that comment a text handler the bytes before that chunk
The last row is the exception to the rest of this section, because a chunk is not a position in the document: how many chunks a text node arrives in depends on the caller's write sizes, so "stop at the fifth chunk" stops in a different place for a different reader upstream. Count to TextChunk.IsLastInTextNode and the position is the document's again. Measured in earlystop_test.go.
Stop writing and call Close when the condition is the caller's rather than the document's - enough data, long enough, a budget spent. Close reports nil, the output is a rewrite of what was fed, and nothing is poisoned. The cost is granularity: the condition is checked between writes, so the rewrite overshoots by up to one write's worth of document. examples/gip/stopwhen runs both mechanisms over a stream that never ends and prints what each leaves behind.
Either way Close has to be called, and either way it releases everything: the handles a rewrite held are gone afterwards whether it ended at the document's end, at a handler's error, or in the middle of a stream nobody intends to finish reading.
Index ¶
- Constants
- Variables
- func CheckComment(text string) error
- func CheckRawText(tag, content string) error
- func DecodesCharacterReferences(tag string) bool
- func EscapeAttribute(s string) string
- func EscapeText(s string) string
- func IsRawText(tag string) bool
- func Rewrite(html []byte, opts ...Option) ([]byte, error)
- func RewriteString(html string, opts ...Option) (string, error)
- type Attribute
- type Comment
- func (c *Comment) After(content string, ct ContentType) error
- func (c *Comment) Before(content string, ct ContentType) error
- func (u *Comment) Detached() bool
- func (c *Comment) IsRemoved() bool
- func (c *Comment) Remove()
- func (c *Comment) Replace(content string, ct ContentType) error
- func (c *Comment) SetText(text string) error
- func (c *Comment) SetUserData(v any) error
- func (c *Comment) SourceLocation() SourceLocation
- func (c *Comment) Text() string
- func (c *Comment) UserData() any
- type ContentType
- type Doctype
- func (u *Doctype) Detached() bool
- func (d *Doctype) IsRemoved() bool
- func (d *Doctype) Name() (string, bool)
- func (d *Doctype) PublicID() (string, bool)
- func (d *Doctype) Remove()
- func (d *Doctype) SetUserData(v any) error
- func (d *Doctype) SourceLocation() SourceLocation
- func (d *Doctype) SystemID() (string, bool)
- func (d *Doctype) UserData() any
- type DocumentEnd
- type Element
- func (e *Element) After(content string, ct ContentType) error
- func (e *Element) Append(content string, ct ContentType) error
- func (e *Element) Attribute(name string) (string, bool)
- func (e *Element) AttributeList() []Attribute
- func (e *Element) Attributes() iter.Seq2[string, string]
- func (e *Element) Before(content string, ct ContentType) error
- func (e *Element) CanHaveContent() bool
- func (e *Element) ClearEndTagHandlers()
- func (u *Element) Detached() bool
- func (e *Element) HasAttribute(name string) (bool, error)
- func (e *Element) IsRemoved() bool
- func (e *Element) IsSelfClosing() bool
- func (e *Element) NamespaceURI() string
- func (e *Element) OnEndTag(fn func(*EndTag) error) error
- func (e *Element) Prepend(content string, ct ContentType) error
- func (e *Element) Remove()
- func (e *Element) RemoveAndKeepContent()
- func (e *Element) RemoveAttribute(name string) error
- func (e *Element) Replace(content string, ct ContentType) error
- func (e *Element) SetAttribute(name, value string) error
- func (e *Element) SetInnerContent(content string, ct ContentType) error
- func (e *Element) SetTagName(name string) error
- func (e *Element) SetUserData(v any) error
- func (e *Element) SourceLocation() SourceLocation
- func (e *Element) StreamAfter(fn StreamFunc) error
- func (e *Element) StreamAppend(fn StreamFunc) error
- func (e *Element) StreamBefore(fn StreamFunc) error
- func (e *Element) StreamPrepend(fn StreamFunc) error
- func (e *Element) StreamReplace(fn StreamFunc) error
- func (e *Element) StreamSetInnerContent(fn StreamFunc) error
- func (e *Element) TagName() string
- func (e *Element) TagNamePreserveCase() string
- func (e *Element) UserData() any
- type EncodingError
- type EndTag
- func (t *EndTag) After(content string, ct ContentType) error
- func (t *EndTag) Before(content string, ct ContentType) error
- func (u *EndTag) Detached() bool
- func (t *EndTag) Name() string
- func (t *EndTag) NamePreserveCase() string
- func (t *EndTag) Remove()
- func (t *EndTag) SetName(name string) error
- func (t *EndTag) SourceLocation() SourceLocation
- func (t *EndTag) StreamAfter(fn StreamFunc) error
- func (t *EndTag) StreamBefore(fn StreamFunc) error
- func (t *EndTag) StreamReplace(fn StreamFunc) error
- type HandlerError
- type MemorySettings
- type NativeError
- type Option
- func OnComment(selector string, fn func(*Comment) error) Option
- func OnDoctype(fn func(*Doctype) error) Option
- func OnDocumentComment(fn func(*Comment) error) Option
- func OnDocumentEnd(fn func(*DocumentEnd) error) Option
- func OnDocumentText(fn func(*TextChunk) error) Option
- func OnElement(selector string, fn func(*Element) error) Option
- func OnText(selector string, fn func(*TextChunk) error) Option
- func WithESITags() Option
- func WithEncoding(label string) Option
- func WithGracefulBailOut() Option
- func WithMemorySettings(m MemorySettings) Option
- func WithStrict(strict bool) Option
- type SelectorError
- type Sink
- type SourceLocation
- type StreamFunc
- type TextChunk
- func (t *TextChunk) After(content string, ct ContentType) error
- func (t *TextChunk) Before(content string, ct ContentType) error
- func (t *TextChunk) Bytes() []byte
- func (u *TextChunk) Detached() bool
- func (t *TextChunk) IsLastInTextNode() bool
- func (t *TextChunk) IsRemoved() bool
- func (t *TextChunk) Remove()
- func (t *TextChunk) Replace(content string, ct ContentType) error
- func (t *TextChunk) SetUserData(v any) error
- func (t *TextChunk) SourceLocation() SourceLocation
- func (t *TextChunk) StreamAfter(fn StreamFunc) error
- func (t *TextChunk) StreamBefore(fn StreamFunc) error
- func (t *TextChunk) StreamReplace(fn StreamFunc) error
- func (t *TextChunk) Text() string
- func (t *TextChunk) UserData() any
- type Writer
Examples ¶
- Comment.SetText
- DocumentEnd.Append
- Element.Attribute (RawSource)
- Element.Attribute (Repeated)
- Element.Before (Order)
- Element.Remove
- Element.SetInnerContent (RawText)
- EscapeAttribute
- EscapeText
- NewWriter
- OnDocumentComment (BogusComments)
- OnElement (MatchingIsDecidedFirst)
- OnElement (NotIsWrongForCompoundSelectors)
- TextChunk.IsLastInTextNode
- WithEncoding (Unrepresentable)
- WithStrict
Constants ¶
const ( NamespaceHTML = "http://www.w3.org/1999/xhtml" NamespaceSVG = "http://www.w3.org/2000/svg" NamespaceMathML = "http://www.w3.org/1998/Math/MathML" )
The three namespaces an element can be parsed in. Element.NamespaceURI returns one of these, so comparing its result against them compares two constants.
Variables ¶
var ErrAmbiguousTag = errors.New("lolhtml: strict mode refused an ambiguous tag")
ErrAmbiguousTag matches the error WithStrict produces when the parser reaches a tag whose meaning depends on markup it cannot see:
if errors.Is(err, lolhtml.ErrAmbiguousTag) {
// the response is truncated; discard it
}
It is the other failure a streaming caller has to act on, and the alternative was matching lol-html's prose - which this package's own tests were doing, with strings.Contains(ne.Message, "ambiguous").
var ErrClosed = errors.New("lolhtml: writer is closed")
ErrClosed is returned by Write on a Writer that has already been closed.
var ErrCommentBreakout = errors.New("lolhtml: text would end the comment it is inside")
ErrCommentBreakout is returned by CheckComment for text that would not stay inside a comment.
var ErrDetached = errors.New("lolhtml: rewritable unit used outside its handler")
ErrDetached is returned by a method that mutates a rewritable unit (Element, Comment, TextChunk, Doctype, DocumentEnd, EndTag) after its handler has returned - and by every method on a Sink retained past its StreamFunc.
lol-html only guarantees these values are alive for the duration of the handler invocation, so golol-html detaches the Go wrapper on return. Copy out whatever you need inside the handler instead of retaining the unit.
Not by a getter. A getter has nowhere to put an error without a second return value, so a detached one answers with a zero value and says nothing:
every mutator ErrDetached
SetAttribute, RemoveAttribute, SetTagName,
Before, Append, Replace, OnEndTag, the
streaming insertions, SetUserData
every getter a zero value and no error
TagName "", CanHaveContent false,
SourceLocation {0, 0}, Attribute ("", false),
Attributes no iterations
Remove, ClearEndTagHandlers nothing, having no error to give
HasAttribute ErrDetached, because its signature has room
So a detached unit gives plausible answers. Element.Attribute reporting ("", false) is indistinguishable from the attribute being absent, and Element.HasAttribute is the only getter that can tell those apart - which is an accident of its signature rather than a design, and worth knowing when choosing between them.
A Sink is the exception, and the better behaviour: its writes report ErrDetached like any mutator, and so does Sink.Err, because its signature has room for the answer. So a retained sink cannot be mistaken for a working one - unlike a retained element, whose getters answer as if the document were empty.
Element.Detached and the same method on the other units answer the question directly, and cost nothing.
var ErrIncompleteRune = errors.New("lolhtml: streamed content has an unfinished UTF-8 sequence")
ErrIncompleteRune reports a UTF-8 sequence written into a Sink that never gets completed.
Splitting a rune across writes is fine: lol-html holds the prefix and joins it to the next Sink.WriteChunk, which is what makes copying from an arbitrary reader into Sink.AsWriter safe. Two things do not finish it, and both were silent:
the StreamFunc returns the held bytes are dropped, so the insertion is
shorter than the content and nothing says so
a WriteString arrives the held bytes become U+FFFD and the string is
written after them
The first happens whenever the source is truncated mid-character. The second is a mistake in the calling code, and Sink.WriteChunk had documented it as something not to do without there being any way to notice having done it.
var ErrInvalidUTF8 = errors.New("lolhtml: content is not valid UTF-8")
ErrInvalidUTF8 matches the error every write path returns when the content it was given is not valid UTF-8:
if err := e.SetInnerContent(name, lolhtml.Text); errors.Is(err, lolhtml.ErrInvalidUTF8) {
// name came from outside; fix it rather than failing the page
}
Any value from outside the program can be invalid UTF-8 - a request header, a query parameter, a filename, a column written by something that was not checking. Inserting one fails the whole rewrite rather than that one insertion, so a page personalised from a header is one Latin-1 name away from not being served at all.
The document path is the other way round, which is why this is easy to miss while testing: bytes arriving in the document are not refused. With no text handler they pass through untouched; with one they come back as U+FFFD, because a text handler decodes and re-encodes. So the same bytes are fine to carry and not fine to write.
The fix is on the caller's side, and there is a standard one: strings.ToValidUTF8 replaces the bad bytes, and utf8.ValidString says whether to reject the value instead. Which of those is right is the caller's decision, which is why this is an error rather than a silent replacement.
Measured for every path that takes content or a name: the ContentType insertions, Element.SetAttribute for both name and value, Element.SetTagName, Comment.SetText, and both sink writes. A trailing partial sequence in Sink.WriteChunk is not this error - that is the case WriteChunk exists for - and one still open when the StreamFunc returns is ErrIncompleteRune.
The classification is made here rather than read off lol-html's message: when a write fails, the content it was given is checked, so a reword upstream cannot turn the guard off.
Reading is lossy for those bytes whatever the unit: a handler is handed U+FFFD and never the byte, so no rewrite can see what the document held. Writing is lossy for text alone. Measured: with a text handler registered, invalid bytes in text or raw text come back as U+FFFD in the output, while an attribute value, a comment and a tag name read the same way keep their bytes, because those are re-emitted from the source. So a tool diagnosing a mis-declared document cannot also be the pass that copies it. See invalidutf8_test.go and examples/gip/mojibake.
var ErrMemoryLimitExceeded = errors.New("lolhtml: the memory limit has been exceeded")
ErrMemoryLimitExceeded matches the error lol-html reports when a rewrite exceeds MemorySettings.MaxMemory:
if errors.Is(err, lolhtml.ErrMemoryLimitExceeded) {
// the response is truncated; discard it
}
It is a match rather than a value: the error a caller receives is a NativeError carrying lol-html's own message, and this is what errors.Is compares it against.
Worth distinguishing because it is one of the two failures a streaming caller has to act on rather than merely report, and because WithGracefulBailOut changes what has already reached the sink when it happens.
var ErrNilOption = errors.New("lolhtml: nil option")
ErrNilOption is returned by NewWriter when the options it was given include a nil one.
It is a mistake rather than a condition, and it is an easy one to make: a conditional that leaves an Option unset, a slice built with a gap, a helper that returns a zero value on a path nobody tested. Before this it was a nil pointer dereference inside NewWriter, which is a panic with a stack trace pointing at the library rather than at the call that made it.
A nil option is refused rather than skipped, for the same reason an unsupported selector is refused rather than ignored: a rewrite that quietly did less than it was told to is worse than one that did not start. The error says which position was nil, because a caller building options in a loop needs to know which iteration it was.
var ErrPoisoned = errors.New("lolhtml: writer is poisoned by an earlier error")
ErrPoisoned is returned by Write and Close on a Writer whose earlier Write or Close failed. lol-html leaves the rewriter unusable after an error, and calling into it again would abort the process, so golol-html refuses instead.
The refusal wraps the error that caused it, so errors.Is and errors.As reach past the sentinel to the handler error or destination-writer error underneath. That matters because the first failure is reported once, from the call that was running, and the ordinary Go shape - write, then check Close - asks afterwards. A handler panic is the exception: it poisons the Writer on its way to the caller without leaving an error, and the sentinel then stands alone.
What the destination already holds when this happens is not nothing: everything before the token whose handler failed has been written to it, and it is well-formed markup. Refusing a document therefore means buffering the output and forwarding it only on success. Measured in handlerfailure_test.go.
var ErrRawTextBreakout = errors.New("lolhtml: inserted content would end the raw-text element it is inside")
ErrRawTextBreakout is returned by an insertion into the content of a raw-text element when the inserted content would end that element.
Ten element names hold content an HTML parser does not read as markup. Nine of them can be ended from inside, and an insertion into one of those is checked:
script style raw text; references are not decoded iframe noembed noframes noscript xmp the same, and nothing else is special textarea title escapable raw text; references decode
The tenth is plaintext, which runs to the end of the input: nothing closes it, so nothing can break out of it and there is nothing to check. See the note on Element.CanHaveContent and plaintext.
Inside those nine an HTML parser is not looking for markup, so the only thing that can end one is its own closing tag. Content passed as HTML is inserted verbatim, which means a "</script>" in it does not become part of the script - it closes the script, and everything after it is markup in the document. That is a working injection whenever the content came from anywhere untrusted, and it is silent: the output parses, nothing errors, and the script that runs is not the script that was written.
Comment.SetText has always refused the same shape of input for the same reason. This is the other half of that.
The insertion is refused rather than escaped because the escape that works depends on the element, and for five of the nine there is none. Where one exists the error names it: a JavaScript or JSON "<\/script" for a script, a CSS "\3c /style" for a style, and for a textarea or a title, inserting as Text instead, because references are decoded there. Inside an iframe, noembed, noframes, noscript or xmp, references are not decoded and there is no inner language, so the sequence cannot be represented at all and the content has to change.
What is checked is the position, not the type: insertions into the element's own content, which are Element.Prepend, Element.Append, Element.SetInnerContent and EndTag.Before. Element.Before, Element.After and Element.Replace write outside the element, where a closing tag is ordinary markup, and are not affected. Nor is ContentType Text, which escapes the "<" and so cannot end anything - though in the seven where references do not decode, Text corrupts the content instead; see the package documentation on inserting into a script or a style.
Two gaps, both measured. The streaming insertions (Element.StreamPrepend and the rest) are not checked, because content arrives in pieces and a closing tag can straddle two of them. Neither are TextChunk.Before, TextChunk.After and TextChunk.Replace, which is the more surprising one, because editing a script through a text handler is the obvious way to do it: a text chunk has no way to name the element it is inside, so the check has nothing to look up. Until it does, a text handler has to guard itself: it knows the tag, because it registered the selector, and IsRawText answers for a tag it does not know in advance.
A rename is the other way round this. Element.SetTagName can turn a script into a div, and its text into markup, without inserting anything at all - so there is nothing for this check to look at. See that method, and Element.RemoveAndKeepContent, which does it by taking the tags away altogether. IsRawText is the list, for a caller who has to decide.
The check is by tag name only, so it does not consider namespaces. In SVG and MathML none of these elements is raw text, and the refusal there is conservative rather than wrong: an inserted "</title>" still ends an <svg><title>, by ordinary tree construction rather than by the tokenizer.
var ErrReentrant = errors.New("lolhtml: writer re-entered from its own handler")
ErrReentrant is returned by Writer.Write and Writer.Close when they are called from inside the Writer's own handler - or from the destination writer, which lol-html calls on the same stack.
A handler runs in the middle of lol_html_rewriter_write, and lol-html has no idea it is being called at all. Re-entering it there is not a Go-level nuisance but memory-unsafety: a nested Write hands the same rewriter to Rust twice, which is a second &mut alias, and the parser state it corrupts is reported - when it is reported - as an internal consistency error against a document that was fine. A nested Close is worse, because it finishes the document and then frees the rewriter and every handle underneath the call still running on them, so the outer write continues on freed memory.
The refusal changes nothing for a Writer used the ordinary way. It matters for an "early stop" handler, which is where the reflex to call Close from inside a handler comes from: stop by returning an error from the handler instead, which Writer.Write reports and which leaves the Writer poisoned rather than half-freed. See OnElement on stopping early. Measured in reentrancy_test.go.
It is not a poison. The interrupted call carries on and reports whatever it was going to, so a caller who ignores a reentrant Close still gets the real outcome from the real one.
Functions ¶
func CheckComment ¶ added in v0.2.0
CheckComment reports whether text can be the data of a comment, for a caller building the comment itself rather than going through Comment.SetText.
It exists for the gap this file already names: SetText is the only path that writes a comment's text for you, and a comment assembled by hand out of HTML content has no guard. That path is a real one - DocumentEnd.Append and the insertion methods take markup, so a summary emitted as a trailing comment is built by hand - and the failure is silent in the same way the raw-text one is, which is why CheckRawText is exported for the same reason.
The rule is the tokenizer's: comment data must not contain "-->" or "--!>", and must not begin with ">" or "->". Nothing else is special - "--", "--!", "----", "<!--", a trailing "-", a NUL, "]]>" and a bare ">" after the first character are all fine, because a comment ends only at those two sequences and the two abrupt-closing forms at the start.
There is no escaping, and that is not an omission: nothing inside a comment is a character reference, so there is no spelling of those characters that a comment can hold and still mean. A caller with text that fails this has to change it or refuse it - replacing "--" with "- -" is the usual choice, and it is a choice about meaning rather than an escape, which is why the library does not make it.
What it refuses is exactly what SetText refuses, pinned over every string up to four characters of "-", "!", ">", "<", "a", newline and space by a test that compares the two paths rather than assuming they agree. And what it refuses is exactly what leaks: appended by hand, ">" becomes the complete comment "<!-->" followed by "-->" as text in the document, and "a-->b" ends the comment at "a" and leaves "b-->" behind.
func CheckRawText ¶ added in v0.2.0
CheckRawText reports whether content would end the raw-text element named tag, returning an error wrapping ErrRawTextBreakout if it would and nil otherwise. A tag that is not one of the raw-text elements is always nil, and so is [plaintext], which nothing closes.
It exists because one set of insertion paths cannot apply this check for you. The Element and EndTag methods know which element they are writing into and refuse a breakout themselves. A TextChunk does not: lol-html hands a chunk over with no way to ask what element it came from, so TextChunk.Before, TextChunk.After and TextChunk.Replace with HTML write whatever they are given.
That is the path a rewrite editing a stylesheet or a script body has to use, because Text escapes the three markup characters and raw text does not decode references - so a CSS ">" comes back as ">" and a script's "a < b" as "a < b". A handler registered as OnText("style") knows the tag name it asked for, and can hand it to this:
lolhtml.OnText("style", func(t *lolhtml.TextChunk) error {
// … accumulate to IsLastInTextNode, rewrite the CSS …
if err := lolhtml.CheckRawText("style", css); err != nil {
return err
}
return t.Replace(css, lolhtml.HTML)
})
The rule it applies is the tokenizer's, measured rather than assumed: raw text ends at "</" followed by the tag name in any case, followed by ">", "/", ASCII whitespace, or the end of the content - because what follows an insertion is the rest of the document. The error names the offending sequence, its offset, and what to write instead for that element.
func DecodesCharacterReferences ¶ added in v0.2.0
DecodesCharacterReferences reports whether an HTML parser decodes character references in the content of an element named tag.
It is the predicate for the reading question, where IsRawText is the predicate for the writing one. The same ten names come up in both, and the answers are different sets: of the ten elements whose content is not markup, eight leave references alone and two - textarea and title, the escapable raw-text pair - decode them. Everything else decodes, because its content is markup.
So a program deciding whether to unescape the text it was handed wants this, and IsRawText would be wrong by exactly those two names. Wrong in both directions and silently: unescaping a <style>'s content makes it say something it does not say, and not unescaping a <title>'s loses the decoding a parser performs. The NUL rule does key on IsRawText exactly - inside those ten a NUL becomes U+FFFD, elsewhere a parser drops it.
Measured against golang.org/x/net/html for every element name in the HTML index, in both directions, by TestTheDecodeListIsTheParsersList in the differential suite - which is what makes this a question the library can answer rather than two names to copy out of a doc comment. examples/gip/texttruth composes it with the other three rules that separate reported text from parsed text.
Comparison is by tag name, case-insensitive for ASCII, as for IsRawText. It does not consider namespaces: in SVG and MathML none of these elements is raw text, so a <title> inside an <svg> holds ordinary markup and decodes. A name that is not an element decodes, which is the right answer for content that is parsed as markup.
func EscapeAttribute ¶ added in v0.2.0
EscapeAttribute escapes s for the inside of a quoted attribute value that you are writing yourself, as in
`<img src="` + EscapeAttribute(u) + `" alt="` + EscapeAttribute(a) + `">`
It escapes what EscapeText escapes and both quote characters, so the result is safe between single or double quotes without the caller having to say which. It does not escape whitespace, so the value has to be quoted: an unquoted attribute ends at the first space and no escaping prevents that.
Element.SetAttribute is the better tool whenever the attribute is on an element a handler already has, because it needs no escaping from the caller at all. This is for the case SetAttribute cannot reach: an element that does not exist yet, being built as markup.
The caveats on EscapeText apply here too, in particular that escaping a URL does not make the URL safe.
Example ¶
EscapeAttribute escapes both quote characters, so a value is safe between quotes the caller chose without having to say which.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
title := `" onload=alert(1) x="`
out, err := lolhtml.RewriteString(`<div></div>`,
lolhtml.OnElement("div", func(e *lolhtml.Element) error {
return e.SetInnerContent(
`<img alt="`+lolhtml.EscapeAttribute(title)+`">`, lolhtml.HTML)
}))
fmt.Println(out, err)
}
Output: <div><img alt="" onload=alert(1) x=""></div> <nil>
func EscapeText ¶ added in v0.2.0
EscapeText escapes s for a position where HTML text is expected: between tags, where the value would otherwise be read as markup.
It replaces &, < and > with the character references &, < and >, and leaves everything else alone. That is exactly what the library does for content passed with ContentType Text, so
e.SetInnerContent(s, Text)
and
e.SetInnerContent("<b>"+EscapeText(s)+"</b>", HTML)
escape s identically; the second is how to keep that guarantee while also inserting markup. The equivalence is pinned by a test that compares the two paths over a corpus rather than assumed.
EscapeText is not enough for an attribute value: quotes pass through it unchanged, so a value containing one would end the attribute and start another. Use EscapeAttribute there.
The argument is a literal value, not markup. Everything this library reports - Element.Attribute, TextChunk.Text, Comment.Text - is raw source with character references still encoded, so escaping one of those again double-escapes it and "Configure & run" becomes "Configure &amp; run". For a value read from the document, either leave it raw and do not escape it, or decode it first with html.UnescapeString from the standard library and escape the result - remembering that in an attribute value that decoder is not the parser's, since it decodes a semicolon-less name that a browser leaves alone. Which of those is right depends on where the value came from as well as where it is going, because each context lets through the character the other one ends on. A value that came from text can be written back into text raw, and needs the quote escaped to go inside quotes you chose yourself. A value that came from an attribute can go into another attribute raw, and needs the "<" escaped to become text - an attribute may hold a raw "<", so a title of "<img src=x onerror=alert(1)>" written into an element's text is an element. Measured both ways in differential/context_test.go.
Escaping is not sanitising. A URL is still a URL after escaping, so
`<a href="` + EscapeAttribute(u) + `">`
is well-formed markup even when u is "javascript:alert(1)". Deciding which schemes to allow is a separate job, and neither function does it.
Example ¶
EscapeText is what the library applies for ContentType Text, so the two ways of inserting the same value agree.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
value := `a < b && c > d`
byLibrary, _ := lolhtml.RewriteString(`<p></p>`,
lolhtml.OnElement("p", func(e *lolhtml.Element) error {
return e.SetInnerContent(value, lolhtml.Text)
}))
byHand, _ := lolhtml.RewriteString(`<p></p>`,
lolhtml.OnElement("p", func(e *lolhtml.Element) error {
return e.SetInnerContent("<b>"+lolhtml.EscapeText(value)+"</b>", lolhtml.HTML)
}))
fmt.Println(byLibrary)
fmt.Println(byHand)
}
Output: <p>a < b && c > d</p> <p><b>a < b && c > d</b></p>
func IsRawText ¶ added in v0.2.0
IsRawText reports whether an element with this tag name holds content that an HTML parser does not read as markup.
Ten names do:
script style raw text; character references are not decoded iframe noembed noframes noscript xmp the same, and nothing else is special textarea title escapable raw text; references decode plaintext raw text, and it runs to the end of the input
The package already uses this list for ErrRawTextBreakout, which covers nine of the ten: nothing closes a plaintext, so nothing can break out of one. This reports all ten, because the hazards a caller has to handle for itself are about the content not being markup, not about closing tags:
[Element.SetTagName] renaming one turns its text into markup [Element.RemoveAndKeepContent] taking the tags away does the same [TextChunk.Before] and the rest insertions through a text handler are not checked
Each of those says so, and until now said it without giving the caller any way to ask. A tool that renames, unwraps, or rewrites text under a wide selector has to know the list, and the alternative to asking is copying ten names out of a doc comment - which then falls behind the parser silently. The list here is measured against the parser by TestTheGuardCoversEveryRawTextElement, so it cannot.
It is the predicate for the insertion question - can content written into this element end it - and not for the other question these ten names come up in: whether character references in the content are decoded. That one is DecodesCharacterReferences, which is this list minus textarea and title. The NUL rule does key on this list exactly - inside these ten a NUL becomes U+FFFD, elsewhere a parser drops it - and both are measured against the parser for every element name in differential/texttruth_test.go, with the whole conversion in examples/gip/texttruth.
The comparison is by tag name and is case-insensitive for ASCII, so it accepts both what Element.TagName reports and what Element.TagNamePreserveCase does. It does not consider namespaces: in SVG and MathML none of these elements is raw text, and a <title> inside an <svg> holds ordinary markup. A caller who cares about that distinction has the namespace - see Element.NamespaceURI.
Types ¶
type Attribute ¶
type Attribute struct {
// Name is the attribute name, lowercased.
Name string
// NamePreserveCase is the name as spelled in the source, which matters for
// foreign content such as SVG's viewBox.
NamePreserveCase string
// Value is the attribute value as it appeared in the source, with
// character references left encoded. See Element.Attribute.
Value string
}
An Attribute is one attribute of an element. An Attribute has no source location, and its bytes cannot be recovered from the ones that do. Element.SourceLocation covers the whole start tag, and searching that for an attribute's own range does not work on markup this library deliberately preserves:
<div a="1" a="2"> a duplicate: two entries, both named a <div a=1 a='2' a="1"> two entries with the same name and the same value <div data-a="x" a="1"> searching for "a=" finds it inside data-a <div a> a bare attribute, so there is no "a=" to find
The first two are the ones with no answer: a repeated attribute is kept rather than dropped, so name and value together do not identify one. A tool keyed on byte offsets can therefore act on an element or a whole start tag but not on one attribute of it, which is what examples/gip/shrink meets when it wants to propose removing a single attribute and has to fall back to bytes.
type Comment ¶
type Comment struct {
// contains filtered or unexported fields
}
A Comment is a comment token matched by a comment handler, which is not the same thing as a comment.
An HTML parser produces a comment token for four different pieces of source syntax, and all four arrive here with their delimiters stripped and nothing to say which they were:
<!--a--> a comment Text "a" <!bogus> a bogus comment Text "bogus" <?php echo 1; ?> a processing instruction Text "?php echo 1; ?" <![CDATA[x]]> a CDATA section in HTML Text "[CDATA[x]]"
The last two are the ones that matter, because they are not comments to whatever reads the document next: the second is a PHP block in a template, and the third is character data in every language that has CDATA - including SVG, where the same bytes are not a comment token at all and their content is reported as text.
Sniffing Comment.Text does not answer it. A comment containing "?php x ?" reads exactly like the processing instruction, because the difference is in the delimiters and those are gone. What does answer it is how much source the token occupied, from Comment.SourceLocation, measured against the text:
source End-Start-len(Text) <!--a--> 7 a comment, closed by --> <!--a--!> 8 a comment, closed by --!> <!--a 4 a comment, closed by the end of the input <!--> 5 a comment, the short empty form <!---> 6 the same, one dash longer <!bogus> 3 a bogus comment <![CDATA[x]]> 3 a CDATA section, which is a bogus comment in HTML <?php a ?> 2 a processing instruction <!bogus 2 a bogus comment, closed by the end of the input <?a 1 a processing instruction, closed by the same
So 7 is "the document spelled this <!--...-->" and everything else is "it did not", which is the test a rewrite that removes or edits comments wants: two of the values collide - a truncated bogus comment and a processing instruction are both 2 - so the distinction that can be made is with the ordinary form rather than between the unusual ones.
Editing normalises the delimiters. Comment.SetText on any of the four writes <!--text-->, so a processing instruction becomes a comment and the template engine downstream stops seeing it. Comment.Remove removes the whole token, whatever it was spelled as, which is the one operation with no surprise in it. Measured in commentshapes_test.go.
It is valid only for the duration of the handler that received it; see the package documentation on handler lifetime.
func (*Comment) After ¶
func (c *Comment) After(content string, ct ContentType) error
After inserts content immediately after the comment.
Called twice, the second insertion lands before the first: see the package documentation on two insertions of the same kind.
func (*Comment) Before ¶
func (c *Comment) Before(content string, ct ContentType) error
Before inserts content immediately before the comment.
func (*Comment) Detached ¶
func (u *Comment) Detached() bool
Detached reports whether this value has outlived its handler. Every other method returns ErrDetached, or a zero value, once this is true.
func (*Comment) IsRemoved ¶
IsRemoved reports whether the comment has been removed by a handler.
This comment, and not the element it is in: a comment inside an element another handler has removed reports false. Element.IsRemoved answers for an ancestor; this does not. See TextChunk.IsRemoved, which has the same rule for the same reason.
func (*Comment) Replace ¶
func (c *Comment) Replace(content string, ct ContentType) error
Replace replaces the comment with content.
func (*Comment) SetText ¶
SetText replaces the comment's text, and refuses a value that would end the comment early:
c.SetText("--><img src=x>")
// lolhtml: comment_text_set: Comment text shouldn't contain a
// comment-closing sequence.
Refused, not escaped - which this documentation used to say, along with the conclusion that untrusted input is therefore safe to pass. It is safe in the sense that nothing breaks out, and it fails the rewrite: a caller handing this arbitrary text has to expect an error and decide what to do about it, not expect a sanitised comment.
There is no escaping that would work. A comment ends at "-->" or at "--!>", and nothing inside a comment is a character reference, so there is no spelling of those four characters that a comment can hold and still mean. Refusing is the only honest option, which is the same reason ErrRawTextBreakout refuses an insertion into a script.
Measured: "-->", "--!>", "->", "a-->b", "a--!>b" and "<!-->" are refused; "--", "--!", "<!--" and "a--b" are accepted, and each of those round-trips as one comment.
This is the only path that writes a comment's text for you. Building a comment by hand out of HTML content has no equivalent guard - see the package documentation on building markup yourself, and CheckComment, which applies this same rule to text a caller is about to put inside a comment it assembled itself.
It also writes the delimiters, as <!-- and -->, whatever the document used. A token spelled <?php echo 1; ?> is a comment token, and setting its text turns it into <!--...-->, which is a comment to a browser and nothing to the template engine that was going to run it. Comment has the measured table and the test for telling one from the other before writing.
Example ¶
Comment.SetText refuses text that would end the comment.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
out, err := lolhtml.RewriteString(`<!--x-->`,
lolhtml.OnDocumentComment(func(c *lolhtml.Comment) error {
return c.SetText("safe")
}))
fmt.Println(out, err)
_, err = lolhtml.RewriteString(`<!--x-->`,
lolhtml.OnDocumentComment(func(c *lolhtml.Comment) error {
return c.SetText("a --> b")
}))
fmt.Println("refused:", err != nil)
}
Output: <!--safe--> <nil> refused: true
func (*Comment) SetUserData ¶
SetUserData attaches a value to this comment, readable by another handler that is given the same comment - an OnComment handler and an OnDocumentComment one both see it. Go handlers can usually close over the value instead.
It costs a handle held until the rewrite ends, and setting it to nil releases that handle immediately; see Element.SetUserData, where the cost and the mitigation are set out.
func (*Comment) SourceLocation ¶
func (c *Comment) SourceLocation() SourceLocation
SourceLocation returns the byte range the comment occupied in the input.
func (*Comment) Text ¶
Text returns the comment's text, without the delimiters, as raw source text with character references left encoded - nothing inside a comment is a reference, so there is nothing to decode. See TextChunk.Text.
The delimiters removed are whatever the document used, which is not always <!-- and -->: see Comment for the four syntaxes that arrive here and how to tell them apart.
type ContentType ¶
type ContentType int
ContentType says how inserted content should be interpreted.
The choice is context-insensitive: Text escapes the same three characters wherever the content lands. That is correct in element content, in escapable raw text (textarea and title) and inside a comment, and it is wrong inside <script> and <style>. See the package documentation on inserting into a script or a style.
const ( // Text inserts content as text, escaping <, > and & so that none of it can // be read as markup. This is the safe choice for untrusted values. // // It escapes nothing else. A quote, an apostrophe and a backtick pass // through, which is correct for element content. So does a NUL, as a literal // zero byte, and what a parser then does with it depends on where it landed: // measured, a NUL in element content is dropped, one in raw text or a comment // becomes U+FFFD, and one in an attribute value is kept. None of those is the // value that was written, so a NUL does not survive a round trip; see the // package documentation on source being unpreprocessed. Text ContentType = iota // HTML inserts content as raw markup, parsed as part of the document. The // caller is responsible for everything about it, including that it does not // end the element it is being inserted into. HTML )
func (ContentType) String ¶
func (ct ContentType) String() string
type Doctype ¶
type Doctype struct {
// contains filtered or unexported fields
}
A Doctype is the document type declaration, as in <!DOCTYPE html>.
It can be read and it can be removed. There is no way to write one: this type has no Before, After or Replace, because lol-html offers none, so a rewrite that wants to change a legacy declaration into <!DOCTYPE html> has to remove the old one and insert the new one somewhere else - and the only place available is before the first element:
pending := false
lolhtml.OnDoctype(func(d *lolhtml.Doctype) error {
d.Remove()
pending = true
return nil
}),
lolhtml.OnElement("*", func(e *lolhtml.Element) error {
if !pending {
return nil
}
pending = false
return e.Before("<!DOCTYPE html>", lolhtml.HTML)
})
That works on an ordinary document and fails silently on three shapes, measured against golang.org/x/net/html in differential/doctype_test.go:
<!DOCTYPE …><html>… upgraded
<!DOCTYPE …><!--c--><html>… upgraded: a comment before a doctype is allowed
<!DOCTYPE …> <html>… upgraded: so is whitespace
<!DOCTYPE …>text<html>… the new one lands after text, and a parser
ignores a doctype there: quirks mode
<!DOCTYPE …> nothing to insert before: quirks mode
<!DOCTYPE …>just text the same
Adding one without removing the old is not an alternative: a second DOCTYPE is a parse error and dropped, so the legacy declaration still applies.
So the decision to remove has to be made before the place to put the replacement is known, and nothing in the doctype handler can know it. A rewrite that must be right about this has to read the document twice - the first pass answering "is there an element before any text" - or leave the declaration alone.
It is valid only for the duration of the handler that received it; see the package documentation on handler lifetime.
func (*Doctype) Detached ¶
func (u *Doctype) Detached() bool
Detached reports whether this value has outlived its handler. Every other method returns ErrDetached, or a zero value, once this is true.
func (*Doctype) IsRemoved ¶
IsRemoved reports whether the declaration has been removed by a handler.
func (*Doctype) Name ¶
Name returns the doctype name, such as "html". The second result is false when the declaration has no name.
func (*Doctype) PublicID ¶
PublicID returns the PUBLIC identifier. The second result is false when there is none, which is the case for the modern <!DOCTYPE html>.
func (*Doctype) Remove ¶
func (d *Doctype) Remove()
Remove removes the declaration from the output.
Which is the largest rendering change any single token removal can make: a document with no doctype is in quirks mode, where the box model, table cell heights, line heights and a dozen other things differ. Nothing warns, and the diff is one deleted token.
See Doctype for the other half - that there is no way to write one back, and what that costs a rewrite that wants to upgrade a legacy declaration rather than delete it.
func (*Doctype) SetUserData ¶
SetUserData attaches a value to the declaration. Go handlers can usually close over the value instead.
func (*Doctype) SourceLocation ¶
func (d *Doctype) SourceLocation() SourceLocation
SourceLocation returns the byte range the declaration occupied in the input.
type DocumentEnd ¶
type DocumentEnd struct {
// contains filtered or unexported fields
}
A DocumentEnd marks the end of the input, and exists so a handler can append trailing content after everything else - an injected script, a closing comment, a summary built up while rewriting.
The end of the input is not the end of the document. See Append.
It is valid only for the duration of the handler that received it; see the package documentation on handler lifetime.
func (*DocumentEnd) Append ¶
func (d *DocumentEnd) Append(content string, ct ContentType) error
Append adds content at the end of the output, which is not the same as the end of the document.
The rewriter has no tree and does not close anything the input left open, so this handler runs wherever the input stopped. If the input was cut off in the middle of a construct, appended markup lands inside that construct and is not markup at all:
<script>var a = 1 + <img x> -> <script>var a = 1<img x> <!-- unterminated + <img x> -> <!-- unterminated<img x> <p title="unterminated + <img x> -> <p title="unterminated<img x>
In the first the img is JavaScript source; in the second it is comment data; in the third the attribute value runs on and the img's attributes become the p's, so a search for x finds it on the wrong element. Measured over twelve documents that end mid-construct, seven produce no element from the append. Nothing reports this: Write and Close both succeed, and WithStrict does not change it.
This is not a corner case for anyone rewriting a live response. A truncated body is what an origin that died mid-stream produces, which is exactly when injected instrumentation matters, and it fails silently.
Where an element's end tag will do, use it instead - EndTag.Before on the element you want to be inside puts the content in the tree rather than at the end of a byte stream. Note that an end tag the input omits never arrives: </body> is optional in HTML, and Element.OnEndTag on a body without one does not fire, so a fallback to Append is the usual shape and needs the check above. examples/gip/beacon does this both ways round, and verifies the result by stripping its own insertion back out and comparing byte for byte.
This takes a string, and there is no streaming form of it: Element and EndTag each have six Stream methods and DocumentEnd has none. A large trailing append therefore exists in memory in full before it is appended. For a 12 MB report of a million rows that cost 65.5 MB of allocation.
Where the append is large, write it to your own sink after Writer.Close instead. The rewriter's output has already gone there and Close has flushed it, so what a caller writes next lands exactly where this would have put it - the same position, with the same hazard about an input cut off mid-construct. The same report streamed that way allocated 16.0 MB and the output was byte-identical. Two differences, both small: the caller does its own escaping, for which EscapeText is documented as exactly what Text applies, and the caller has to check Close first. An error there does not discard the output - what was already emitted stays in the sink, which is the documented early-stop prefix - so a report written anyway would be attached to a truncated document. examples/gip/tailreport is that shape.
Example ¶
A document-end insertion goes at the end of the output, which is wherever the input stopped - so a truncated document swallows it.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
for _, doc := range []string{`<p>whole</p>`, `<script>truncated`} {
out, err := lolhtml.RewriteString(doc,
lolhtml.OnDocumentEnd(func(d *lolhtml.DocumentEnd) error {
return d.Append(`<img data-x="1">`, lolhtml.HTML)
}))
if err != nil {
fmt.Println(err)
return
}
found := 0
lolhtml.RewriteString(out, lolhtml.OnElement("img[data-x]", func(*lolhtml.Element) error {
found++
return nil
}))
fmt.Printf("%-22q -> img elements: %d\n", doc, found)
}
}
Output: "<p>whole</p>" -> img elements: 1 "<script>truncated" -> img elements: 0
type Element ¶
type Element struct {
// contains filtered or unexported fields
}
An Element is a start tag matched by one of your selectors.
It is valid only for the duration of the handler that received it; see the package documentation on handler lifetime.
func (*Element) After ¶
func (e *Element) After(content string, ct ContentType) error
After inserts content immediately after the element's end tag.
Called twice, the second insertion lands before the first: see the package documentation on two insertions of the same kind.
Where the element's end is depends on the source having an end tag for it. An element whose end tag HTML lets a document omit - a list item, a table cell, a paragraph - ends here at the enclosing element's end tag instead, so this writes somewhere else: see the package documentation on end tags being tokens.
func (*Element) Append ¶
func (e *Element) Append(content string, ct ContentType) error
Append inserts content as the element's last child.
Where the element's end is depends on the source having an end tag for it. An element whose end tag HTML lets a document omit - a list item, a table cell, a paragraph - ends here at the enclosing element's end tag instead, so this writes somewhere else: see the package documentation on end tags being tokens.
It also loses insertions there, where EndTag.Before does not. Applied to every item of <ul><li>a<li>b<li>c</ul>, this keeps one of the three and EndTag.Before keeps all three; neither position is the item's own end, which the source does not have. Where either would do, prefer the one that does not drop content silently.
Calling this after Remove still emits the content, without the element's tags around it; see the package documentation on removal.
func (*Element) Attribute ¶
Attribute returns the value of the named attribute. Names are matched case-insensitively. The second result is false if the attribute is absent, which distinguishes it from an attribute present with an empty value.
It does not distinguish either of those from an element that is no longer valid: a detached element reports ("", false) for everything. [HasAttribute] does, because its signature has room for an error, and [Detached] answers directly. See ErrDetached.
The value is live rather than a snapshot: a read after [SetAttribute] or [RemoveAttribute] sees the change, unlike TextChunk.Text, which is always the source. That holds across handlers as well as within one - a second handler matching the same element reads what the first one wrote, which is how an attribute gets rewritten twice; see the package documentation on selectors being settled before handlers run.
An element can carry the same attribute twice - the HTML parsing specification calls that a parse error and requires a parser to drop all but the first, and lol-html keeps them all. Where that shows up is set out under "An attribute can appear twice" in the package documentation; the short version is that this method returns the first, which is the one a browser would have.
The value is raw source text, with character references left encoded: the href of <a href="?a=1&b=2"> is "?a=1&b=2", not "?a=1&b=2".
html.UnescapeString from the standard library decodes it, with one difference worth knowing: in an attribute value a named reference without its semicolon is not a reference at all when the character after it is "=" or ASCII alphanumeric, and the standard library decodes it anyway. So "?a=1©=2" is a URL with a parameter called copy to a browser and a URL with a copyright sign to html.UnescapeString. In text the two agree. Measured in differential/attrrefs_test.go; a rewrite that has to be exact about attribute values needs that rule, which examples/gip/references implements.
SetAttribute is the mirror image and takes raw source text too, escaping only the double quote, so a value read here and written straight back is unchanged. It does mean writing the five characters "&" produces the single character "&" for whoever parses the result.
One quirk to know: lol-html decodes on the way out and its decoder removes a leading byte-order mark, so a value starting with U+FEFF reads back without it. The value is still serialised faithfully, and a U+FEFF anywhere but the first position survives.
Example (RawSource) ¶
A character reference in an attribute is not decoded on the way in, and SetAttribute takes the same raw source on the way out.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
out, err := lolhtml.RewriteString(`<a href="?a=1&b=2">l</a>`,
lolhtml.OnElement("a", func(e *lolhtml.Element) error {
href, _ := e.Attribute("href")
fmt.Println("as reported:", href)
return e.SetAttribute("data-copy", href)
}))
fmt.Println(out, err)
}
Output: as reported: ?a=1&b=2 <a href="?a=1&b=2" data-copy="?a=1&b=2">l</a> <nil>
Example (Repeated) ¶
An element can carry the same attribute twice. Reading, writing and matching act on the first copy; iteration and removal act on all of them.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
const doc = `<p a="x" a="v">t</p>`
out, err := lolhtml.RewriteString(doc,
lolhtml.OnElement("p", func(e *lolhtml.Element) error {
first, _ := e.Attribute("a")
var all []string
for name, value := range e.Attributes() {
all = append(all, name+"="+value)
}
fmt.Println("Attribute: ", first)
fmt.Println("Attributes:", all)
return e.SetAttribute("a", "z")
}))
fmt.Println("after SetAttribute:", out, err)
matched := 0
lolhtml.RewriteString(doc, lolhtml.OnElement(`[a="v"]`, func(*lolhtml.Element) error {
matched++
return nil
}))
fmt.Println(`[a="v"] matched:`, matched)
}
Output: Attribute: x Attributes: [a=x a=v] after SetAttribute: <p a="z" a="v">t</p> <nil> [a="v"] matched: 0
func (*Element) AttributeList ¶
AttributeList returns every attribute of the element, in source order.
Every attribute means every one, including repeats of the same name that a browser's parser would have dropped. That is the opposite of what Attribute and the selectors do, and it is the choice that matters for anything reporting on a document rather than rewriting it: see "An attribute can appear twice" in the package documentation.
This collects eagerly rather than iterating lazily because the underlying lol-html iterator invalidates each attribute when the next is fetched, and hands out pointers valid only while the element is.
func (*Element) Attributes ¶
Attributes iterates the element's attributes in source order, yielding lowercased names. Use AttributeList when the original spelling matters.
Mutating the element while iterating is safe, and the iteration is over the attributes as they were: setting or removing one inside the loop takes effect on the element and does not disturb the walk, and an attribute added inside the loop is not visited. Measured - adding one per iteration terminates at the original count rather than growing without end.
Like AttributeList, this yields repeats of the same name rather than the first only - see "An attribute can appear twice" in the package documentation.
The iterator must be consumed inside the handler; once the element is detached it yields nothing.
func (*Element) Before ¶
func (e *Element) Before(content string, ct ContentType) error
Before inserts content immediately before the element's start tag.
Used with a closing tag at the element's end to wrap it, what comes out is a container around the element only if the two tags nest where they were put: a block-level wrapper inside a paragraph takes the element out of the paragraph, and an inline one cannot hold an element that closes a paragraph by starting. See the package documentation on wrappers.
Example (Order) ¶
Three calls to each insertion method, inserting "1", "2" then "3". The newest insertion is the one closest to the unit, which puts it last in reading order for Before and Append and first for After and Prepend.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
for _, name := range []string{"Before", "After", "Prepend", "Append"} {
out, err := lolhtml.RewriteString(`<p>t</p>`,
lolhtml.OnElement("p", func(e *lolhtml.Element) error {
insert := map[string]func(string, lolhtml.ContentType) error{
"Before": e.Before, "After": e.After,
"Prepend": e.Prepend, "Append": e.Append,
}[name]
for _, s := range []string{"1", "2", "3"} {
if err := insert(s, lolhtml.Text); err != nil {
return err
}
}
return nil
}))
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("%-8s %s\n", name, out)
}
}
Output: Before 123<p>t</p> After <p>t</p>321 Prepend <p>321t</p> Append <p>t123</p>
func (*Element) CanHaveContent ¶
CanHaveContent reports whether the element may contain content. It is false for void elements such as <br> and for self-closing foreign elements.
The four methods it governs do not fail alike when it is false. Append, Prepend and SetInnerContent - and their streaming forms - silently do nothing. OnEndTag returns an error, because there is no end tag to wait for, and that error fails the rewrite. So a handler on a selector that can match a void element must check this before calling OnEndTag; the others can be called blind.
Before, After and Replace are unaffected: they position content outside the element and work on a void one.
It reports true for <plaintext>, and that is correct - plaintext has content - but it is not the guarantee it looks like. A plaintext element ends only at the end of the input, so there is no end tag: OnEndTag returns nil and its handler never runs, and Append has no position and is dropped without error. Prepend and SetInnerContent work. Pinned in rawtext_test.go.
func (*Element) ClearEndTagHandlers ¶
func (e *Element) ClearEndTagHandlers()
ClearEndTagHandlers removes every end-tag handler registered for this element, including any added by handlers that ran before this one.
Removing a handler is not an edit to the document: the start tag is not re-serialised by this or by anything else that only reads. See Element.SetAttribute on what re-serialisation changes.
It stops the callbacks running. It does not release what registering them cost: the handle stays live until the Writer closes, so clearing is not a way to bound the memory that Element.OnEndTag describes. Registering on fewer elements is. Measured in userdatacost_test.go.
func (*Element) Detached ¶
func (u *Element) Detached() bool
Detached reports whether this value has outlived its handler. Every other method returns ErrDetached, or a zero value, once this is true.
func (*Element) HasAttribute ¶
HasAttribute reports whether the named attribute is present.
func (*Element) IsRemoved ¶
IsRemoved reports whether the element has been removed by a handler, whether by Remove or by RemoveAndKeepContent. It does not distinguish them, so it cannot be used to decide whether inserting inside the element is safe - only whether the element itself will be emitted.
It also answers for an ancestor. An element inside one that a handler has already removed with Element.Remove or Element.Replace reports true, because it is on its way out with everything else in there - so a handler accumulating over a document does not have to keep a depth counter to know that what it is looking at will not be in the output. Element.RemoveAndKeepContent is the exception, and the right one: the content is being kept, so a descendant reports false.
The other units do not work this way. TextChunk.IsRemoved and Comment.IsRemoved report only whether that chunk or comment has itself been removed, so a text handler inside a removed element sees false and has to learn it some other way - an element handler tracking removed ancestors, which is what the package documentation on removal describes. Measured in removedsubtree_test.go.
func (*Element) IsSelfClosing ¶
IsSelfClosing reports whether the tag was *written* self-closing, as in <foo />. It is about the source text and nothing else.
In HTML a trailing slash is ignored, and this method still returns true for it: <div/> reports true, and that div goes on to have content and an end tag like any other. So it is not a test for whether an element is empty, and using it as one is wrong wherever an author wrote a slash out of habit:
source IsSelfClosing CanHaveContent <div/> true true <div></div> false true <br/> true false <br> false false <svg><rect/> true false <svg><rect> false true
Element.CanHaveContent is the method for "may this hold content", and it is right in every row above. Measured: <div/>text</div> reaches an OnEndTag handler and takes an Append, while <svg><rect/> does neither.
What this is for is foreign content, where the slash is the only thing that closes an element - <svg><rect/> against <svg><rect> are two different trees - and for a rewrite that wants to reproduce the source spelling.
func (*Element) NamespaceURI ¶
NamespaceURI returns the namespace that this element's children are parsed in, which is not always the element's own namespace.
For almost everything the two are the same: "http://www.w3.org/1999/xhtml" in HTML content, "http://www.w3.org/2000/svg" inside <svg>, "http://www.w3.org/1998/Math/MathML" inside <math>. The exceptions are the integration points, where foreign content switches back to HTML parsing. Those elements report the HTML namespace even though they are SVG or MathML elements:
<svg>: foreignObject, desc, title
<math>: mi, mo, mn, ms, mtext
<math>: annotation-xml, but only when its encoding attribute is
"text/html" or "application/xhtml+xml"
Measured, not derived from the spec: <svg><title> reports the HTML namespace, and so does <math><mi>, while <svg><a>, <svg><script>, <svg><style> and <math><mrow> report their own.
The consequence is worth stating plainly, because it removes the obvious use for this method. Selectors do not consider namespaces either - "title" matches both a document title and an SVG tooltip - so a handler that needs to tell them apart cannot do it with a selector alone and cannot do it with this method either, since both report HTML. What works is a selector that names the context ("svg title" matches only the tooltip), or the rule below.
The element's own namespace ¶
Read it one level up. An element is parsed in the namespace its parent's children are parsed in, which is exactly what this method reports for the parent, so a handler that keeps a stack of these values has the answer for every element at the top of it:
ns := []string{lolhtml.NamespaceHTML}
lolhtml.OnElement("*", func(e *lolhtml.Element) error {
own := ns[len(ns)-1]
if child := e.NamespaceURI(); child != own && e.CanHaveContent() {
ns = append(ns, child)
tag := e.TagName()
e.OnEndTag(func(t *lolhtml.EndTag) error {
if t.Name() == tag {
ns = ns[:len(ns)-1]
}
return nil
})
}
return nil
})
with one exception: <svg> and <math> are the tags that enter foreign content, so they are themselves foreign and their own namespace is the one they report.
Counting <svg> and <math> depth instead is the obvious thing and it is wrong at the integration points, in the direction that matters: it puts the <p> in <svg><foreignObject><p> in the SVG namespace, where it is an ordinary HTML paragraph. The stack gets that right because the switch back is exactly what foreignObject reports. Worked through in examples/gip/histogram.
It also follows the parser out of foreign content, where a selector does not. An HTML tag name inside an <svg> ends the svg - 44 names do it - and what comes after reports the HTML namespace here while "svg > circle" still matches it. So the two answers disagree, and a rewrite asking "is this element still inside that <svg>" cannot use either on its own; see the package documentation on foreign content.
The returned string is one of NamespaceHTML, NamespaceSVG and NamespaceMathML, and is those constants rather than a copy of them: a fresh 28- or 32-byte string per element was measured at 1000 allocations and 32 KB per 1000 elements, for three distinct values.
func (*Element) OnEndTag ¶
OnEndTag registers fn to run when this element's content ends, which is the way to act on an element after seeing what is inside it.
"When its content ends" is not the same as "at its own end tag", and the difference is silent. HTML lets many elements leave their end tag out - a list item closed by the next item, a table cell closed by the next row, a paragraph closed by anything that cannot be inside one - and for those there is no end tag in the source. The handler then runs against the tag that did close them, which belongs to an enclosing element:
<ul><li>a<li>b</ul>
Both items' handlers run at </ul>, innermost first, and EndTag.Name reports "ul" for both. Content inserted with EndTag.Before lands at the end of the list rather than at the end of an item, and nothing reports a problem:
<ul><li>a<li>b[end][end]</ul>
If nothing closes the element at all - it runs to the end of the document, as in <p>a<p>b - the handler does not run.
The test is the name. An end tag closes the nearest open element of that name, which is the element itself, so a name that differs is not this element's end tag and no position taken from it belongs to this element:
tag := e.TagName()
e.OnEndTag(func(t *lolhtml.EndTag) error {
if t.Name() != tag {
return nil // closed implicitly; this position is somewhere else
}
return t.Before("<span class=\"marker\"></span>", lolhtml.HTML)
})
Measured across every shape in endtagposition_test.go, against what the source spells at that position.
A handler that only wants to know that the element is over, rather than to write at its position, needs a finer distinction than that guard makes. There are three timings, not two:
<p><em>a</em> b</p> at </em>, its own tag, exactly where it ends
<p><em>a</p>b at </p>, an ancestor's tag, exactly where it ends
<ul><li><em>a<li>b</ul> at </ul>, an ancestor's tag, and "b" was already
reported: the <em> ended at the second <li>
So a foreign end tag is where the element ended when an ancestor's end tag is what closed it, and later than where it ended when a sibling's start tag was. Nothing in the callback separates those, and the difference matters to anything accumulating - a converter closing an emphasis at the third row's callback wraps the next item's text as well.
The only way to be exact is to keep the stack of open elements and apply the specification's implied end tags, which is what examples/gip/markdown and examples/gip/depth do. Pinned in endtagposition_test.go.
It can be called more than once to register several handlers, which run in registration order. It fails if the element cannot have content - a void element such as <br> has no end tag to wait for - so check CanHaveContent first when the tag is not known statically. It also returns nil and never runs for <plaintext>, which has no end tag at all; see CanHaveContent.
Each registration costs memory until the rewrite ends, not until the end tag arrives. Measured on 100,000 sibling <div>s with a handler on "*" that registers one end-tag handler each, Go 1.25.8: the live handle count rises to 100,001 and never falls until the Writer is closed, and the Go side allocates 27.0 MB against 4.2 MB for the same rewrite without the registration - about 240 bytes per element, and the same for a wide document as for a deep one. The toolchain is named because that is the axis these figures move on; earlier readings on an older Go were about 30 MB, 6 MB and 300 bytes.
MemorySettings.MaxMemory does not bound it: the same document completes under a 64 KiB limit while allocating those tens of megabytes, because that limit is lol-html's parsing buffer and this is the binding's handle table. So a rewrite that must hold a memory budget has to bound its input, and register this only where an element actually needs it - a narrow selector, or a condition checked before registering rather than inside the callback. Measured in endtagcost_test.go.
func (*Element) Prepend ¶
func (e *Element) Prepend(content string, ct ContentType) error
Prepend inserts content as the element's first child.
Called twice, the second insertion lands before the first: see the package documentation on two insertions of the same kind.
Calling this after Remove still emits the content, without the element's tags around it; see the package documentation on removal.
Prepending an element into a <template> whose content is table markup deletes that content: the rows are parsed in a mode the first inserted element ends. A comment or text is safe there, and so is Append; see the package documentation on templates.
func (*Element) Remove ¶
func (e *Element) Remove()
Remove removes the element and everything inside it.
"Everything inside it" is everything up to the next end tag, which is not the same thing when the source left this element's end tag out. Removing the first item of <ul><li>a<li>b<li>c</ul> removes all three, with no error: see the package documentation on end tags being tokens.
Nor is it the same thing as everything a parser would say is inside it. A table is the case: content a parser moves out of one - text between <table> and the first <tr>, for instance - is inside it here, so removing the table removes content a tree-based edit would keep. See the package documentation on a table containing things that are not in it.
Handlers still run for the content being removed, and their edits are discarded with it. Content inserted inside the element after this call is not discarded, which is a corner worth reading about: see the package documentation on removal.
Example ¶
Removing an element suppresses the output of handlers on its content, but the handlers still run.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
calls := 0
out, err := lolhtml.RewriteString(`<div><p>inside</p></div><p>outside</p>`,
lolhtml.OnElement("div", func(e *lolhtml.Element) error {
e.Remove()
return nil
}),
lolhtml.OnElement("p", func(e *lolhtml.Element) error {
calls++
return e.SetAttribute("data-seen", "1")
}),
)
fmt.Println(out, err)
fmt.Println("p handler calls:", calls)
}
Output: <p data-seen="1">outside</p> <nil> p handler calls: 2
func (*Element) RemoveAndKeepContent ¶
func (e *Element) RemoveAndKeepContent()
RemoveAndKeepContent removes the element's tags but keeps its children, so <b>hi</b> becomes hi.
"Its children" is what was inside the tags, and unwrapping one of the ten elements whose content is not markup turns that content into markup:
<script>var x = "<img src=x onerror=alert(1)>"</script> e.RemoveAndKeepContent() var x = "<img src=x onerror=alert(1)>"
and the image is now an element. Measured for script, style, textarea, title, xmp, iframe, noembed, noframes, noscript and plaintext.
That matters most for the shape this method invites: a sanitiser with an allowlist that unwraps everything not on it. Very few allowlists include noembed or xmp, so a payload placed inside one is inert until it is unwrapped - which is the sanitiser doing the work. Where the content of an unknown element might not be markup, remove the element instead, or ask IsRawText before unwrapping - the tag name is enough, and the answer is the same list the library checks insertions against.
This is the same hazard as ErrRawTextBreakout and Element.SetTagName, reached a third way: nothing is inserted and nothing is renamed, and the content is reinterpreted all the same. Pinned in settagname_test.go.
The other thing this removes is the token that closed the element, and that is not always the element's own end tag. Where the source left the end tag out, the token that closed it belongs to an enclosing element, and it goes too:
<h1>a <em>b</h1><p>after</p> em.RemoveAndKeepContent() <h1>a b<p>after</p>
The heading never closes, so the paragraph is now inside it. All the content is still there and nothing reports anything: the change is to the shape of the document rather than to what it says. The element that loses its tag need not be one the call named or an ancestor the caller was thinking about:
<h1><span>a <em>b</span> c</h1> em.RemoveAndKeepContent() <h1><span>a b c</h1>
Element.Remove and Element.Replace have the same cause and the opposite symptom - they take the content up to that token with them, which is what those methods describe. Here the content survives and the nesting does not.
The name guard on Element.OnEndTag detects it. Register the handler before removing, and a callback whose EndTag.Name is not this element's name is standing on the token that has just been deleted, so writing it back there repairs the document:
name := e.TagName()
e.OnEndTag(func(t *lolhtml.EndTag) error {
if t.Name() == name {
return nil // its own end tag; nothing borrowed
}
return t.Before("</"+t.Name()+">", lolhtml.HTML)
})
e.RemoveAndKeepContent()
Measured in removeimplied_test.go, along with what happens without it.
Appending after this is well defined, and puts the content after the children that were kept.
func (*Element) RemoveAttribute ¶
RemoveAttribute removes the named attribute. Removing an absent attribute is not an error.
Every copy goes, not just the first, which is deliberate: a filter that left a second copy behind would be a filter that does not filter, since what a browser drops on parse is not necessarily what the next parser in the chain drops. See "An attribute can appear twice" in the package documentation.
func (*Element) Replace ¶
func (e *Element) Replace(content string, ct ContentType) error
Replace replaces the element, including its tags, with content.
Where the element's end is depends on the source having an end tag for it. An element whose end tag HTML lets a document omit - a list item, a table cell, a paragraph - ends here at the enclosing element's end tag instead, so this writes somewhere else: see the package documentation on end tags being tokens.
func (*Element) SetAttribute ¶
SetAttribute sets the named attribute, adding it if absent.
The name's case is not kept when the attribute is being added: it is lower-cased, and there is no way to write a name with a capital in it that was not already in the document. Updating one that is there keeps the document's spelling, so the two directions differ:
<svg viewBox="0 0 1 1"> SetAttribute("viewBox", "0 0 9 9") -> viewBox="0 0 9 9"
<svg> SetAttribute("viewBox", "0 0 9 9") -> viewbox="0 0 9 9"
For an HTML element read by an HTML parser that is nothing: attribute names are matched case-insensitively, and [Attributes] lower-cases them for the same reason. It is a silent breakage wherever the next reader is case-sensitive, and that is not only SVG.
In SVG and MathML the names are case-sensitive to a browser - viewbox is not viewBox, and it is ignored. The spec's list of the ones that need a capital runs to about sixty names, viewBox, preserveAspectRatio, gradientTransform, patternUnits, refX, textLength, stdDeviation and zoomAndPan among them.
A framework template is the other case, and it is HTML-shaped text rather than a document: the file is parsed as HTML by anything using this library, and its attribute names are read by a compiler that treats them as identifiers.
source Attribute.Name Attribute.NamePreserveCase *ngIf="ok" *ngif *ngIf [ngClass]="c" [ngclass] [ngClass] [(ngModel)]="v" [(ngmodel)] [(ngModel)] v-bind:someProp v-bind:someprop v-bind:someProp @myEvent @myevent @myEvent
*ngIf is a directive and *ngif is not. So a rewrite that reads Name and writes it back turns the directive off, a report built from Name names something the author cannot search for, and an attribute added as "*ngIf" arrives as "*ngif". examples/gip/bindings reads NamePreserveCase for everything it prints and only ever writes names that are lower-case already.
The question is not whether the document is HTML. It is whether whoever reads it next cares about case.
So a rewrite that reads an SVG attribute, computes a new value and writes it back works, and the same code adding the attribute to an element that did not have it produces one a browser will not read. Where the attribute has to be added, the tag has to be written: Element.Replace with markup you build, or the value carried into the document some other way. Attribute.NamePreserveCase is the read side of the same problem, and is how a rebuild keeps the spelling.
The name is checked, and the characters it refuses are the ones that could end the attribute or start another: a space, a tab, a newline, "/", "=", ">", and the empty name. Those return an error rather than producing markup, so a name taken from a document cannot break the tag it is written into. What it accepts includes the merely odd - a quote, an apostrophe, a "<", a leading digit - each of which reads back as part of the name. Element.SetTagName is stricter: it requires the first character to be an ASCII letter, and it does not lower-case what it is given. Measured in attrnamecase_test.go.
The value is raw attribute-value source, the mirror of what Attribute reports, so it needs no escaping from the caller: a value read from one element and written to another is unchanged, and a value containing a quote cannot end the attribute or start another. The only character rewritten on the way out is the double quote, which becomes ".
Raw source is not the same as text, and the difference is worth one sentence because it is silent when it bites. Passing the five characters "&" sets an attribute that a browser reads as the single character "&", because that is what those five characters mean in an attribute. If what you have is a literal value rather than source - a string that should arrive at the other end byte for byte - encode it first with EscapeAttribute.
Escaping is not sanitising, either. SetAttribute will set href to "javascript:alert(1)" without complaint, because that is a valid attribute value; which schemes to allow is the caller's decision.
A boolean attribute comes out with a value. There is no way to write a bare one: SetAttribute("defer", "") emits defer="" and the C API takes no other shape. That is the same attribute as far as any parser is concerned - presence is what a boolean attribute means - but it does not match the spelling a page used, so a rewrite that adds one to a document full of bare attributes produces a diff in two styles. A bare attribute already in the input is passed through unchanged and reads back as an empty value.
It writes the first copy and leaves the others, which is the opposite choice from Element.RemoveAttribute and the more dangerous one:
<a href="first" href="second">
e.SetAttribute("href", "safe")
<a href="safe" href="second">
A browser reads the first, so the rewrite took effect there. The original is still in the bytes, and RemoveAttribute's reasoning applies here too: what a browser drops on parse is not necessarily what the next parser in the chain drops. A rewrite that sanitises by changing a value rather than removing it leaves the value it was sanitising.
Remove first where that matters:
e.RemoveAttribute("href") // every copy
e.SetAttribute("href", "safe") // one copy, at the end
which costs the attribute its position. That is why this is not done for you: finding out whether a name is duplicated means listing every attribute, on every call to the most-used method in the package, to change the answer for the documents that have a duplicate and move the attribute in all the rest. See "An attribute can appear twice" in the package documentation.
The start tag comes back re-serialised ¶
Setting an attribute re-serialises the whole start tag, so the output is not the input plus the attribute. Each attribute's own source text survives - its quoting style, the case of its name, spaces around its equals sign, an entity in its value, a duplicate, a bare boolean - and the separators between attributes do not:
<a href="/x" class="c"> -> <a href="/x" class="c" data-x="1"> <a href="/x"class="c"> -> <a href="/x" class="c" data-x="1"> <a href="/x" > -> <a href="/x" data-x="1">
Newlines, tabs and runs of spaces between attributes become single spaces, a trailing space before the bracket is dropped, and a missing one is added. On a page whose templates put each attribute on its own line the effect is substantial: setting one attribute on every anchor of cloudflare.com.html takes the document from 119,237 bytes to 114,542, having been asked to make it bigger.
So a byte comparison, a checksum or an ETag over the output sees changes the caller did not ask for, and a rewrite is not a diff of itself. It also happens when the value set is the value already there, so a rewrite that wants to leave unchanged elements untouched has to compare first and only set when it differs.
Element.RemoveAttribute does the same when the attribute is present, and so does Element.SetTagName. Reading, inserting, an end-tag handler and user data do not: the tag's bytes come through exactly as they arrived. Measured in reserialise_test.go.
func (*Element) SetInnerContent ¶
func (e *Element) SetInnerContent(content string, ct ContentType) error
SetInnerContent replaces everything inside the element, leaving its tags in place.
"Everything inside it" reaches to the next end tag. For an element whose own end tag the source left out, that is the enclosing element's, so this replaces the enclosing element's remaining content too: see the package documentation on end tags being tokens.
Calling this after Remove still emits the content, without the element's tags around it; see the package documentation on removal.
Example (RawText) ¶
Inserting into the content of a script that would close it is refused.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
_, err := lolhtml.RewriteString(`<script></script>`,
lolhtml.OnElement("script", func(e *lolhtml.Element) error {
return e.SetInnerContent(`var s = "</script>";`, lolhtml.HTML)
}))
fmt.Println(err != nil)
// The same content with the slash escaped for JavaScript is accepted.
out, err := lolhtml.RewriteString(`<script></script>`,
lolhtml.OnElement("script", func(e *lolhtml.Element) error {
return e.SetInnerContent(`var s = "<\/script>";`, lolhtml.HTML)
}))
fmt.Println(out, err)
}
Output: true <script>var s = "<\/script>";</script> <nil>
func (*Element) SetTagName ¶
SetTagName renames the element. The matching end tag, if any, is renamed too.
The name is written as given: "sPan" produces <sPan>, unlike Element.SetAttribute, which lower-cases a name it is adding. The first character has to be an ASCII letter - a digit or a non-ASCII letter is an error - and the characters that could end a tag are refused, as they are there.
Renaming can change what the element's content means, because whether content is markup is decided by the element it is in. Renaming one of the raw-text elements to an ordinary one turns its text into markup:
<script>var x = "<img src=x onerror=alert(1)>"</script>
SetTagName("div")
<div>var x = "<img src=x onerror=alert(1)>"</div>
and the image is now an element. Measured for script, style, textarea and title. That is the same hazard ErrRawTextBreakout refuses for an insertion, and a rename is the way round it - the content is not being inserted, it is being reinterpreted, and the library cannot see it coming: this call happens at the start tag, before any content has been reported.
The other direction is quieter and still a change of meaning: renaming an ordinary element to a raw-text one turns its markup into text, so <div><img src=x></div> becomes <script><img src=x></script>, where the image is nine words of JavaScript.
So a rename across that boundary is only safe when you know what the content is. IsRawText answers which side of the boundary a name is on, for the old name and the new one. Where you do not know the content, replace the element instead - Element.Replace with content you built - or read the content and decide at the end tag. Pinned in settagname_test.go.
The second thing a rename writes is the closing tag, and where the source left the element's end tag out, what it writes over belongs to an enclosing element:
<h1>a <em>b</h1> SetTagName("i") -> <h1>a <i>b</i>
The </h1> is gone and an </i> stands in its place, so the heading never closes. Where a sibling's start tag closed the element, the new tag lands at the end of the enclosing element rather than where the element ended:
<ul><li><em>a<li>b</ul> SetTagName("i") -> <ul><li><i>a<li>b</i>
and "b", which was not emphasised, now is. Both are the general rule that an end tag is a token and not a fact about the element; see the package documentation, and Element.OnEndTag for the name guard that detects it. Measured in removeimplied_test.go.
The content inside the element is not looked at again - it was tokenised under the old name and this writes over the tag alone - but whoever parses the output applies the new name's content model to it, and that model can move the content out of the element or throw it away:
<div><p>x</p></div> renamed to table the p is fostered out <div><p>x</p><span>y</span></div> renamed to select both are gone, text merged
A name that cannot hold content at all is worse, because the answer depends on which one. Renaming <div class="w">x</div> and asking a parser what it built:
renamed to the tree br two br elements, with x between them img hr input one element, and x is now its sibling wbr area col no element at all, only x meta the element in <head>, and x left in <body>
</br> is the one end tag HTML treats as a start tag, so the stray one becomes a second element and the rename duplicated the widget. A col outside a table is dropped, so the rename deleted it. A meta belongs in the head, so the rename moved it and left its content behind.
No error in any of this, and the output is exactly the markup that was asked for. So a rename is safe when the new element accepts what the old one held, which is a question about the two content models rather than about this method: examples/gip/modernise renames only within that set, and examples/gip/widgets refuses a target that is not a container. Measured in differential/rename_test.go.
func (*Element) SetUserData ¶
SetUserData attaches a value to this element, readable by another handler that is given the same element.
"The same element" means one reported to two handlers, which happens when two selectors match it:
OnElement(".card", func(e *lolhtml.Element) error { return e.SetUserData(n) })
OnElement("[data-id]", func(e *lolhtml.Element) error { n := e.UserData() ... })
Not an end-tag handler, which is what this documentation used to say and is not possible: EndTag has no user data - lol-html provides it for elements, comments, text chunks and the doctype, and not for end tags - and the element itself is detached by the time its end-tag handler runs, so reading through the captured Element returns nil. Measured. Close over a Go variable instead, which is what an end-tag handler is written inside a start-tag handler for.
It is per unit and not per position: two elements never share it, and a value set on one text chunk is not readable from the next chunk of the same text node.
What it costs ¶
A handle per unit, held until the rewrite ends - the same cost class as Element.OnEndTag, and for the same reason: the value has to stay reachable from Go while C holds a reference to it. Measured, attaching user data to every anchor in a 64 MB document held about 520 MB of Go heap, against 3.7 MB for the same rewrite reading the same elements. MemorySettings.MaxMemory does not bound it, because that limit is lol-html's parsing buffer and this is the binding's handle table.
Setting it to nil releases the handle immediately, so a handler that has handed its value to the handler that needed it can clear it and the rewrite stays bounded:
OnElement(".card", func(e *Element) error { return e.SetUserData(id) }),
OnElement("[data-id]", func(e *Element) error {
id := e.UserData()
defer e.SetUserData(nil) // releases the handle now, not at Close
…
}),
Replacing a value releases the one it replaced, so a handler that sets it twice holds one handle rather than two. Gated in userdatacost_test.go, and examples/gip/unbounded measures which handler patterns keep a rewrite's memory flat as the document grows.
Go handlers can usually just close over the value instead, which is why this exists mainly for parity with the C API - and closing over a variable costs nothing that lives past the handler.
func (*Element) SourceLocation ¶
func (e *Element) SourceLocation() SourceLocation
SourceLocation returns the byte range the start tag occupied in the input - the start tag alone, not the element.
For the element's whole extent, hold this Start and take the End from the end tag's own location - but only once the end tag is known to be this element's, because an omitted end tag hands the handler an enclosing element's tag and this arithmetic then measures to the end of that one instead:
start, tag := e.SourceLocation().Start, e.TagName()
e.OnEndTag(func(t *lolhtml.EndTag) error {
if t.Name() != tag {
return nil // not this element's end tag; see OnEndTag
}
extent := t.SourceLocation().End - start
return nil
})
Without the guard, both items in <ul><li>a<li>b</ul> measure as reaching the end of the list. An element whose end tag never arrives has no measurable extent at all, because the handler never runs.
func (*Element) StreamAfter ¶
func (e *Element) StreamAfter(fn StreamFunc) error
StreamAfter inserts content after the element's end tag, produced on demand by fn.
func (*Element) StreamAppend ¶
func (e *Element) StreamAppend(fn StreamFunc) error
StreamAppend inserts content as the element's last child, produced on demand by fn.
Inside a raw-text element this is unguarded: see StreamFunc.
func (*Element) StreamBefore ¶
func (e *Element) StreamBefore(fn StreamFunc) error
StreamBefore inserts content before the element's start tag, produced on demand by fn.
func (*Element) StreamPrepend ¶
func (e *Element) StreamPrepend(fn StreamFunc) error
StreamPrepend inserts content as the element's first child, produced on demand by fn.
Inside a raw-text element this is unguarded: see StreamFunc.
func (*Element) StreamReplace ¶
func (e *Element) StreamReplace(fn StreamFunc) error
StreamReplace replaces the element, tags included, with output produced on demand by fn.
func (*Element) StreamSetInnerContent ¶
func (e *Element) StreamSetInnerContent(fn StreamFunc) error
StreamSetInnerContent replaces the element's content with output produced on demand by fn.
func (*Element) TagName ¶
TagName returns the tag name, lowercased. Use TagNamePreserveCase for the spelling as it appeared in the source.
ASCII-lowercased, which is what an HTML parser does and is only the same thing for an ASCII name. <DÉTAIL> reports "dÉtail": the D and the TAIL are folded and the É is not. So a name with a non-ASCII letter in it comes back in a spelling nobody wrote, and comparing it against a lower-cased Go literal fails. strings.EqualFold is the fix here, and it is more than a selector can do - a selector is folded the same way, so it matches one of the two spellings and not both. See the package documentation on selectors, and asciicase_test.go.
It is the document's name, not the tree's, and one name differs: <image> in HTML content builds an img element, so this reports "image" where a browser has an img. See the package documentation on <image>.
func (*Element) TagNamePreserveCase ¶
TagNamePreserveCase returns the tag name exactly as spelled in the source document: <DiV> reports "DiV" and <svg><LINEARGRADIENT> reports "LINEARGRADIENT".
As spelled, not as canonical, and for foreign content those differ. A parser applies the SVG tag-name adjustment, so a browser's DOM holds "linearGradient" however the page wrote it. Neither method here does that:
source TagName TagNamePreserveCase <linearGradient/> lineargradient linearGradient <LINEARGRADIENT/> lineargradient LINEARGRADIENT <lineargradient/> lineargradient lineargradient
against "linearGradient" from an independent parser in all three cases. So comparing either result with a canonical SVG name is wrong for two spellings out of three, and which one a page used is not a thing to rely on. Match with a selector, which is case-insensitive and gets all three, or lower-case and map through the adjustment table yourself.
Nothing is wrong with the output: the source spelling is emitted unchanged and a browser adjusts it on the way in, so a passthrough of <LINEARGRADIENT/> is still a linearGradient. Element.SetTagName writes what it is given, so a rewrite can normalise the spelling if it wants to. Pinned in differential/tagname_test.go.
type EncodingError ¶ added in v0.2.0
An EncodingError reports a character encoding label that lol-html would not accept, either because it is not a label in the WHATWG Encoding Standard or because the encoding it names is not ASCII compatible. Label is the value that was passed to WithEncoding.
func (*EncodingError) Error ¶ added in v0.2.0
func (e *EncodingError) Error() string
type EndTag ¶
type EndTag struct {
// contains filtered or unexported fields
}
An EndTag is a closing tag, delivered to a handler registered with Element.OnEndTag. It is the hook for acting on an element once its content has been seen.
It is the tag that closed the element, which is not always the element's own: see Element.OnEndTag on end tags HTML lets a document leave out.
It is valid only for the duration of the handler that received it; see the package documentation on handler lifetime.
func (*EndTag) After ¶
func (t *EndTag) After(content string, ct ContentType) error
After inserts content immediately after the end tag, making it the first content following the element.
Called twice, the second insertion lands before the first: see the package documentation on two insertions of the same kind.
func (*EndTag) Before ¶
func (t *EndTag) Before(content string, ct ContentType) error
Before inserts content immediately before the end tag, making it the last content inside the element.
This is the insertion to reach for where Element.Append would also do, because the two differ when the source omits an end tag. Applied to every item of <ul><li>a<li>b<li>c</ul>, where all three handlers run at the single </ul>, this keeps all three insertions - innermost first - and Append keeps one. Neither lands at the item's own end, which the source does not have, but only one of them loses content, and neither reports anything.
func (*EndTag) Detached ¶
func (u *EndTag) Detached() bool
Detached reports whether this value has outlived its handler. Every other method returns ErrDetached, or a zero value, once this is true.
func (*EndTag) Name ¶
Name returns the tag name, lowercased.
It is not necessarily the name of the element whose handler this is. An element that left its end tag out is closed by an enclosing element's, and the handler is handed that one: in <ul><li>a<li>b</ul>, both items' handlers see a tag named "ul". Comparing this against the element's own tag name is how a handler tells the two apart; see Element.OnEndTag.
func (*EndTag) NamePreserveCase ¶
NamePreserveCase returns the tag name as spelled in the source.
func (*EndTag) Remove ¶
func (t *EndTag) Remove()
Remove removes the end tag, leaving the element's content in place.
The token being removed is not always the element's own end tag. Where the source left the end tag out, the callback runs against the token that closed the element, which belongs to an enclosing element - so this removes that element's closing tag and it never closes:
<h1>a <em>b</h1><p>after</p> // in the em's end tag handler t.Remove() <h1>a <em>b<p>after</p>
EndTag.Name is the test: a name that is not this element's is a token that belongs to something else, and removing it is almost never what the handler meant. See Element.OnEndTag, and removeimplied_test.go for the measurement.
func (*EndTag) SetName ¶
SetName renames the end tag. Renaming only this tag, and not the matching start tag, produces mismatched markup.
func (*EndTag) SourceLocation ¶
func (t *EndTag) SourceLocation() SourceLocation
SourceLocation returns the byte range the end tag occupied in the input.
func (*EndTag) StreamAfter ¶
func (t *EndTag) StreamAfter(fn StreamFunc) error
StreamAfter inserts content just after the end tag, produced on demand by fn.
func (*EndTag) StreamBefore ¶
func (t *EndTag) StreamBefore(fn StreamFunc) error
StreamBefore inserts content just inside the end tag, produced on demand by fn.
func (*EndTag) StreamReplace ¶
func (t *EndTag) StreamReplace(fn StreamFunc) error
StreamReplace replaces the end tag with output produced on demand by fn.
type HandlerError ¶
type HandlerError struct {
// Selector is the selector the handler was registered for, empty for a
// document-level one. An end-tag or streaming handler inherits it from the
// handler that registered it, so a failure in one says which selector it
// belongs to rather than only which kind it was.
Selector string
// Kind is one of "element", "comment", "text", "doctype", "document-end",
// "end-tag" and "streaming". The last is the output of a [StreamFunc], which
// runs later than the handler that registered it and is reported separately
// for that reason.
Kind string
Err error
}
A HandlerError wraps an error returned by one of your own handlers, so that the error surfacing from Write or Close is traceable back to the handler that produced it. Unwrap returns the original error.
func (*HandlerError) Error ¶
func (e *HandlerError) Error() string
func (*HandlerError) Unwrap ¶
func (e *HandlerError) Unwrap() error
type MemorySettings ¶
type MemorySettings struct {
// PreallocatedParsingBuffer is the parsing buffer size reserved up front,
// in bytes. Zero means allocate nothing up front.
//
// It counts against MaxMemory, and it does not lower the peak. Measured on
// four documents, the smallest MaxMemory that completes rises by about
// whatever is preallocated:
//
// prealloc 0 16 1024 4096 8192
// floor 832 848 1856 4928 9024
//
// and no document tried was cheaper with a buffer than without one -
// including ones chosen to reallocate a lot, such as four hundred small
// elements or two hundred levels of nesting. So against a limit this is
// overhead to budget for, not a saving.
//
// Setting it equal to MaxMemory is accepted - validate refuses only a buffer
// larger than the limit - and fails as soon as a selector has to match:
// 1024 and 1024 with one OnElement handler bails out on <p>x</p>. Without
// handlers, or with document-level handlers only, the same pair is fine,
// because nothing needs the buffer. With no preallocation the same limit
// completes every document tried.
//
// It buys fewer reallocations in the Rust allocator, which nothing here can
// see: the Go allocation count is identical at 0, 1024 and 8192. Leave it
// alone unless a profile of the C side says otherwise.
PreallocatedParsingBuffer int
// MaxMemory caps lol-html's memory in bytes. Zero means unlimited.
//
// lol-html's, not the rewrite's: it bounds the parsing buffer on the C side
// and is blind to what the binding allocates for a handler. The difference is
// measurable - 100,000 sibling <div>s with an [Element.OnEndTag] registered on
// each complete under a 64 KiB MaxMemory while the Go side allocates about
// 30 MB, because an end-tag registration costs a live handle until the rewrite
// ends. See [Element.OnEndTag], and bound the input as well as this if a
// budget has to hold.
//
// Exceeding it fails the Write or Close that noticed, with a *NativeError
// that errors.Is matches against [ErrMemoryLimitExceeded].
//
// How much a document needs depends on how it is written, not only on the
// document: one measured 5170-byte page completes at 1024 when fed in a
// single Write and needs 8192 when fed in 256-byte writes. Size the limit
// against the write pattern the caller will actually use, or a value that
// passed a test will bail out under io.Copy.
//
// It is not a formula, either. The floor depends on where the write boundaries
// fall relative to the tokens, so it is not a function of the write size: measured
// on two 14 KB documents of paragraphs with a handler on each, one needed 4930
// bytes at both 4095-byte and 4096-byte writes, and the other needed 4928 at 4095
// and 832 at 4096. A larger write can want a smaller limit. Measure the floor with
// the write pattern that will be used - examples/gip/bailout does that with -floor
// - rather than deriving it.
//
// What the handlers are costs something too, and it is the matching rather than
// the editing. Measured in one Write over 400 paragraphs: no handler and a text
// handler both complete at 5 bytes, while an element handler needs 832 whether it
// reads an attribute or sets one. So the fixed part of the floor is "an element
// handler exists", not "the rewrite changes something".
//
// The rule underneath that is the largest single token a handler is given, and
// only where the token straddles two writes. Measured on a 2012-byte <img> tag
// and on 2800 bytes of 14-byte tags:
//
// one Write 256 B writes 64 B writes
// one 2012-byte tag, matched 5 2012 2012
// one 2012-byte tag, unmatched 5 260 68
// 200 short tags, matched 5 268 76
//
// So the document's length does not come into it, an unmatched token costs
// nothing beyond the write size, and text costs nothing at all because it
// arrives in chunks - while a comment, which arrives whole, costs its length
// like a matched tag. What the rewrite writes does not count either: growing an
// attribute 64 times does not move the floor.
//
// The consequence is that adding a handler can raise the limit a pipeline
// needed before, with no change to the document. A rewrite matching the
// elements that carry srcset - the longest tags on most pages - is the usual
// way to meet this. Measured in memoryfloor_test.go.
MaxMemory int
// GracefulBailOut changes what the rewriter does when MaxMemory is
// exceeded. By default it abandons the response, having already emitted
// some rewritten output and lost the rest of the input, which usually
// yields a truncated document.
//
// When true, the rewriter first flushes every input byte it has received
// but not yet emitted, untransformed. The response is then rewritten up to
// some boundary and verbatim after it, but not broken, and you can keep
// serving by writing subsequent bytes straight to your own sink. The
// rewriter itself is still unusable afterwards.
//
// Two things about that boundary. It can be at the very beginning: the buffer
// requirement is decided early, so a limit that is too small for a document
// usually fails on the first write rather than part way through. Measured on a
// 2.6 KB document of 201 paragraphs fed in 64-byte writes, with a handler
// setting an attribute on each:
//
// output paragraphs rewritten
// MaxMemory 560 0 bytes 0 default: nothing reached the destination
// MaxMemory 560 64 bytes 0 graceful: one write, verbatim
// MaxMemory 900 5425 bytes 201 no bail-out
//
// So "rewritten up to some boundary" can mean "rewritten up to byte zero", and
// the error surfaces in both modes either way.
//
// And what the flush contains is input, not output. For a rewrite that adds
// something - a lazy-loading attribute, a class - continuing to serve is a page
// that is merely unimproved. For a rewrite that removes or neutralises
// something - a sanitiser, a token, an autoplay attribute, a tracking script -
// continuing to serve is serving the thing the rewrite existed to stop. There
// the truncated response is the safer failure, which is the opposite of the way
// this option reads. Measured in gracefulbailout_test.go.
GracefulBailOut bool
}
MemorySettings bounds the memory a single rewriter may use.
type NativeError ¶
type NativeError struct {
Op string // the operation that failed, e.g. "set_attribute"
Message string // lol-html's own message
// contains filtered or unexported fields
}
A NativeError is an error reported by the underlying lol-html library.
func (*NativeError) Error ¶
func (e *NativeError) Error() string
func (*NativeError) Is ¶ added in v0.2.0
func (e *NativeError) Is(target error) bool
Is lets errors.Is reach the conditions a caller branches on. Any other target is not something this error can claim to be, so the answer is no and errors.Is falls back to its own comparison.
func (*NativeError) MemoryLimitExceeded ¶
func (e *NativeError) MemoryLimitExceeded() bool
MemoryLimitExceeded reports whether this error is lol-html's memory-limit error.
ErrMemoryLimitExceeded with errors.Is says the same thing and reads the way Go callers expect, including through the wrapping that ErrPoisoned adds to a later Close. This remains because it is exported.
type Option ¶
type Option interface {
// contains filtered or unexported methods
}
An Option configures a Writer. Options either register a content handler (OnElement, OnComment, OnText, OnDoctype, OnDocumentEnd, OnDocumentComment, OnDocumentText) or tune the rewriter (WithEncoding, WithMemorySettings, WithStrict, WithGracefulBailOut, WithESITags).
A nil Option is a mistake and is refused: NewWriter returns ErrNilOption naming the position rather than panicking, which is what it used to do.
An Option holds no state and can be passed to as many Writers as you like. The function inside it is a different matter: two Writers given the same Option share that function, so anything it closes over is shared too. Building the option set once and reusing it per request is the obvious thing for a server to do, and it is where this goes wrong:
count := 0
opts := []lolhtml.Option{lolhtml.OnElement("a", func(*lolhtml.Element) error {
count++
return nil
})}
// two rewrites on two goroutines, both using opts
Measured: 655 of 800 matches counted, and the race detector reports it. A Writer being safe on its own goroutine is a statement about the Writer.
So build the options where the state lives - a function called once per rewrite, returning both - or synchronise what they share. The cost of building them again is around seven allocations per distinct selector; see the section on cost.
func OnComment ¶
OnComment registers fn to run for every comment inside an element matching selector. Use OnDocumentComment for every comment in the document.
This runs before any OnDocumentComment handler on the same comment; see the package documentation on handler order.
func OnDoctype ¶
OnDoctype registers fn to run for a document type declaration.
For a declaration, not for the declaration: fn runs for every "<!DOCTYPE ...>" token in the input, wherever it appears. An HTML parser honours a DOCTYPE only before anything else has been seen, and discards the rest as parse errors, so the handler is told about doctypes the document does not have. Compared against golang.org/x/net/html:
<!DOCTYPE html><html>... handler 1, parser keeps 1 <!-- c --><!DOCTYPE html><html>... handler 1, parser keeps 1 <meta charset="utf-8"><!DOCTYPE html>... handler 1, parser keeps 0 <html><!DOCTYPE html><body>... handler 1, parser keeps 0 x<!DOCTYPE html><html>... handler 1, parser keeps 0 <!DOCTYPE html><!DOCTYPE html><html>... handler 2, parser keeps 1
So "a doctype was seen" is not "this page has a doctype", and the third row is a document that renders in quirks mode however much its source looks otherwise. A rewrite that decides to leave a page alone because it already has a doctype can be wrong; one that removes every doctype it is offered is fine, since the extra removals were of tokens nothing was honouring.
The declaration cannot be added or replaced either. Doctype has Remove and no insertion methods - the C API has none to bind - and neither has the position before the first element, so there is no way to put a doctype in front of a document that lacks one. Writing it to the destination before the rewriter starts is the only route, and that is only correct when the input has no doctype of its own: prefixing one that has puts the input's declaration second, where a parser discards it. Pinned in differential/doctype_test.go.
func OnDocumentComment ¶
OnDocumentComment registers fn to run for every comment in the document, including comments outside any element.
"Comment" is the HTML parser's meaning of the word, which is wider than "<!-- ... -->": a bogus comment is a comment too. So this fires for <?php ... ?>, for <?xml ... ?>, and for <!anything>. Removing every comment therefore deletes template and processing instructions along with the prose, and "<!x>" is indistinguishable from "<!--x-->" by its text alone. See the package documentation on what counts as a comment.
Every OnComment handler runs before this one on a comment they both see, even if this option came first; see the package documentation on handler order.
Example (BogusComments) ¶
A comment handler fires for what a parser calls a comment, which includes several malformed constructs.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
for _, doc := range []string{
`<!-- ordinary -->`,
`<?php echo "hi"; ?>`,
`<?xml version="1.0"?>`,
`<!bogus>`,
`<! spaced>`,
} {
if _, err := lolhtml.RewriteString(doc,
lolhtml.OnDocumentComment(func(c *lolhtml.Comment) error {
fmt.Printf("%-22q -> %q\n", doc, c.Text())
return nil
})); err != nil {
fmt.Println(err)
return
}
}
}
Output: "<!-- ordinary -->" -> " ordinary " "<?php echo \"hi\"; ?>" -> "?php echo \"hi\"; ?" "<?xml version=\"1.0\"?>" -> "?xml version=\"1.0\"?" "<!bogus>" -> "bogus" "<! spaced>" -> " spaced"
func OnDocumentEnd ¶
func OnDocumentEnd(fn func(*DocumentEnd) error) Option
OnDocumentEnd registers fn to run once, after the last content of the document, so it can append trailing content.
Several may be registered, and they run in the order they were registered, so appended content appears in that order. A handler returning an error stops the ones after it.
func OnDocumentText ¶
OnDocumentText registers fn to run for every text chunk in the document, including text outside any element. See OnText for how chunking works, and for the measured difference between the two.
Every OnText handler runs before this one on a chunk they both see, even if this option came first; see the package documentation on handler order.
func OnElement ¶
OnElement registers fn to run for every start tag matching selector.
Example (MatchingIsDecidedFirst) ¶
A selector is decided against the document as it arrived, so an edit never changes which handlers fire.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
out, err := lolhtml.RewriteString(`<p class="a">t</p>`,
lolhtml.OnElement(".a", func(e *lolhtml.Element) error {
return e.SetAttribute("class", "b")
}),
lolhtml.OnElement(".b", func(e *lolhtml.Element) error {
return e.SetAttribute("data-fired", "yes")
}),
)
fmt.Println(out, err)
}
Output: <p class="b">t</p> <nil>
Example (NotIsWrongForCompoundSelectors) ¶
:not() with a compound selector negates each part separately and requires all of them, so :not(div.a) behaves as :not(div):not(.a).
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
const doc = `<div class="a">1</div><div class="b">2</div>` +
`<span class="a">3</span><span class="b">4</span>`
for _, selector := range []string{`:not(div.a)`, `:not(div):not(.a)`} {
var matched []string
if _, err := lolhtml.RewriteString(doc,
lolhtml.OnElement(selector, func(e *lolhtml.Element) error {
class, _ := e.Attribute("class")
matched = append(matched, e.TagName()+"."+class)
return nil
})); err != nil {
fmt.Println(err)
return
}
fmt.Printf("%-18s %v\n", selector, matched)
}
}
Output: :not(div.a) [span.b] :not(div):not(.a) [span.b]
func OnText ¶
OnText registers fn to run for every text chunk inside an element matching selector, including text inside its descendants. Use OnDocumentText for every text chunk in the document.
Those are not the same set, and the gap is silent. No selector reaches text that is not inside any element, so a fragment - which is what an edge rewrite usually has - hands a selector-based handler less than it looks like:
document OnText("*") OnDocumentText
hello 0 2
<p>a</p>tail 2 4
before<p>a</p>after 2 6
<html><body>a</body></html> 2 2
A redactor written as OnText("*", redact) leaves the first three untouched and reports nothing. The last row is the shape that hides it: a full document has an <html> around everything, so a test written against one passes.
Text arrives in chunks with no guaranteed boundaries: a single text node can be reported as several chunks, and only the last has IsLastInTextNode set. Accumulate across chunks if you need whole text nodes.
A caller does not choose the boundaries: the writes split a node, and so does the tokenizer at a "<" that turns out not to begin a tag - see TextChunk.Text. What no boundary does is split a character. Everything about a document that a handler can observe is invariant across write patterns except this - element, comment and doctype calls, their order, tag names, attributes, source locations, end tags, and the text of each node are all the same however the input arrived - measured over 22 documents and seven write patterns in examples/gip/chunkinvariance.
Registering this handler is not free even if it does nothing. The text path decodes and re-encodes, so a document holding bytes that are not valid in the declared encoding comes out different for having been looked at:
<p>caf\xe9</p> no text handler <p>caf\xe9</p>
a text handler, reading <p>caf\uFFFD</p>
a text handler, ignoring <p>caf\uFFFD</p>
The other paths do not do this: a comment handler leaves a comment's bytes alone, and an element handler leaves an attribute's alone, whatever they hold. So "adding a read-only handler cannot change the output" is true of every kind but this one, which matters for instrumentation - a counter, an audit, a linter - added to a rewrite that has to be byte-exact.
It matters more than one character's worth when the body is not text. A gzip response through a rewrite with a text handler comes back longer and no longer decodable; with element handlers only it comes back byte-identical. Neither reports an error, so a proxy that does not check Content-Encoding either destroys the response or silently rewrites nothing, depending on which handlers it happens to register. See "Rewriting an HTTP response" in the package documentation. Where the answer only has to be reported rather than served, write the rewrite's output to io.Discard and the question does not arise. Measured in readonlytext_test.go and as a property in properties/.
The last chunk of a node is its own call and carries no bytes, in every shape measured - see TextChunk.IsLastInTextNode - so this handler runs at least twice per text node and about half its calls on a document of prose are handed nothing. Work that costs anything belongs behind a length check.
A text node is not the same thing as an element's text, and the difference is where this gets people. <a>click <b>here</b></a> has two text nodes, so this handler fires for both and each gets its own final chunk. Accumulating to IsLastInTextNode and replacing there replaces each node separately, giving "REPLACED<b>REPLACED</b>". A document without nested markup looks perfect and hides it.
For an element's whole text, accumulate here and act in Element.OnEndTag, which is the boundary that means what you want. See the package documentation on reading an element's whole text.
func WithESITags ¶
func WithESITags() Option
WithESITags treats Edge Side Includes tags as void elements, so an <esi:include> written without a self-closing slash does not swallow what follows it. It is off by default, matching lol-html.
Selecting one needs the colon escaped, which is easy to get wrong because the error blames something else:
OnElement(`esi\:include`, ...) // matches
OnElement("esi:include", ...) // Unsupported pseudo-class or pseudo-element
See the package documentation on escaping a selector.
Without it, an esi: element is an ordinary container: its content runs until a matching end tag, and since ESI is conventionally written unclosed, that is usually the enclosing element's end tag. Replacing or removing the include then takes that end tag with it, and the only sign is malformed output:
// <span><esi:include src=a></span>, with a handler replacing the include WithESITags absent: <span>? WithESITags present: <span>?</span>
Writing the tag as <esi:include src=a/> does not help: HTML ignores a trailing slash on an element that is not void and not in a foreign namespace, so the include is still a container without this option. There is no way to spell it that avoids needing this.
Element.CanHaveContent reports the treatment directly: false for an esi: element when this is enabled, true when it is not. <esi:remove>, which is meant to have content, keeps it either way.
This wraps an upstream entry point explicitly marked unstable (unstable_lol_html_rewriter_build_with_esi_tags) and may change or disappear in a future lol-html release.
func WithEncoding ¶
WithEncoding sets the character encoding of the input, as an encoding label from the WHATWG Encoding Standard, such as "utf-8" or "windows-1252". The default is "utf-8".
Nothing is sniffed. The label is the caller's declaration and the rewriter takes it as fact: a document's own <meta charset> is ordinary markup here, read and written like any other element and never consulted. So a document declaring windows-1252 in its head and rewritten with the default is decoded as UTF-8, and the label has to come from wherever the caller actually learned it - a Content-Type header, a database column, a filename convention.
A byte-order mark is not sniffed either, and it is the one that matters, because it ranks the other way round. A <meta charset> ranks below a transport-level charset, so taking the header's word for it agrees with a browser; a leading BOM ranks above it. Measured against the sniffing algorithm in golang.org/x/net/html/charset: a UTF-8 BOM gives "utf-8, certain" whether the declared label is windows-1252, shift_jis or nothing at all, and a UTF-16 mark gives utf-16le or utf-16be the same way. Here the label wins regardless. So a proxy passing a header charset to this option decodes the body differently from the browser it is proxying for, whenever the body has a mark - handed "\xef\xbb\xbf<p>café</p>" as windows-1252, handlers are given "" and "café" where the browser reads "café". Nothing errors, and with no text handler the output is byte-identical either way, so only the handlers' view is wrong.
The mark also arrives as text: the first chunk of that document is U+FEFF over its own three bytes, so anything accumulating text gets a character the page never shows. Both are two lines to fix in a caller - read the first three bytes, prefer what they say, and drop the mark from the text - which is examples/gip/bom.
Getting it wrong is quiet, and how quiet depends on what is registered. The strings handlers are given are wrong either way: the same bytes read as utf-8 and as windows-1252 give "café" and "café" from one attribute. Whether the output is wrong depends on whether a text handler exists. Text is decoded and re-encoded only when one is registered, and then a byte that is not valid in the declared encoding becomes U+FFFD on the way out - whether or not the handler touches it. With no text handler the bytes pass through and only the handlers' view is wrong. Measured on "<p>caf\xe9</p>" declared as utf-8:
no handlers bytes identical an element handler bytes identical an element handler that writes identical bar its own change any text handler caf\xef\xbf\xbd
The encoding is the document's, not your handlers'. Whatever it is, a handler always sees UTF-8: the text of <p>caf\xe9</p> in windows-1252 arrives as the Go string "café". Content you insert is taken as UTF-8 and encoded on the way out, so the output is in the document's encoding throughout.
Which means a rewrite cannot convert a document from one encoding to another. There is no output-encoding option, and replacing every text chunk and every attribute with itself leaves the bytes exactly as they were - measured over windows-1252, iso-8859-2, shift_jis, euc-jp and gbk in legacyencoding_test.go. A program that has to convert transcodes the bytes itself and can use the rewriter for the other half: reading both versions and comparing what the handlers were given proves the text survived, which is what examples/gip/reencode does.
A byte the decoder cannot use has two shapes, and both are visible to a caller that looks. In a document declared UTF-8 it reaches the output as the three bytes of U+FFFD. In a legacy encoding it reaches the output as "�" - a reference, in the text, seven bytes where the document had one - because U+FFFD is not in a legacy repertoire and the reference below is the fallback. Both only when a text handler is registered; with none, the byte passes through.
A character the target encoding cannot represent does not have one answer. It depends on the position, because a numeric character reference is only a character where references are decoded:
content, any ContentType 😀
an attribute value 😀
streamed content 😀
appended at document end 😀
SetTagName refused: "The tag name contains a character that
can't be represented in the document's character
encoding."
Comment.SetText refused, with the same wording for comment text
The two refusals are right rather than inconsistent: there is no such thing as a reference in a tag name, and a comment holds characters rather than references, so emitting one there would put the eight characters of "😀" where a caller asked for one. Refusing is the only honest answer, as it is for a comment-closing sequence.
So a rewrite that inserts characters from outside the document's repertoire has to expect an error from those two, and to know that the reference the others emit is only a character to something that decodes references - which a script and a style do not. Measured for windows-1252 and iso-8859-2 in encoding_test.go.
That fallback is correct wherever a reference is decoded, and inside a <script> or a <style> it is not: the reference stays in the script as the characters it is written with, rather than the character it stands for. Nothing reports it, and the content type makes no difference, because the substitution happens after escaping. See the package documentation on inserting into a script or a style.
What the label cannot do is change which bytes are markup. In a browser a legacy multi-byte encoding can hide a markup character - a lead byte takes the byte after it, and if that byte is a quote or a ">" then a filter reading bytes and a browser reading characters disagree about where the tag ended, which is a whole class of cross-site scripting. Measured here over all 36 accepted encodings against a corpus that puts every markup character after nine different lead bytes: the byte spans of the elements, their names, their attribute names and the spans of the text and comments are identical in every one of them, and identical to x-user-defined, which is single-byte and cannot combine bytes even in principle. The characters differ in almost every encoding; the structure does not differ in any. So a label taken from a header changes what a document says and not what a rewrite treats as a tag. Gated in encodingstructure_test.go, and examples/gip/encodingmatrix runs the comparison over a caller's own corpus.
Two things about the labels are worth knowing, because both come from the standard rather than from this package and both have surprised people:
The labels are aliases, not encodings. "iso-8859-1", "latin1", "ascii" and "us-ascii" all select windows-1252, which is what the standard requires and what browsers do. So a document declared "iso-8859-1" is decoded with windows-1252, and the two differ over 0x80 to 0x9F: in true Latin-1 those are control characters, and here 0x80 is the euro sign.
Four of the standard's encodings are refused, and the list is measured rather than assumed: every canonical name in the Encoding Standard's index was tried, and 36 of the 40 work. The four are
utf-16le utf-16be (and the "utf-16" label, which means utf-16le) iso-2022-jp replacement
The first three are refused as not ASCII-compatible, because the rewriter has to find ASCII markup in the byte stream. UTF-16 is the obvious one; iso-2022-jp is the one that surprises, because its bytes look ASCII until an escape sequence switches the charset, after which they are not - so it cannot be rewritten in a stream either. Decode to UTF-8 before rewriting, and expect a Japanese page to be the one that needs it.
"replacement" is refused as unknown rather than as incompatible. It is a real label in the standard, whose whole purpose is to decode nothing safely, and there is nothing for a rewriter to do with it.
Labels are matched the standard's way, which is worth two notes for a caller passing a value straight from a Content-Type header: leading and trailing whitespace is stripped, so " utf-8 " works, and nothing else is normalised, so "utf_8" and "utf 8" are unknown labels while "iso8859-1" and "iso88591" are windows-1252.
An unusable label fails from NewWriter, not from Write, with an EncodingError naming it.
One thing a rewrite can get wrong on the way out: inserting a <meta charset> does not change the bytes. Output is emitted in the declared encoding throughout, so adding <meta charset="utf-8"> to a document being written as windows-1252 produces a document that lies about itself - the bytes stay windows-1252 and every reader believes the meta. A charset declaration has to name the encoding the bytes are actually in.
Building fails if the label is unknown or names a non-ASCII-compatible encoding such as UTF-16, which lol-html cannot rewrite.
Example (Unrepresentable) ¶
A character the document's encoding cannot represent is inserted as a numeric character reference - which is decoded in text and not in a script.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
for _, tag := range []string{"p", "script"} {
out, err := lolhtml.RewriteString("<"+tag+"></"+tag+">",
lolhtml.WithEncoding("windows-1252"),
lolhtml.OnElement(tag, func(e *lolhtml.Element) error {
return e.SetInnerContent("日", lolhtml.Text)
}))
if err != nil {
fmt.Println(err)
return
}
fmt.Println(out)
}
}
Output: <p>日</p> <script>日</script>
func WithGracefulBailOut ¶
func WithGracefulBailOut() Option
WithGracefulBailOut asks for graceful bail-out. See MemorySettings.GracefulBailOut.
It composes with WithMemorySettings in either order, which is worth saying because WithMemorySettings takes a whole struct and therefore replaces everything in it. The two are combined by union: graceful bail-out is on if either this option or a MemorySettings asks for it, so
WithMemorySettings(MemorySettings{MaxMemory: n}), WithGracefulBailOut()
WithGracefulBailOut(), WithMemorySettings(MemorySettings{MaxMemory: n})
mean the same thing. Passing MemorySettings{GracefulBailOut: false} does not turn off a WithGracefulBailOut given elsewhere; nothing does, because there is no reason to ask for both.
func WithMemorySettings ¶
func WithMemorySettings(m MemorySettings) Option
WithMemorySettings replaces the memory limits. See MemorySettings.
func WithStrict ¶
WithStrict controls strict mode, which is on by default. Leave it on.
The rewriter works on a token stream with no DOM to backtrack through, so a few shapes of non-conforming markup leave it unable to tell whether what follows is markup or raw text. In strict mode it stops; with strict off it guesses, and a wrong guess means your handlers never see that content.
The trigger is narrow and worth knowing exactly. Two contexts have it, and their lists are not the same list - measured by trying every element name in the HTML index inside each, in strict_test.go.
Inside a <select>, eight names:
title style iframe xmp plaintext noembed noframes noscript
Inside a <frameset>, nine - the same eight without <noframes>, which is legal there, plus <script> and <textarea>, which are not ambiguous in a <select> and are here:
title style iframe xmp plaintext noembed noscript script textarea
<script> is explicitly allowed in a <select>, and <select>, <textarea>, <input> and <keygen> end the ambiguous context there rather than entering it - so <select><textarea><title> is fine. None of that carries over: inside a <frameset> those tags do not end anything, and <frameset><select><title> is ambiguous. <noframes> is the one thing that ends it there.
Nothing outside those two contexts triggers it - measured for every name in the index inside a <div>, a <table>, a <template> and an <optgroup>.
Neither mode is simply the safe one, which is why this is spelled out:
With strict on, the rewrite fails from Write or Close with a *NativeError that errors.Is matches against ErrAmbiguousTag, and whatever had already been emitted has reached the sink. That is a truncated document, exactly as with a memory bail-out, so a caller has to discard the response rather than serve what it has.
With strict off, the rewrite succeeds and the ambiguous element is treated as a raw-text element, so its content is text rather than markup. For a rewriter that adds attributes this means a missed region. For anything that removes content it is a bypass: a sanitiser that strips every <script> does not strip this one,
<select><xmp><script>alert(1)</script>
and emits it verbatim. Turning strict off to get past a failure hands that through.
What that region is, exactly, is worth knowing rather than guessing at, because it is narrower than "everything after the tag" and not as quiet as "nothing is seen". Measured on <select><xmp><script>alert(1)</script></xmp></select><p>after</p>:
element handlers select, xmp and p all fire; script does not text handlers the script's source arrives as text, in chunks the output identical to the input
So the ambiguous element itself is an element, the document after the region is markup as usual - a closed ambiguous tag costs only its own content, and an <img> after a <title> in a <select> still fires - and the missed markup is text that a text handler is given. A rewrite that cannot use strict mode can therefore refuse on its own terms: a run of text holding "<script" is the signal, and returning an error from the text handler stops the document. Measured in strict_test.go.
The unseen region runs from the ambiguous tag to its closing tag, or to the end of the input if there is not one - and a document that trips this guard is already malformed, so often there is not.
Example ¶
Strict mode refuses input whose meaning the rewriter cannot be sure of, rather than producing output that silently differs. An <iframe> inside a <select> is one such case: what the tag means there depends on the tree, and a rewriter has no tree.
package main
import (
"fmt"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
const doc = `<select><iframe></iframe></select>`
for _, strict := range []bool{false, true} {
out, err := lolhtml.RewriteString(doc,
lolhtml.WithStrict(strict),
lolhtml.OnElement("iframe", func(e *lolhtml.Element) error {
return e.SetAttribute("data-x", "1")
}))
fmt.Printf("strict=%-5v refused=%-5v out=%q\n", strict, err != nil, out)
}
}
Output: strict=false refused=false out="<select><iframe data-x=\"1\"></iframe></select>" strict=true refused=true out=""
type SelectorError ¶
A SelectorError reports a CSS selector that lol-html could not parse or does not support. Selector is the value that was passed. See the package documentation on which selectors are supported.
func (*SelectorError) Error ¶
func (e *SelectorError) Error() string
type Sink ¶
type Sink struct {
// contains filtered or unexported fields
}
A Sink receives the output of a StreamFunc.
Writes may fall anywhere: a rune split across two of them is joined, so copying from a reader that knows nothing about UTF-8 boundaries is safe. What is not safe is stopping in the middle of one, and that is checked when the StreamFunc returns; see ErrIncompleteRune.
It is valid only for the duration of that call. The rewriter may run it on a different goroutine than the one that called Write, though never on more than one at a time, so a StreamFunc must not depend on goroutine-local state.
func (*Sink) AsWriter ¶
func (s *Sink) AsWriter(ct ContentType) io.Writer
AsWriter adapts the sink to io.Writer, so content can be produced with io.Copy or fmt.Fprintf. Writes go through WriteChunk, so chunk boundaries may fall anywhere in a UTF-8 sequence.
A nil error from the returned writer means the content was accepted, not that it was delivered: the sink writes into lol-html's buffer and a destination failure surfaces from Write or Close instead. So io.Copy will happily copy a whole template into a rewrite that has already failed. Check Sink.Err between chunks if that matters, which for anything large it does.
func (*Sink) Detached ¶
func (u *Sink) Detached() bool
Detached reports whether this value has outlived its handler. Every other method returns ErrDetached, or a zero value, once this is true.
func (*Sink) Err ¶ added in v0.2.0
Err reports the error that has already stopped this rewrite, if any.
It exists because the sink's own methods cannot tell you. They write into lol-html's buffer, not to the destination, so a nil from WriteString, WriteChunk or a writer from AsWriter means the content was accepted - not that it arrived. A destination that fails is recorded and reported from the Write or Close that was running, and until then the sink goes on accepting everything: measured, fifty writes after a failing destination were all accepted and none reported anything.
For short content that costs nothing. For the case a StreamFunc is for - large or incrementally produced content, the io.Copy of a big template the documentation recommends - it means copying the whole thing after there is nowhere for it to go. Err is how to stop:
e.StreamAppend(func(s *lolhtml.Sink) error {
for _, chunk := range chunks {
if err := s.Err(); err != nil {
return err
}
if err := s.WriteString(chunk, lolhtml.HTML); err != nil {
return err
}
}
return nil
})
Returning it is optional: the rewrite is already failing and the error will surface from Write or Close either way. Returning it costs nothing and makes the abandoned work visible in a stack trace rather than silent.
Nil means nothing has failed yet, not that anything has succeeded. There is no point at which the destination is known to have taken the content, because the rewriter may still be holding it.
This is the destination failing under a StreamFunc that is still going. The other direction - the StreamFunc failing after the destination has taken something - is not recoverable at all; see StreamFunc.
func (*Sink) WriteChunk ¶
func (s *Sink) WriteChunk(b []byte, ct ContentType) error
WriteChunk writes a fragment of UTF-8 to the sink, escaping it when ct is Text.
Unlike WriteString, b need not be complete UTF-8: a trailing partial sequence is buffered and flushed once a later WriteChunk completes it, so content can be forwarded straight from a network read.
Two ways of never completing it, both of which used to be silent and are now ErrIncompleteRune. A StreamFunc that returns with a sequence still open loses those bytes - lol-html drops them - and a WriteString while one is open does not join it: the held bytes become U+FFFD and the string follows them.
func (*Sink) WriteString ¶
func (s *Sink) WriteString(str string, ct ContentType) error
WriteString writes s to the sink, escaping it when ct is Text.
s must be complete, valid UTF-8. Use WriteChunk for content split at arbitrary byte boundaries. Bytes that are not valid UTF-8 are refused and match ErrInvalidUTF8, which is worth handling for anything that came from outside the program.
type SourceLocation ¶
SourceLocation is the half-open byte range a unit occupied in the input document, counted from the first byte fed to the rewriter.
Slicing your own input at these offsets while streaming takes one precaution: retain from the end of the last unit you were told about, not from the last point where nothing was pending. A start tag spans writes, and the handler for it runs after its first bytes were already handed over - fed three bytes at a time, the handler for `<div id=a>` at offset 0 fires while a caller that dropped its buffer between units is holding input from offset 9, and the element it is asked to slice begins before anything it kept. Tokens do not overlap, so the end of the last reported unit is a safe floor, and retention between units is then bounded by the largest single token rather than by nothing. examples/gip/dupsection does this to copy a section without holding the document.
The bytes fed, before anything is decoded or transcoded. Under WithEncoding the reported text of a unit is UTF-8 and the range is not: a text chunk reading "café" in a windows-1252 document has a four-byte range, because that is what the document spent on it. So slicing the input at the range works and measuring the reported string does not. The offsets are absolute and unaffected by how the document was written in - one byte at a time gives the same numbers as one call - which is what makes them usable as identity across two passes, as long as both passes are fed the same bytes.
A text chunk is the exception, and it is the one that matters for a proxy reading from an io.Reader with a fixed buffer. When a multi-byte character straddles a write boundary, the chunk's range covers only the part of it that arrived in the last write, or the bytes held over are charged to the chunk already emitted. `<p>a€b</p>` fed in one call reports one chunk, 3..8, whose text is its own slice. Fed three bytes at a time it reports 3..6 for the text "a" - three bytes of range for one byte of text - and fed one byte at a time it reports 3..4 "a", 6..7 "€", 7..8 "b", leaving bytes 4 and 5 named by no chunk at all. The text is right in every case; the range is not. So for a text chunk, neither the write-invariance above nor slicing the input at the range can be relied on.
The way to map the text of a document without depending on the write pattern is to take the ranges of the units around it, which do not move: an element, an end tag, a comment and a doctype report the same range at every write size, including when their own content is multi-byte. Everything between them is text (or a stray end tag, below), and it can be read from the caller's own copy of the input - which is also how to read the text of a body that is not text at all, since a registered text handler decodes and re-encodes and turns every undecodable byte into U+FFFD. examples/gip/textmap does this.
What the range covers depends on the unit:
an element its start tag, and nothing of its content
an end tag the tag that closed the element, which may belong to an
enclosing one - see [Element.OnEndTag]
a comment the whole token, delimiters included; see [Comment]
a doctype the whole declaration
a text chunk the bytes of that chunk
The units do not tile the document. A stray end tag - one with no start tag to pair with - reaches no handler at all: an end tag is observable only through Element.OnEndTag, and there is no element to register that on. Its bytes are still written to the output. So `<p>a</p></p>` reports ranges covering its first eight bytes and nothing for the last four, and a tool that rebuilds a document from the ranges it was told about has to treat the gaps between them as content rather than as an impossibility. Measured for `</p>`, `</span>`, `</br>`, `</img>`, `</p class=x>`, `</>` and `</circle>`, with the document written in one call. One space decides it: `</ x>` is not a tag but a bogus comment, so a comment handler does see that.
A range can be empty. The final chunk of a text node has a range that is the zero-width point where the node ended - which is the way to find a text node's extent, from the first chunk's Start to the last chunk's End. Its range, not its text: the chunk usually carries no text either, and it carries the replacement character when the node ended with bytes that could not be decoded, so "<p>ab\xe9</p>" ends with a flagged chunk whose text is U+FFFD at a zero-width range. See TextChunk.IsLastInTextNode. A replacement character anywhere in a node has the same shape: fed "caf\xe9" as UTF-8, the chunk reporting U+FFFD stands at a point rather than over any bytes. So the length of the reported text and the length of the range are unrelated numbers.
Measured in sourcelocation_test.go.
func (SourceLocation) Len ¶
func (s SourceLocation) Len() int
Len reports the length of the range in bytes.
func (SourceLocation) String ¶
func (s SourceLocation) String() string
type StreamFunc ¶
A StreamFunc produces inserted content on demand, writing it into the sink instead of returning it.
Use it when the content is large or generated incrementally: nothing has to be assembled in memory first - each write reaches the destination as it is made, measured in streamcommit_test.go.
Returning an error aborts the rewrite, and the error surfaces from Write or Close - but what the function has already written stays written, which is the one place where failing costs something. A handler that fails discards its insertion:
e.Before("<div>partial", lolhtml.HTML)
return err
// the destination gets nothing: the insertion goes with the rewrite
A StreamFunc that fails does not, because the point of it is that the content was already on its way:
e.StreamBefore(func(s *lolhtml.Sink) error {
if err := s.WriteString("<div>partial", lolhtml.HTML); err != nil {
return err
}
return err // a fetch that failed halfway, say
})
// the destination already has "<div>partial", unclosed <div> and all
So the first byte written to the sink is a commitment: after it there is no error path that leaves a usable document, only a truncated one. Whatever has to be true before committing - the file opened, the request returned 200, the template parsed - has to be established in the handler, where returning an error still costs nothing, and the sink used only for content that is already known to exist. examples/gip/include is built that way round.
"On demand" means when the content is emitted, which is neither immediately nor at the end, and two things follow from that.
It cannot see anything the rewriter has not parsed yet. A function registered on an element runs while that element is being written out, so state gathered from later in the document is not there yet, and the failure is silent - you get the empty result your closure computed, not an error. Building a table of contents at a marker near the top of a page is the usual way to meet this: one streaming pass cannot do it. Read the document twice, buffer it, or put the content at the document end with OnDocumentEnd, which is the one position that has seen everything.
It may never run at all. If the content is discarded - the element was removed by a later handler, or it is inside something that was removed - the function is not called. So a StreamFunc is the wrong place for a side effect you need: count and log in the handler, and write only content in the sink.
It must not finish mid-character. Writes may split a rune however they like - lol-html joins the pieces, which is what makes io.Copy from an arbitrary reader safe - but a sequence still open when the function returns is dropped, so returning then is ErrIncompleteRune rather than a shorter insertion nobody mentioned.
None of the streaming insertions is checked for a raw-text breakout, which the equivalent one-shot methods on Element are: content arrives in pieces and a "</script>" can straddle two of them, so there is nothing whole to check. An insertion into a script or a style body therefore ends the element if the content says so, silently. CheckRawText is the guard to call, on content the StreamFunc assembled rather than on the pieces. See ErrRawTextBreakout, which records this as one of its two gaps.
type TextChunk ¶
type TextChunk struct {
// contains filtered or unexported fields
}
A TextChunk is a run of character data matched by a text handler.
Text is reported in chunks with no guaranteed boundaries: one text node may arrive as several chunks, split wherever the parser happened to stop. Only the final chunk of a node has IsLastInTextNode set, and that last chunk is usually empty - it exists to mark the boundary, and it is not empty when the node ends with undecodable bytes; see TextChunk.IsLastInTextNode. Accumulate across chunks, and that chunk's text with them, if you need whole text nodes.
A TextChunk is valid only for the duration of the handler that received it; see the package documentation on handler lifetime.
func (*TextChunk) After ¶
func (t *TextChunk) After(content string, ct ContentType) error
After inserts content immediately after the chunk.
Called twice, the second insertion lands before the first: see the package documentation on two insertions of the same kind.
Inside a raw-text element this is unguarded: see TextChunk.Replace and CheckRawText.
func (*TextChunk) Before ¶
func (t *TextChunk) Before(content string, ct ContentType) error
Before inserts content immediately before the chunk.
Inside a raw-text element this is unguarded: see TextChunk.Replace and CheckRawText.
func (*TextChunk) Bytes ¶
Bytes returns the chunk's text as a freshly allocated byte slice. As with Text, character references are left encoded.
func (*TextChunk) Detached ¶
func (u *TextChunk) Detached() bool
Detached reports whether this value has outlived its handler. Every other method returns ErrDetached, or a zero value, once this is true.
func (*TextChunk) IsLastInTextNode ¶
IsLastInTextNode reports whether this is the final chunk of its text node.
That chunk is usually a call of its own carrying no bytes: it exists to mark the boundary. Measured empty for a short text node, a 100 KB one, character references, each of the four raw-text elements, and the same document fed in one-, three- and five-byte writes, which changes how the content is chunked and not how it ends. An element with no text has no text node and so no final chunk at all.
It is not empty when the node ends with bytes that could not be decoded in the document's encoding: then it carries the replacement character produced for them. Fed "<p>ab\xe9</p>" as UTF-8 the calls are "ab" and then a final chunk whose text is U+FFFD, three bytes of it. So accumulate this chunk's own text before acting on the flag - a handler that treats it as a marker and returns loses a character, silently, on exactly the input that a text handler already rewrites lossily. Measured in every raw-text element, after 100 KB of text, at every write size, and with the document unterminated. The same bytes under WithEncoding "windows-1252" decode, so the final chunk there is empty again.
The usual consequence is a cost: a text handler runs twice per text node, and on a document of prose about half its calls are handed nothing. See OnText. It can run once, where the node is nothing but a truncated multi-byte sequence: "<p>\xe9</p>" and "<p>\xc3</p>" are a single call each, which is both the first chunk of the node and its last, and carries the replacement character. A standalone invalid byte is still two calls, because it is replaced inside the content chunk and the empty boundary follows - "<p>\x80</p>" is two.
Its text node, not its element: an element containing nested markup has one text node per run of character data, and each one ends with its own final chunk. Element.OnEndTag is the boundary that means "this element's content is complete".
Example ¶
A text handler sees a text node in as many chunks as the input arrived in, and IsLastInTextNode marks the end of the node rather than of the element.
package main
import (
"fmt"
"io"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
w, err := lolhtml.NewWriter(io.Discard,
lolhtml.OnText("p", func(t *lolhtml.TextChunk) error {
fmt.Printf("%q last=%v\n", t.Text(), t.IsLastInTextNode())
return nil
}))
if err != nil {
fmt.Println(err)
return
}
for _, chunk := range []string{"<p>one", " two</p>"} {
if _, err := w.Write([]byte(chunk)); err != nil {
fmt.Println(err)
return
}
}
if err := w.Close(); err != nil {
fmt.Println(err)
}
}
Output: "one" last=false " two" last=false "" last=true
func (*TextChunk) IsRemoved ¶
IsRemoved reports whether the chunk has been removed by a handler.
This chunk, and not the element it is in: text inside an element another handler has removed reports false, because nothing has been done to the chunk itself. Element.IsRemoved does answer for an ancestor, so a text handler that needs to know - anything accumulating, since the text it is being handed may be on its way out - has to be told by an element handler. See the package documentation on removal. Measured in removedsubtree_test.go.
func (*TextChunk) Replace ¶
func (t *TextChunk) Replace(content string, ct ContentType) error
Replace replaces the chunk with content.
Rewriting the text of a raw-text element - a stylesheet, a script body - means HTML rather than Text, because Text escapes the three markup characters and raw text does not decode references: a CSS ">" would come back as ">" and a script's "a < b" as "a < b". And HTML here is not checked for a breakout the way the Element methods are, because a chunk cannot say what element it is in, so a "</style>" in the content ends the element. Call CheckRawText with the tag name the handler asked for.
func (*TextChunk) SetUserData ¶
SetUserData attaches a value to this chunk, readable by another handler that is given the same chunk.
The same chunk, not the same text node: each chunk is its own unit, so this is not a place to accumulate across the chunks of one node. Measured - the second chunk of a two-chunk node reads nil. Go handlers can usually close over the value instead.
The chunk being the unit makes this the one cost in the library that depends on how the caller fed the document rather than on what the document says. A handle is held per chunk until the rewrite ends, and how many chunks a node arrives in is decided by the write sizes:
one 2000-byte text node written whole 2 chunks
1024-byte writes 3 chunks
64-byte writes 33 chunks
one byte at a time 2001 chunks
A rewrite reading from a socket does not choose those sizes, so this is a shape to avoid rather than to budget for. Setting the value to nil releases the handle immediately; see Element.SetUserData for the cost and the mitigation, and userdatacost_test.go for the gate.
func (*TextChunk) SourceLocation ¶
func (t *TextChunk) SourceLocation() SourceLocation
SourceLocation returns the byte range the chunk occupied in the input.
func (*TextChunk) StreamAfter ¶
func (t *TextChunk) StreamAfter(fn StreamFunc) error
StreamAfter inserts content after the chunk, produced on demand by fn.
func (*TextChunk) StreamBefore ¶
func (t *TextChunk) StreamBefore(fn StreamFunc) error
StreamBefore inserts content before the chunk, produced on demand by fn.
func (*TextChunk) StreamReplace ¶
func (t *TextChunk) StreamReplace(fn StreamFunc) error
StreamReplace replaces the chunk with output produced on demand by fn.
func (*TextChunk) Text ¶
Text returns the chunk's text exactly as it appeared in the source, with character references left encoded: the text of <p>café</p> is "café", not "café".
A chunk never contains part of a character. Where the chunk boundaries fall is not a caller's choice - but they always fall between characters, measured at one byte per write over two-, three- and four-byte runes, in text, in a comment and in an attribute value. Content going the other way has the opposite rule: Sink.WriteChunk takes a partial sequence and joins it to the next write.
Two things decide the boundaries, and only one of them is the writes. The tokenizer splits a text node of its own accord at a "<" that turns out not to begin a tag, and delivers that character as a chunk by itself:
<p>3 < 4 and 5 < 6</p> "3 " "<" " 4 and 5 " "<" " 6" ""
Six chunks for one text node, from one write. So controlling the writes does not control the chunking, and prose with a bare "<" in it - arithmetic, a code sample outside a <code> element - arrives in more pieces than a caller sizing the work by writes would expect. A "<" does not split anything, and neither does a "&", a NUL or a CRLF; "<!", "</" and "<?" do something else again, since each of those begins a comment token and so ends the text node.
What a boundary does split is everything larger than a character. So a transform applied per chunk is safe per character and wrong per pattern: strings.ToUpper on a chunk is correct however the document arrived, a regular expression looking for a word is not, because the word can straddle two chunks. Accumulate to TextChunk.IsLastInTextNode for anything that spans more than one character, and see the package documentation on reading an element's whole text for why that is still not the element's text.
This is deliberate on lol-html's part - a rewriter has to be able to re-emit what it read - but it is easy to trip over when comparing against a plain Go string. Use html.UnescapeString from the standard library when you need the decoded form.
Transforming text and writing it back ¶
That is the operation most text handlers perform, and only one of the three obvious spellings is right. Measured on <p>a < b & café</p> with strings.ToUpper as the transform, applied once and then again to its own output:
Replace(f(Text()), Text) A < B &AMP; CAF&EACUTE;
then A &LT; B &AMP;AMP; ...
Replace(f(Text()), HTML) A < B & CAF&EACUTE;
then the same
Replace(f(Unescape(Text())), Text) A < B & CAFÉ
then the same
The first escapes references that were already escaped - on the first pass, not only on the second - so a page rewritten twice shows "&LT;" where it used to show "<".
The second is stable and wrong in a quieter way: the transform ran over the source, so "é" became "&EACUTE;", which is not a character reference at all and renders as those nine characters. It is also HTML, so anything the transform produces is markup - fine for a transform you wrote, an injection for one driven by data.
The third is the one that means what it says. Decode, transform, and let the library escape: the output is correct on the first pass and unchanged by the second.
type Writer ¶
type Writer struct {
// contains filtered or unexported fields
}
A Writer streams HTML through lol-html, applying the registered handlers and forwarding rewritten output to an underlying io.Writer.
Write may be called as many times as you like with arbitrary chunk boundaries; handlers observe the document as if it had arrived in one piece. Close finishes the document and flushes the tail of the output, and must be called to get well-formed output.
A Writer is not safe for concurrent use, and independent Writers on separate goroutines are fine - as long as their handlers are independent too. See Option on reusing one.
Nor is it reentrant: a handler, or a destination writer, must not call Write or Close on the Writer that is running it. Both refuse with ErrReentrant rather than re-entering lol-html, which has no idea it is already running.
func NewWriter ¶
NewWriter builds a rewriter that writes its output to dst.
Options register handlers (see OnElement and friends) and tune the rewriter (see WithEncoding, WithMemorySettings, WithStrict). At least one handler is usually wanted; with none, the output is the input re-serialised.
Put a bufio.Writer in front of dst unless it is already buffered. How many times dst is written to is decided by what the rewrite does, not by how the document is written, and a mutation makes it much larger. Measured on <div class="row"><a href="/p">link</a></div>, written in one call:
passthrough 1 write a handler that matches 3 writes the handler reads an attribute 3 writes the handler removes an attribute 5 writes the handler sets an attribute 12 writes
because a mutated start tag is re-serialised piece by piece: "<", "a", " ", `href="/p"`, " ", "rel", `="`, "noopener", `"`, ">". Over 2000 such elements that is one 132 KB write becoming 22,001 writes with a median size of one byte, which on a socket or a file is 22,001 system calls for 162 KB.
Nothing is buffered here on purpose: a caller streaming to a client wants the bytes as they are produced, and a buffer belongs where that caller can flush it. Pinned in writecount_test.go.
Close every Writer, including one being abandoned. There is a cleanup attached here that frees the native resources if a Writer is dropped without it, but it is a backstop rather than a second way of doing this, and it is one a caller can take away without noticing: handler payloads live in a process-global handle table until the Writer is released, so a handler that closes over the Writer - to count into it, to stop it, to reach it from a nested rewrite - makes the Writer permanently reachable through that table, and the cleanup that would have released it can never run. The rewriter, its selectors and every handle then leak for the life of the process. Nothing detects it at runtime; a deferred Close is the whole answer.
Example ¶
The streaming shape from the package documentation, with a strings.Reader standing in for a response body.
package main
import (
"fmt"
"io"
"os"
"strings"
lolhtml "github.com/JakeChampion/golol-html"
)
func main() {
w, err := lolhtml.NewWriter(os.Stdout,
lolhtml.OnElement("a[href]", func(e *lolhtml.Element) error {
href, _ := e.Attribute("href")
return e.SetAttribute("href", "https://example.com"+href)
}),
)
if err != nil {
fmt.Println(err)
return
}
if _, err := io.Copy(w, strings.NewReader(`<a href="/a">one</a>`)); err != nil {
fmt.Println(err)
return
}
if err := w.Close(); err != nil {
fmt.Println(err)
}
}
Output: <a href="https://example.com/a">one</a>
func (*Writer) Close ¶
Close finishes the document, flushes the remaining output and releases every native resource held by the Writer. It is safe to call more than once.
The error from the final flush is reported here, so Close must not be ignored. Two handlers can still run inside it: OnDocumentEnd always, and a text handler for the last chunk of a text node the document left open - measured, a closed element delivers every chunk during Write and an unclosed one delivers its boundary chunk during Close. So an error or a panic from a text handler can surface from here rather than from Write, and a caller that recovers around Write alone has a gap. An end-tag handler for an element nothing closes never runs at all, so it is not a third case.
A panic from a handler running inside Close leaves the Writer closed rather than poisoned, because Close marks it closed before it does anything: a later Write reports ErrClosed and a later Close reports nil. A panic from Write poisons it with the bare sentinel. Either way the native resources are released on the way out and the library is unaffected: examples/gip/panics prints the whole table.
Close is also the call that discovers a destination that broke after the last Write - but only when Close is the call that writes. For most documents it writes nothing, because the bytes have already gone: measured, a document that ends cleanly, in the middle of text, or inside a raw-text element has been handed over entirely by the time Close is called, and Close reports nil however broken the destination is. Close writes, and so can fail, when the document ends inside a token - an unfinished end tag, attribute, comment, or a bare "<" - or when a handler appends at the document end. Gated in sinkfailure_test.go.
If an earlier Write already failed, Close reports ErrPoisoned wrapped around that first error rather than the bare sentinel: checking only Close is the ordinary Go shape, and it should not lose the reason.
The first Close, that is. "Safe to call more than once" means the later calls do nothing and return nil, including after a failure - so a caller whose only check is on a Close that runs second sees nil for a rewrite that failed. The shape to avoid is an explicit Close in an error path together with a deferred one that assigns to the returned error; keep one Close, and let it be the one whose error is checked. Measured in faults_test.go, which asserts the quiet second Close deliberately, and demonstrated in examples/gip/poisoned.
Not from inside a handler, though, which is the one place "safe to call more than once" used to read as an invitation: closing from a handler would free the rewriter underneath the write still running on it. Called there, or from the destination writer, Close does nothing and returns ErrReentrant. A handler stops the document by returning an error instead.
func (*Writer) Write ¶
Write feeds the next chunk of HTML to the rewriter. Rewritten output is forwarded to the destination writer as it becomes available, which may happen during this call or a later one.
The destination can be another Writer, since a Writer is an io.Writer: that is a streaming second pass, which is what it takes to act on markup an earlier stage produced. Close the stages upstream first, because each one flushes into the next. See the package documentation on two passes.
An error from one of your handlers, or from the destination writer, surfaces here. lol-html cannot resume after an error, so the Writer is poisoned and every later Write and the Close return ErrPoisoned wrapped around that first error - so errors.Is and errors.As still reach it, however late it is asked for.
A destination failure stops the rewrite, and stops it completely: no further element, comment or text handler runs, and the OnDocumentEnd handler never runs at all. That last one is worth planning for, because the document end is where a rewrite naturally writes its accounting - a summary logged there logs nothing on the run where the client went away. Keep the counters where the caller can read them after the error, and treat them as what the rewrite reached rather than as what the page contains. examples/gip/clientgone prints the difference; sinkfailure_test.go gates it.
Where a destination failure stops does not depend on the write sizes: the budget is a fact about the destination and the page is a fact about the document. A destination that accepts nothing at all still sees one handler run, because handlers run as tokens are parsed and the destination is written to afterwards.
What the destination is handed is lol-html's own buffer, not a copy: the slice passed to dst.Write is a view of Rust memory that is reused or freed as soon as the call returns. io.Writer already forbids retaining p, so an ordinary destination is unaffected - but here the cost of breaking that rule is not a stale read of Go memory the garbage collector is still holding. It is a read of freed native memory, which the race detector cannot see and which fails at whatever distance the retained slice is finally looked at. A destination that queues its argument - an asynchronous logger, a tee that buffers slices - must copy before it does. This is by construction rather than by measurement: there is no copy to leave out, and no test can safely demonstrate the read.
Failing is not atomic. Everything before the token whose handler failed has already reached the destination, at every write size and including a single Write of the whole document, and what it holds is a whole number of tokens - well-formed markup that a parser accepts. So a caller who returns an error to refuse a document has already delivered a short version of it unless it held the output itself: write into a buffer and forward only on success, which is what examples/gip/mixed does. Measured in handlerfailure_test.go, along with the two ends of the range - a handler that fails on the document's first element delivers nothing, and one that fails in OnDocumentEnd has already delivered all of it.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
gip/absolutise
command
Command absolutise rewrites every relative URL in an HTML document against a base URL, streaming, and reports what it changed.
|
Command absolutise rewrites every relative URL in an HTML document against a base URL, streaming, and reports what it changed. |
|
gip/abtest
command
Command abtest picks a variant for each experiment, keeps the markup for the chosen one, removes the rest, and marks the document with what it chose.
|
Command abtest picks a variant for each experiment, keeps the markup for the chosen one, removes the rest, and marks the document with what it chose. |
|
gip/alt
command
Command alt reports images with no alt attribute, and images whose alt text says nothing a screen reader could not already say.
|
Command alt reports images with no alt attribute, and images whose alt text says nothing a screen reader could not already say. |
|
gip/article
command
Command article finds a page's article body by scoring elements as it streams past them, and then emits that element's subtree and nothing else.
|
Command article finds a page's article body by scoring elements as it streams past them, and then emits that element's subtree and nothing else. |
|
gip/autocomplete
command
Command autocomplete adds autocomplete tokens to form fields whose purpose the markup makes plain.
|
Command autocomplete adds autocomplete tokens to form fields whose purpose the markup makes plain. |
|
gip/autoplay
command
Command autoplay stops media that would start playing on its own.
|
Command autoplay stops media that would start playing on its own. |
|
gip/backpressure
command
Command backpressure measures what a rewrite costs a slow destination.
|
Command backpressure measures what a rewrite costs a slow destination. |
|
gip/bailout
command
Command bailout shows what a memory limit does to a response, with and without graceful bail-out.
|
Command bailout shows what a memory limit does to a response, with and without graceful bail-out. |
|
gip/beacon
command
Command beacon injects an analytics beacon and then proves it changed nothing else.
|
Command beacon injects an analytics beacon and then proves it changed nothing else. |
|
gip/bindings
command
Command bindings turns framework attribute syntax into plain HTML attributes where it can, and says why it cannot everywhere else.
|
Command bindings turns framework attribute syntax into plain HTML attributes where it can, and says why it cannot everywhere else. |
|
gip/bom
command
Command bom rewrites a document that may begin with a byte-order mark, and does what a browser would do with it - which is not what this library does on its own.
|
Command bom rewrites a document that may begin with a byte-order mark, and does what a browser would do with it - which is not what this library does on its own. |
|
gip/breadcrumb
command
Command breadcrumb emits schema.org BreadcrumbList JSON-LD derived from a page's own breadcrumb nav.
|
Command breadcrumb emits schema.org BreadcrumbList JSON-LD derived from a page's own breadcrumb nav. |
|
gip/buildinfo
command
Command buildinfo stamps a page with the commit that produced it, and reports where it managed to put the stamp.
|
Command buildinfo stamps a page with the commit that produced it, and reports where it managed to put the stamp. |
|
gip/bust
command
Command bust puts a build's content hash on every asset URL it knows.
|
Command bust puts a build's content hash on every asset URL it knows. |
|
gip/bytewise
command
Command bytewise measures what a write size costs.
|
Command bytewise measures what a write size costs. |
|
gip/cachetags
command
Command cachetags collects cache tags from a document and turns them into a header value, which is a thing a streaming rewriter cannot quite do.
|
Command cachetags collects cache tags from a document and turns them into a header value, which is a thing a streaming rewriter cannot quite do. |
|
gip/canonical
command
Command canonical enforces exactly one canonical link element.
|
Command canonical enforces exactly one canonical link element. |
|
gip/captions
command
Command captions gives a video with no captions track a placeholder to fill in.
|
Command captions gives a video with no captions track a placeholder to fill in. |
|
gip/charset
command
Command charset makes sure a document declares the encoding it is actually in.
|
Command charset makes sure a document declares the encoding it is actually in. |
|
gip/chunkinvariance
command
Command chunkinvariance checks what does and does not depend on how the input was written.
|
Command chunkinvariance checks what does and does not depend on how the input was written. |
|
gip/classmap
command
Command classmap renames classes through a mapping, the way a CSS-modules build does.
|
Command classmap renames classes through a mapping, the way a CSS-modules build does. |
|
gip/clicktoload
command
Command clicktoload turns every third-party iframe into a placeholder that loads on click, so a page does not hand an embed to the reader before they ask for it.
|
Command clicktoload turns every third-party iframe into a placeholder that loads on click, so a page does not hand an embed to the reader before they ask for it. |
|
gip/clientgone
command
Command clientgone rewrites a page to a destination that stops accepting bytes partway through, which is what a browser closing a connection looks like from inside a handler.
|
Command clientgone rewrites a page to a destination that stops accepting bytes partway through, which is what a browser closing a connection looks like from inside a handler. |
|
gip/collapse
command
Command collapse collapses runs of insignificant whitespace to a single space and leaves the elements where whitespace is significant alone.
|
Command collapse collapses runs of insignificant whitespace to a single space and leaves the elements where whitespace is significant alone. |
|
gip/comments
command
Command comments renders an untrusted comment: it removes what a comment has no business containing, and turns bare URLs into links.
|
Command comments renders an untrusted comment: it removes what a comment has no business containing, and turns bare URLs into links. |
|
gip/consentgate
command
Command consentgate stops third-party scripts from running until consent is given, by rewriting their type attribute rather than removing them.
|
Command consentgate stops third-party scripts from running until consent is given, by rewriting their type attribute rather than removing them. |
|
gip/controls
command
Command controls gives media elements a control bar and stops them downloading until someone asks for them.
|
Command controls gives media elements a control bar and stops them downloading until someone asks for them. |
|
gip/cookiebanner
command
Command cookiebanner injects a consent banner before the closing body content, without a script and without a template engine.
|
Command cookiebanner injects a consent banner before the closing body content, without a script and without a template engine. |
|
gip/corpus
command
Command corpus reports which of the rewriter's documented hazards a document actually contains.
|
Command corpus reports which of the rewriter's documented hazards a document actually contains. |
|
gip/cspnonce
command
Command cspnonce prepares a document for a nonce-based Content Security Policy: it stamps every script and style with the nonce, removes any nonce the document already carried, hashes inline script bodies so a hash-based policy can be emitted as well, and reports the constructs that no nonce can rescue.
|
Command cspnonce prepares a document for a nonce-based Content Security Policy: it stamps every script and style with the nonce, removes any nonce the document already carried, hashes inline script bodies so a hash-based policy can be emitted as well, and reports the constructs that no nonce can rescue. |
|
gip/csrf
command
Command csrf inserts a hidden token field into every form that posts.
|
Command csrf inserts a hidden token field into every form that posts. |
|
gip/darkmode
command
Command darkmode adds the two things a page needs to respect a reader's colour preference: a theme-color meta per scheme, and a stylesheet link that only loads in dark mode.
|
Command darkmode adds the two things a page needs to respect a reader's colour preference: a theme-color meta per scheme, and a stylesheet link that only loads in dark mode. |
|
gip/decomment
command
Command decomment strips comments from a document without stripping the things that only look like comments.
|
Command decomment strips comments from a document without stripping the things that only look like comments. |
|
gip/deferscripts
command
Command deferscripts adds defer to the scripts that can take it.
|
Command deferscripts adds defer to the scripts that can take it. |
|
gip/deployid
command
Command deployid echoes a deploy identifier from the environment into a meta tag, and says when it could not put it somewhere a browser will read.
|
Command deployid echoes a deploy identifier from the environment into a meta tag, and says when it could not put it somewhere a browser will read. |
|
gip/deprecated
command
Command deprecated reports the obsolete elements and attributes a page still uses.
|
Command deprecated reports the obsolete elements and attributes a page still uses. |
|
gip/depth
command
Command depth enforces a maximum element nesting depth and reports the deepest path it found.
|
Command depth enforces a maximum element nesting depth and reports the deepest path it found. |
|
gip/descript
command
Command descript removes script elements and reports what that saved.
|
Command descript removes script elements and reports what that saved. |
|
gip/detached
command
Command detached shows what every rewritable unit answers after its handler has returned.
|
Command detached shows what every rewritable unit answers after its handler has returned. |
|
gip/dimensions
command
Command dimensions reports every image, iframe, video, embed and object that does not declare its size, which is what makes a page shift under the reader as it loads.
|
Command dimensions reports every image, iframe, video, embed and object that does not declare its size, which is what makes a page shift under the reader as it loads. |
|
gip/dir
command
Command dir adds dir="rtl" to the elements whose text reads right to left.
|
Command dir adds dir="rtl" to the elements whose text reads right to left. |
|
gip/doctypepick
command
Command doctypepick chooses what a rewrite does from the document's doctype, and only from a doctype the document's own parser will honour.
|
Command doctypepick chooses what a rewrite does from the document's doctype, and only from a doctype the document's own parser will honour. |
|
gip/dupsection
command
Command dupsection duplicates a section of a document, renaming the ids in the copy, without ever holding the whole document.
|
Command dupsection duplicates a section of a document, renaming the ids in the copy, without ever holding the whole document. |
|
gip/email
command
Command email prepares an HTML page for an email client: it inlines the stylesheet, makes every URL absolute, and removes what a mail client would refuse to run.
|
Command email prepares an HTML page for an email client: it inlines the stylesheet, makes every URL absolute, and removes what a mail client would refuse to run. |
|
gip/emailstrip
command
Command emailstrip removes everything a mail client would reject and says what it removed and why.
|
Command emailstrip removes everything a mail client would reject and says what it removed and why. |
|
gip/emoji
command
Command emoji expands :shortcodes: in a document's text.
|
Command emoji expands :shortcodes: in a document's text. |
|
gip/encodingmatrix
command
Command encodingmatrix runs a document through every encoding the rewriter accepts and compares what comes out.
|
Command encodingmatrix runs a document through every encoding the rewriter accepts and compares what comes out. |
|
gip/envbadge
command
Command envbadge marks a page as belonging to a non-production environment: a visible badge in the corner and a prefix on the browser tab.
|
Command envbadge marks a page as belonging to a non-production environment: a visible badge in the corner and a prefix on the browser tab. |
|
gip/esi
command
Command esi expands Edge Side Include markers two ways - with lolhtml.WithESITags and without it - and reports where the two disagree.
|
Command esi expands Edge Side Include markers two ways - with lolhtml.WithESITags and without it - and reports where the two disagree. |
|
gip/etag
command
Command etag computes an entity tag for a rewritten page, and does it without waiting for the page to be rewritten.
|
Command etag computes an entity tag for a rewritten page, and does it without waiting for the page to be rewritten. |
|
gip/firstlink
command
Command firstlink links each glossary term once, the first time it is mentioned, and leaves alone any term the page already links.
|
Command firstlink links each glossary term once, the first time it is mentioned, and leaves alone any term the page already links. |
|
gip/flags
command
Command flags gates blocks of markup on feature flags, removing what is off.
|
Command flags gates blocks of markup on feature flags, removing what is off. |
|
gip/fontpreload
command
Command fontpreload injects preload hints for the fonts a stylesheet uses.
|
Command fontpreload injects preload hints for the fonts a stylesheet uses. |
|
gip/formschema
command
Command formschema reads every form on a page and prints what it would take to submit it.
|
Command formschema reads every form on a page and prints what it would take to submit it. |
|
gip/glossary
command
Command glossary reads the definition lists in a document and links every mention of their terms in the body.
|
Command glossary reads the definition lists in a document and links every mention of their terms in the body. |
|
gip/greet
command
Command greet injects a personalised greeting taken from a request header into four kinds of place, and treats the header as what it is: a string an attacker chooses.
|
Command greet injects a personalised greeting taken from a request header into four kinds of place, and treats the header as what it is: a string an attacker chooses. |
|
gip/gunzip
command
Command gunzip rewrites a document that arrives gzipped, decompressing as it goes, and refuses to be surprised by the two things that go wrong.
|
Command gunzip rewrites a document that arrives gzipped, decompressing as it goes, and refuses to be surprised by the two things that go wrong. |
|
gip/gzipout
command
Command gzipout rewrites a document into a gzip writer and checks the round trip, because two Closers in a chain is a thing people get wrong in a way that costs bytes.
|
Command gzipout rewrites a document into a gzip writer and checks the round trip, because two Closers in a chain is a thing people get wrong in a way that costs bytes. |
|
gip/handlerstats
command
Command handlerstats reports how many times each registered handler fired.
|
Command handlerstats reports how many times each registered handler fired. |
|
gip/headings
command
Command headings reports headings that skip a level, and the other things a heading outline can get wrong.
|
Command headings reports headings that skip a level, and the other things a heading outline can get wrong. |
|
gip/headonly
command
Command headonly rewrites the head of a document and passes the body through without parsing it.
|
Command headonly rewrites the head of a document and passes the body through without parsing it. |
|
gip/highlight
command
Command highlight marks search terms in a document's text without ever injecting markup.
|
Command highlight marks search terms in a document's text without ever injecting markup. |
|
gip/histogram
command
Command histogram counts the elements in a document by tag name and prints them as a bar chart.
|
Command histogram counts the elements in a document by tag name and prints them as a bar chart. |
|
gip/hoiststyle
command
Command hoiststyle moves inline style attributes into a stylesheet.
|
Command hoiststyle moves inline style attributes into a stylesheet. |
|
gip/honeypot
command
Command honeypot adds a decoy field to every form, and reports where each one went.
|
Command honeypot adds a decoy field to every form, and reports where each one went. |
|
gip/hreflang
command
Command hreflang injects alternate-language links from a locale table.
|
Command hreflang injects alternate-language links from a locale table. |
|
gip/idempotent
command
Command idempotent runs a rewrite twice and reports whether the second pass changed anything.
|
Command idempotent runs a rewrite twice and reports whether the second pass changed anything. |
|
gip/idmerge
command
Command idmerge concatenates several documents into one and keeps every id unique, rewriting the references as well as the ids.
|
Command idmerge concatenates several documents into one and keeps every id unique, rewriting the references as well as the ids. |
|
gip/ids
command
Command ids reports duplicate id attributes, and the references they make ambiguous.
|
Command ids reports duplicate id attributes, and the references they make ambiguous. |
|
gip/imgcdn
command
Command imgcdn points a page's images at an image CDN, with a width and a format.
|
Command imgcdn points a page's images at an image CDN, with a width and a format. |
|
gip/importmap
command
Command importmap injects an import map before the first module script.
|
Command importmap injects an import map before the first module script. |
|
gip/include
command
Command include expands Edge Side Include markers into fetched content, streaming, without buffering either the page or what it pulls in.
|
Command include expands Edge Side Include markers into fetched content, streaming, without buffering either the page or what it pulls in. |
|
gip/inlinesvg
command
Command inlinesvg replaces small SVG images with the file's own markup, so that CSS can style them and the page can stop asking for them.
|
Command inlinesvg replaces small SVG images with the file's own markup, so that CSS can style them and the page can stop asking for them. |
|
gip/inputtype
command
Command inputtype upgrades generic text inputs to email, tel and url where the field's name says what it holds - and, more often, declines to.
|
Command inputtype upgrades generic text inputs to email, tel and url where the field's name says what it holds - and, more often, declines to. |
|
gip/inventory
command
Command inventory lists the custom elements a page uses and says which of them nothing defines.
|
Command inventory lists the custom elements a page uses and says which of them nothing defines. |
|
gip/islands
command
Command islands annotates the interactive regions of a page for partial hydration: which ones there are, which are inside which, and what each one needs to hydrate.
|
Command islands annotates the interactive regions of a page for partial hydration: which ones there are, which are inside which, and what each one needs to hydrate. |
|
gip/jsonld
command
Command jsonld extracts every JSON-LD block from a document and reports what is wrong with each one.
|
Command jsonld extracts every JSON-LD block from a document and reports what is wrong with each one. |
|
gip/keywords
command
Command keywords counts word frequencies in a document's own content, leaving out the parts every page on the site shares.
|
Command keywords counts word frequencies in a document's own content, leaving out the parts every page on the site shares. |
|
gip/labels
command
Command labels reports form controls with no label, and labels that point at nothing.
|
Command labels reports form controls with no label, and labels that point at nothing. |
|
gip/landmarks
command
Command landmarks adds ARIA landmark roles to a document that has none.
|
Command landmarks adds ARIA landmark roles to a document that has none. |
|
gip/lang
command
Command lang adds a lang attribute to the elements whose own text is in a script other than the document's.
|
Command lang adds a lang attribute to the elements whose own text is in a script other than the document's. |
|
gip/lazyload
command
Command lazyload defers off-screen images and iframes.
|
Command lazyload defers off-screen images and iframes. |
|
gip/linkify
command
Command linkify turns bare URLs in a document's text into links, without breaking the links it already has.
|
Command linkify turns bare URLs in a document's text into links, without breaking the links it already has. |
|
gip/linkreport
command
Command linkreport collects every anchor in a document with its target and text, and reports the ones a reader or a screen reader would struggle with.
|
Command linkreport collects every anchor in a document with its target and text, and reports the ones a reader or a screen reader would struggle with. |
|
gip/linktext
command
Command linktext finds links whose text says nothing about where they go, and either flags or fixes them.
|
Command linktext finds links whose text says nothing about where they go, and either flags or fixes them. |
|
gip/localise
command
Command localise formats dates, numbers, currencies and percentages that a document has marked, in the locale a request asked for.
|
Command localise formats dates, numbers, currencies and percentages that a document has marked, in the locale a request asked for. |
|
gip/locate
command
Command locate reports the source location of every match and proves each report by slicing the caller's own copy of the input.
|
Command locate reports the source location of every match and proves each report by slicing the caller's own copy of the input. |
|
gip/mapembed
command
Command mapembed replaces interactive map embeds with a static image and a link, so a page that shows a map does not load a map SDK to do it.
|
Command mapembed replaces interactive map embeds with a static image and a link, so a page that shows a map does not load a map SDK to do it. |
|
gip/markdown
command
Command markdown converts the subset of HTML it understands to Markdown, and says what it dropped.
|
Command markdown converts the subset of HTML it understands to Markdown, and says what it dropped. |
|
gip/mentions
command
Command mentions turns @names and #tags in a document's text into links.
|
Command mentions turns @names and #tags in a document's text into links. |
|
gip/microdata
command
Command microdata extracts HTML microdata into a flat key-value report.
|
Command microdata extracts HTML microdata into a flat key-value report. |
|
gip/middleware
command
Command middleware wraps an http.Handler so that its HTML output is rewritten on the way to the client, without giving up streaming.
|
Command middleware wraps an http.Handler so that its HTML output is rewritten on the way to the client, without giving up streaming. |
|
gip/minifydiff
command
Command minifydiff minifies a document conservatively and then proves it did no harm, by parsing the input and the output and diffing what a parser sees.
|
Command minifydiff minifies a document conservatively and then proves it did no harm, by parsing the input and the output and diffing what a parser sees. |
|
gip/mixed
command
Command mixed finds mixed content on an https page and, told to, refuses the page.
|
Command mixed finds mixed content on an https page and, told to, refuses the page. |
|
gip/modernise
command
Command modernise replaces obsolete elements with the markup that means the same thing, moving their presentation into classes.
|
Command modernise replaces obsolete elements with the markup that means the same thing, moving their presentation into classes. |
|
gip/modulesplit
command
Command modulesplit turns a classic script into a module-and-fallback pair.
|
Command modulesplit turns a classic script into a module-and-fallback pair. |
|
gip/mojibake
command
Command mojibake finds text that has been decoded with the wrong encoding, and says which one it probably was.
|
Command mojibake finds text that has been decoded with the wrong encoding, and says which one it probably was. |
|
gip/multipart
command
Command multipart rewrites the HTML parts of a multipart body and passes everything else through byte for byte.
|
Command multipart rewrites the HTML parts of a multipart body and passes everything else through byte for byte. |
|
gip/needsrewrite
command
Command needsrewrite decides whether a document is worth rewriting before rewriting it, which is the question a proxy asks thousands of times a second and mostly answers "no".
|
Command needsrewrite decides whether a document is worth rewriting before rewriting it, which is the question a proxy asks thousands of times a second and mostly answers "no". |
|
gip/noevents
command
Command noevents removes the ways markup can execute script: inline event handler attributes, and javascript: URLs.
|
Command noevents removes the ways markup can execute script: inline event handler attributes, and javascript: URLs. |
|
gip/noindex
command
Command noindex adds a robots meta to pages whose path matches a pattern, so a staging host, a print view or a search-results page is not indexed.
|
Command noindex adds a robots meta to pages whose path matches a pattern, so a staging host, a print view or a search-results page is not indexed. |
|
gip/noopener
command
Command noopener hardens links that open a new browsing context.
|
Command noopener hardens links that open a new browsing context. |
|
gip/numbering
command
Command numbering prefixes each heading with its position in the document's outline: 1, 1.1, 1.2, 2, and so on.
|
Command numbering prefixes each heading with its position in the document's outline: 1, 1.1, 1.2, 2, and so on. |
|
gip/observe
command
Command observe reads a document, reports what is in it, and proves it changed nothing.
|
Command observe reads a document, reports what is in it, and proves it changed nothing. |
|
gip/ogcompute
command
Command ogcompute fills in missing Open Graph tags from the page's own first heading and first image.
|
Command ogcompute fills in missing Open Graph tags from the page's own first heading and first image. |
|
gip/origins
command
Command origins reports every origin a page would contact, and what asked for it.
|
Command origins reports every origin a page would contact, and what asked for it. |
|
gip/pagenav
command
Command pagenav adds rel=next and rel=prev link elements from a page's own pagination markup.
|
Command pagenav adds rel=next and rel=prev link elements from a page's own pagination markup. |
|
gip/panics
command
Command panics shows where a handler panic comes out, and what is left afterwards.
|
Command panics shows where a handler panic comes out, and what is left afterwards. |
|
gip/passthrough
command
Command passthrough checks that a rewrite which changes nothing changes nothing.
|
Command passthrough checks that a rewrite which changes nothing changes nothing. |
|
gip/pipeline
command
Command pipeline runs a document through two rewriters, the output of the first being the input of the second, and shows what that buys over doing both in one pass.
|
Command pipeline runs a document through two rewriters, the output of the first being the input of the second, and shows what that buys over doing both in one pass. |
|
gip/placeholders
command
Command placeholders resolves handlebars-style {{ name }} placeholders, choosing the escape by where the placeholder sits, and refuses the positions where no escape is enough.
|
Command placeholders resolves handlebars-style {{ name }} placeholders, choosing the escape by where the placeholder sits, and refuses the positions where no escape is enough. |
|
gip/plaintext
command
Command plaintext converts a document to text, keeping the block structure as blank lines and newlines.
|
Command plaintext converts a document to text, keeping the block structure as blank lines and newlines. |
|
gip/poisoned
command
Command poisoned walks the ways a rewrite can fail and prints what each call returns afterwards.
|
Command poisoned walks the ways a rewrite can fail and prints what each call returns afterwards. |
|
gip/preconnect
command
Command preconnect adds resource hints for the third-party origins a page actually uses.
|
Command preconnect adds resource hints for the third-party origins a page actually uses. |
|
gip/preserve
command
Command preserve runs a set of rewrites over a document and says which of them left it alone.
|
Command preserve runs a set of rewrites over a document and says which of them left it alone. |
|
gip/printstyles
command
Command printstyles makes a page printable: a print stylesheet in the head, and a hint before every h2 so a section does not start at the bottom of a page.
|
Command printstyles makes a page printable: a print stylesheet in the head, and a hint before every h2 so a section does not start at the bottom of a page. |
|
gip/proxy
command
Command proxy rewrites HTML response bodies in a reverse proxy, and skips the ones it must not touch.
|
Command proxy rewrites HTML response bodies in a reverse proxy, and skips the ones it must not touch. |
|
gip/queue
command
Command queue runs a rewriter per goroutine over a queue of documents, checks that no worker sees another's work, and says how much of the time went on building rewriters rather than on rewriting.
|
Command queue runs a rewriter per goroutine over a queue of documents, checks that no worker sees another's work, and says how much of the time went on building rewriters rather than on rewriting. |
|
gip/readingtime
command
Command readingtime counts the words in a document and estimates how long it takes to read, ignoring anything that is not prose.
|
Command readingtime counts the words in a document and estimates how long it takes to read, ignoring anything that is not prose. |
|
gip/rebase
command
Command rebase rewrites a <base> element away by resolving the URLs it affected.
|
Command rebase rewrites a <base> element away by resolving the URLs it affected. |
|
gip/redact
command
Command redact removes email addresses and phone numbers from a document's text and from its attributes.
|
Command redact removes email addresses and phone numbers from a document's text and from its attributes. |
|
gip/reencode
command
Command reencode converts a document from a single-byte legacy encoding to UTF-8 and proves the text survived.
|
Command reencode converts a document from a single-byte legacy encoding to UTF-8 and proves the text survived. |
|
gip/references
command
Command references decodes the character references a document did not need.
|
Command references decodes the character references a document did not need. |
|
gip/regions
command
Command regions applies a different set of handlers to each region of one document, split at offsets the caller gives, and refuses a split that would change what the document means.
|
Command regions applies a different set of handlers to each region of one document, split at offsets the caller gives, and refuses a split that would change what the document means. |
|
gip/rollinghash
command
Command rollinghash digests the rewritten output as it streams, without holding it.
|
Command rollinghash digests the rewritten output as it streams, without holding it. |
|
gip/sandbox
command
Command sandbox hardens third-party iframes: it adds a sandbox attribute and a referrer policy, and reports the sandboxes that do not sandbox anything.
|
Command sandbox hardens third-party iframes: it adds a sandbox attribute and a referrer policy, and reports the sandboxes that do not sandbox anything. |
|
gip/scrollwrap
command
Command scrollwrap puts a scroll container around the elements that overflow a narrow screen.
|
Command scrollwrap puts a scroll container around the elements that overflow a narrow screen. |
|
gip/selectorcheck
command
Command selectorcheck reports every selector a rewrite cannot use, before the rewrite starts.
|
Command selectorcheck reports every selector a rewrite cannot use, before the rewrite starts. |
|
gip/selectorcoverage
command
Command selectorcoverage reports which of a stylesheet's selectors never match a document.
|
Command selectorcoverage reports which of a stylesheet's selectors never match a document. |
|
gip/servertiming
command
Command servertiming times a rewrite and writes what it measured into the document it rewrote, as a Server-Timing comment at the end.
|
Command servertiming times a rewrite and writes what it measured into the document it rewrote, as a Server-Timing comment at the end. |
|
gip/shadow
command
Command shadow gives every custom element a declarative shadow root, and gives it exactly once, so the same page can go through twice without gaining two.
|
Command shadow gives every custom element a declarative shadow root, and gives it exactly once, so the same page can go through twice without gaining two. |
|
gip/shard
command
Command shard spreads a page's asset URLs across a set of hostnames, always putting the same asset on the same one.
|
Command shard spreads a page's asset URLs across a set of hostnames, always putting the same asset on the same one. |
|
gip/shrink
command
Command shrink reduces a failing document to its essence: the smallest input it can find that still fails the same way.
|
Command shrink reduces a failing document to its essence: the smallest input it can find that still fails the same way. |
|
gip/sizebudget
command
Command sizebudget streams HTML through a rewrite under a size budget, and stops as soon as the budget is spent.
|
Command sizebudget streams HTML through a rewrite under a size budget, and stops as soon as the budget is spent. |
|
gip/slots
command
Command slots fills named slots in a template with supplied fragments.
|
Command slots fills named slots in a template with supplied fragments. |
|
gip/slugs
command
Command slugs assigns stable id attributes to a document's headings.
|
Command slugs assigns stable id attributes to a document's headings. |
|
gip/socialmeta
command
Command socialmeta reports the Open Graph and Twitter card metadata a page carries, and what is missing for a link to it to render well.
|
Command socialmeta reports the Open Graph and Twitter card metadata a page carries, and what is missing for a link to it to render well. |
|
gip/split
command
Command split cuts a document into parts at a chosen heading level, and makes each part stand on its own.
|
Command split cuts a document into parts at a chosen heading level, and makes each part stand on its own. |
|
gip/sprite
command
Command sprite injects an SVG sprite once and points the page's icons at it.
|
Command sprite injects an SVG sprite once and points the page's icons at it. |
|
gip/srcset
command
Command srcset builds a responsive srcset for every image from its src, a width list and an image CDN template.
|
Command srcset builds a responsive srcset for every image from its src, a width list and an image CDN template. |
|
gip/sri
command
Command sri adds subresource integrity attributes to a document's scripts and stylesheets, from a manifest of hashes, and reports every subresource the manifest does not cover.
|
Command sri adds subresource integrity attributes to a document's scripts and stylesheets, from a manifest of hashes, and reports every subresource the manifest does not cover. |
|
gip/stopafter
command
Command stopafter rewrites a document until it meets a marker and copies everything after it.
|
Command stopafter rewrites a document until it meets a marker and copies everything after it. |
|
gip/stopwhen
command
Command stopwhen rewrites a stream that never ends and stops when it has what it came for.
|
Command stopwhen rewrites a stream that never ends and stops when it has what it came for. |
|
gip/streamvsmemory
command
Command streamvsmemory runs the same rewrite twice - once in memory, once streamed - and reports what differs.
|
Command streamvsmemory runs the same rewrite twice - once in memory, once streamed - and reports what differs. |
|
gip/strictmode
command
Command strictmode shows what each of the two parsing modes makes of a document.
|
Command strictmode shows what each of the two parsing modes makes of a document. |
|
gip/summary
command
Command summary extracts a page's summary and stops reading as soon as it has one.
|
Command summary extracts a page's summary and stops reading as soon as it has one. |
|
gip/tableaudit
command
Command tableaudit reports tables whose header cells cannot be associated with their data.
|
Command tableaudit reports tables whose header cells cannot be associated with their data. |
|
gip/tablecsv
command
Command tablecsv extracts every table in a document as CSV, expanding colspan and rowspan so every row has the same number of fields.
|
Command tablecsv extracts every table in a document as CSV, expanding colspan and rowspan so every row has the same number of fields. |
|
gip/tablejson
command
Command tablejson converts each table in a document to JSON, one object per row, keyed by the header cells.
|
Command tablejson converts each table in a document to JSON, one object per row, keyed by the header cells. |
|
gip/tablelayout
command
Command tablelayout converts a div-based page into the table markup an email client will render, and refuses the conversions whose result would depend on the document's doctype.
|
Command tablelayout converts a div-based page into the table markup an email client will render, and refuses the conversions whose result would depend on the document's doctype. |
|
gip/tailcomment
command
Command tailcomment emits a summary of what a rewrite changed as a trailing HTML comment, which is the shape a build stamp or a debug trace usually takes: invisible in the page, there in the source.
|
Command tailcomment emits a summary of what a rewrite changed as a trailing HTML comment, which is the shape a build stamp or a debug trace usually takes: invisible in the page, there in the source. |
|
gip/tailreport
command
Command tailreport appends a generated report to the end of every document it rewrites, and it is here because the obvious way to do that holds the whole report in memory.
|
Command tailreport appends a generated report to the end of every document it rewrites, and it is here because the obvious way to do that holds the whole report in memory. |
|
gip/tee
command
Command tee streams a document to two destinations at once - one rewritten, one exactly as it arrived - from a single read of the input, and reports how far apart they ran.
|
Command tee streams a document to two destinations at once - one rewritten, one exactly as it arrived - from a single read of the input, and reports how far apart they ran. |
|
gip/textmap
command
Command textmap reports the source location of every text chunk and reconstructs the document from what it was told.
|
Command textmap reports the source location of every text chunk and reconstructs the document from what it was told. |
|
gip/texttruth
command
Command texttruth reconstructs a document's text - the characters, not the source bytes - from the text chunks a rewrite reports, and it exists because the two are not the same thing and the difference is four rules with three different element lists.
|
Command texttruth reconstructs a document's text - the characters, not the source bytes - from the text chunks a rewrite reports, and it exists because the two are not the same thing and the difference is four rules with three different element lists. |
|
gip/toc
command
Command toc builds a table of contents from a document's headings and inserts it at a marker.
|
Command toc builds a table of contents from a document's headings and inserts it at a marker. |
|
gip/transitions
command
Command transitions gives view-transition names to the elements that appear on both of two pages, so a browser can animate between them.
|
Command transitions gives view-transition names to the elements that appear on both of two pages, so a browser can animate between them. |
|
gip/translate
command
Command translate adds translate="no" to the elements whose text is not prose, so a machine translator leaves them alone.
|
Command translate adds translate="no" to the elements whose text is not prose, so a machine translator leaves them alone. |
|
gip/tweetquote
command
Command tweetquote rewrites embedded tweets into plain blockquotes with attribution, so a page that quotes someone does not load a third-party script to show the quote.
|
Command tweetquote rewrites embedded tweets into plain blockquotes with attribution, so a page that quotes someone does not load a third-party script to show the quote. |
|
gip/twoways
command
Command twoways runs two rewriters over the same document at the same time: one that transforms it and one that only reports on it.
|
Command twoways runs two rewriters over the same document at the same time: one that transforms it and one that only reports on it. |
|
gip/typography
command
Command typography applies typographic quotes and dashes to a document's prose, and leaves code alone.
|
Command typography applies typographic quotes and dashes to a document's prose, and leaves code alone. |
|
gip/unbounded
command
Command unbounded rewrites a document larger than any buffer worth holding, and says which handler patterns keep the memory flat.
|
Command unbounded rewrites a document larger than any buffer worth holding, and says which handler patterns keep the memory flat. |
|
gip/units
command
Command units converts imperial quantities in prose to metric, wrapping each conversion in a span whose title keeps what the page said.
|
Command units converts imperial quantities in prose to metric, wrapping each conversion in a span whose title keeps what the page said. |
|
gip/untrack
command
Command untrack removes tracking from an HTML document as it streams past: tracking parameters from every URL, and tracking pixels from the markup.
|
Command untrack removes tracking from an HTML document as it streams past: tracking parameters from every URL, and tracking pixels from the markup. |
|
gip/upgrade
command
Command upgrade rewrites http:// subresources to https:// as a document streams past, and reports what it changed and what it could not.
|
Command upgrade rewrites http:// subresources to https:// as a document streams past, and reports what it changed and what it could not. |
|
gip/viewport
command
Command viewport fixes a missing or harmful viewport meta tag.
|
Command viewport fixes a missing or harmful viewport meta tag. |
|
gip/weight
command
Command weight reports the total byte weight of the scripts, styles and images a page references.
|
Command weight reports the total byte weight of the scripts, styles and images a page references. |
|
gip/widgets
command
Command widgets turns legacy widget markup into web component markup: a container becomes a custom element, the state it kept in classes and data attributes becomes properties, and the parts it kept in nested divs become slots.
|
Command widgets turns legacy widget markup into web component markup: a container becomes a custom element, the state it kept in classes and data attributes becomes properties, and the parts it kept in nested divs become slots. |
|
gip/widows
command
Command widows joins the last two words of every heading with a non-breaking space, so a heading cannot wrap with one word alone on the last line.
|
Command widows joins the last two words of every heading with a non-breaking space, so a heading cannot wrap with one word alone on the last line. |
|
gip/worstshape
command
Command worstshape finds the document shape a handler set is slowest on, by running the same handlers over documents of the same size in different shapes and ranking them.
|
Command worstshape finds the document shape a handler set is slowest on, by running the same handlers over documents of the same size in different shapes and ranking them. |
|
gip/ytembed
command
Command ytembed replaces YouTube embeds with a thumbnail that loads the player on click, so a page does not fetch several hundred kilobytes of player from a third party before the reader asks for it.
|
Command ytembed replaces YouTube embeds with a thumbnail that loads the player on click, so a page does not fetch several hundred kilobytes of player from a third party before the reader asks for it. |
|
rewrite-url
command
Command rewrite-url streams a page through lol-html, rewriting relative links to absolute ones and reporting what it changed.
|
Command rewrite-url streams a page through lol-html, rewriting relative links to absolute ones and reporting what it changed. |