agent-sanitizer 2.64.1 → 2.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-THIRD-PARTY +4 -3
- package/THREAT-MODEL.md +23 -5
- package/package.json +4 -1
- package/src/gfm.mjs +3 -2
- package/src/html-tree-adapter.mjs +48 -2
- package/src/html.mjs +104 -19
- package/src/vendor/gfm-autolink-literal.mjs +53 -178
- package/types/html-tree-adapter.d.mts +4 -1
package/LICENSE-THIRD-PARTY
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Third-party code redistributed in this package.
|
|
2
2
|
|
|
3
|
-
src/vendor/gfm-autolink-literal.mjs
|
|
4
|
-
|
|
5
|
-
function changed (see
|
|
3
|
+
src/vendor/gfm-autolink-literal.mjs (generated, not tracked)
|
|
4
|
+
Generated at install and pack time from micromark-extension-gfm-autolink-literal@2.1.0
|
|
5
|
+
with one function changed by scripts/patches/ (see scripts/gen-gfm-autolink.mjs).
|
|
6
|
+
Original licence follows.
|
|
6
7
|
|
|
7
8
|
(The MIT License)
|
|
8
9
|
|
package/THREAT-MODEL.md
CHANGED
|
@@ -122,8 +122,23 @@ range is preserved verbatim** (no re-serialization). Unclosed hidden markup
|
|
|
122
122
|
extends to the end of the fragment—fail-closed for truncated input.
|
|
123
123
|
|
|
124
124
|
Scripting/resource tags (`script`, `style`, `object`, `embed`, `iframe`, `svg`,
|
|
125
|
-
`math`) and `data:` URI resources are **reported, not removed**:
|
|
126
|
-
page source the model may legitimately need to inspect.
|
|
125
|
+
`math`, `template`) and `data:` URI resources are **reported, not removed**:
|
|
126
|
+
their bodies are page source the model may legitimately need to inspect.
|
|
127
|
+
|
|
128
|
+
A `<template>`'s content is a fragment the browser parses but never renders, so
|
|
129
|
+
instructions placed there reach a model reading the source and no human reading
|
|
130
|
+
the page. It is also where web components and client-side renderers keep their
|
|
131
|
+
UI markup, so the template is reported rather than spliced, and its content is
|
|
132
|
+
scanned like any other markup: a hidden element or comment inside it is
|
|
133
|
+
spliced, a `<script>` inside it reported, a URL inside it read by Layer 3. A
|
|
134
|
+
declarative shadow root (`shadowrootmode="open"`/`"closed"`) is reported too,
|
|
135
|
+
since one the parser fails to attach (a second root on one host, say) is an
|
|
136
|
+
ordinary inert template; its content is laid out in its host's box, so a flex
|
|
137
|
+
host blockifies it as it would a light-DOM child. Slot assignment is not
|
|
138
|
+
modelled: a hidden `<slot>` splices only itself, not the light-DOM children
|
|
139
|
+
the browser renders through it. A `template` inside SVG or MathML (or an HTML
|
|
140
|
+
`template.x`) is not reported: it has no template content, so its children are
|
|
141
|
+
ordinary children and are scanned as such.
|
|
127
142
|
|
|
128
143
|
HTML comments (`<!--…-->`, and the bogus `<!…>`/`<?…?>` forms) are spliced
|
|
129
144
|
like hidden elements — a human viewing the rendered page never sees them. But
|
|
@@ -146,9 +161,12 @@ Report—never rewrite—URLs in markdown links/images/definitions, every HTML/S
|
|
|
146
161
|
URL attribute in `src/url-attributes.mjs` (`src`, `href`, `xlink:href`,
|
|
147
162
|
`srcset`/`imagesrcset`, `ping`, `poster`, `object data`, form
|
|
148
163
|
`action`/`formaction`, `meta refresh`, …), CSS `url()` in `style` attributes and
|
|
149
|
-
`<style>` elements, and the same again inside HTML comments
|
|
150
|
-
carry data off-origin. A URL
|
|
151
|
-
|
|
164
|
+
`<style>` elements, and the same again inside HTML comments and `<template>`
|
|
165
|
+
content, that are shaped to carry data off-origin. A URL inside a comment or an
|
|
166
|
+
inert template is reported as not auto-fetched: nothing there loads until a
|
|
167
|
+
reader or a script acts on it. A URL's scheme is read by the WHATWG URL parser,
|
|
168
|
+
and `srcset` and `meta refresh` by ports of the HTML Standard's own parsing
|
|
169
|
+
steps:
|
|
152
170
|
|
|
153
171
|
- suspicious query/fragment parameters (long base64/hex blobs, credential-shaped
|
|
154
172
|
tokens), tuned to skip request-signing / pagination / analytics parameters
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.65.0",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -58,12 +58,14 @@
|
|
|
58
58
|
"agent-sanitizer": "link:.",
|
|
59
59
|
"bun": "1.3.11",
|
|
60
60
|
"c8": "11.0.0",
|
|
61
|
+
"diff": "9.0.0",
|
|
61
62
|
"esbuild": "0.28.1",
|
|
62
63
|
"eslint": "10.4.0",
|
|
63
64
|
"eslint-plugin-redos": "4.5.0",
|
|
64
65
|
"fast-check": "4.8.0",
|
|
65
66
|
"globals": "17.6.0",
|
|
66
67
|
"lint-staged": "^17.0.5",
|
|
68
|
+
"micromark-extension-gfm-autolink-literal": "2.1.0",
|
|
67
69
|
"prettier": "^3.0.0",
|
|
68
70
|
"rehype-parse": "9.0.1",
|
|
69
71
|
"remark-gfm": "4.0.1",
|
|
@@ -249,6 +251,7 @@
|
|
|
249
251
|
"build:types": "tsc -p tsconfig.build.json && tsc -p tsconfig.build-hooks.json",
|
|
250
252
|
"gen:joining-type": "node scripts/gen-joining-type.mjs",
|
|
251
253
|
"gen:unicode": "node scripts/gen-unicode.mjs",
|
|
254
|
+
"gen:gfm-autolink": "node scripts/gen-gfm-autolink.mjs",
|
|
252
255
|
"gen:unicode-properties": "node scripts/gen-unicode-properties.mjs",
|
|
253
256
|
"gen:fail-open-lib": "node scripts/gen-fail-open-lib.mjs",
|
|
254
257
|
"gen:node-floor-lib": "node scripts/gen-node-floor-lib.mjs",
|
package/src/gfm.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* This is `remark-gfm@4.0.1`'s own plugin body and
|
|
5
5
|
* `micromark-extension-gfm@3.0.0`'s own `gfm()` composition, inlined so that
|
|
6
6
|
* `gfmAutolinkLiteral` can come from `./vendor/gfm-autolink-literal.mjs`
|
|
7
|
-
* instead of the published package
|
|
8
|
-
*
|
|
7
|
+
* instead of the published package. That module is generated, not committed:
|
|
8
|
+
* `scripts/gen-gfm-autolink.mjs` patches the pinned upstream at install time,
|
|
9
|
+
* and its header says what differs and when to delete both. Every other extension, and the mdast layer, are the upstream
|
|
9
10
|
* ones at the versions `remark-gfm` pins.
|
|
10
11
|
*
|
|
11
12
|
* Assembling the list here rather than adding an extension beside `remark-gfm`
|
|
@@ -168,6 +168,39 @@ export const CONVERT_DEPTH = 128;
|
|
|
168
168
|
*/
|
|
169
169
|
export const MAX_HTML_DEPTH = 4096;
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Which elements `hast-util-from-parse5` converts as a template: hastscript reads
|
|
173
|
+
* the tag name as a selector (`template.x` is a `template` with class `x`), so
|
|
174
|
+
* the name up to its first `#` or `.`, lowercased.
|
|
175
|
+
*
|
|
176
|
+
* WHY IT MATTERS — the converter then recurses into the parse5 node's `content`,
|
|
177
|
+
* which only an HTML-namespace `<template>` has. An `<svg><template>`, a
|
|
178
|
+
* `<math><template>` or an HTML `<template.x>` has none, the converter throws,
|
|
179
|
+
* and the whole document would be withheld over a benign element.
|
|
180
|
+
*/
|
|
181
|
+
const CONVERTS_AS_TEMPLATE = /^template(?:[#.]|$)/i;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Prefixed to such an element's name for the conversion, and stripped after.
|
|
185
|
+
*
|
|
186
|
+
* A space, because no parsed element's name can hold one (the tokenizer ends a
|
|
187
|
+
* tag name at whitespace), so a masked name collides with nothing, and the
|
|
188
|
+
* converter neither drops it (its selector parse splits on `#` and `.` only) nor
|
|
189
|
+
* changes it (lowercasing, the SVG case map). The element then converts the way
|
|
190
|
+
* any element without template content does: its children as `children`, where
|
|
191
|
+
* every scan of the hast tree reads them, which is also where the DOM has them.
|
|
192
|
+
*
|
|
193
|
+
* WHY A MASK, and not an empty parse5 `content` handed to the element so the
|
|
194
|
+
* converter has a fragment to recurse into: hastscript's `h` and `s` (the
|
|
195
|
+
* converter builds every element with one of them) move the children of ANY
|
|
196
|
+
* element they name `template` into the hast node's `content` and leave its
|
|
197
|
+
* `children` empty, and the converter then overwrites that `content` with its
|
|
198
|
+
* conversion of the parse5 fragment. Handed an empty one, the element's
|
|
199
|
+
* children would vanish with no error, and every Layer 2/3 scan in
|
|
200
|
+
* `./html.mjs` would go blind to them.
|
|
201
|
+
*/
|
|
202
|
+
const TEMPLATE_MASK = " ";
|
|
203
|
+
|
|
171
204
|
/**
|
|
172
205
|
* `fromParse5(tree, { file })`, recursing at most `chunkDepth` levels at a time.
|
|
173
206
|
*
|
|
@@ -177,7 +210,10 @@ export const MAX_HTML_DEPTH = 4096;
|
|
|
177
210
|
* child its parent's conversion reads (an unclosed element ends where its last
|
|
178
211
|
* child does). One walk then swaps each placeholder for its subtree. The result
|
|
179
212
|
* is the tree a single `fromParse5` call returns, which
|
|
180
|
-
* `html-tree-adapter.test` holds it to
|
|
213
|
+
* `html-tree-adapter.test` holds it to — except that an element the converter
|
|
214
|
+
* would take for a template with no content to take (see
|
|
215
|
+
* {@link CONVERTS_AS_TEMPLATE}) converts as an ordinary element instead of
|
|
216
|
+
* throwing.
|
|
181
217
|
*
|
|
182
218
|
* Mutates `tree`: {@link parseHtmlFragment} owns the parse and drops it after.
|
|
183
219
|
* @param {any} tree
|
|
@@ -189,6 +225,7 @@ export function fromParse5Bounded(tree, file, chunkDepth = CONVERT_DEPTH) {
|
|
|
189
225
|
/** @type {Array<{ siblings: any[], index: number }>} */
|
|
190
226
|
const cuts = [];
|
|
191
227
|
const stack = [{ node: tree, depth: 0 }];
|
|
228
|
+
let masked = false;
|
|
192
229
|
while (stack.length > 0) {
|
|
193
230
|
const { node, depth } = /** @type {{ node: any, depth: number }} */ (
|
|
194
231
|
stack.pop()
|
|
@@ -197,6 +234,13 @@ export function fromParse5Bounded(tree, file, chunkDepth = CONVERT_DEPTH) {
|
|
|
197
234
|
throw new RangeError(`HTML nested deeper than ${MAX_HTML_DEPTH} levels`);
|
|
198
235
|
// A template's content is a root its template's conversion recurses into.
|
|
199
236
|
if (node.content) stack.push({ node: node.content, depth: depth + 1 });
|
|
237
|
+
else if (
|
|
238
|
+
node.tagName !== undefined &&
|
|
239
|
+
CONVERTS_AS_TEMPLATE.test(node.tagName)
|
|
240
|
+
) {
|
|
241
|
+
node.tagName = TEMPLATE_MASK + node.tagName;
|
|
242
|
+
masked = true;
|
|
243
|
+
}
|
|
200
244
|
const siblings = node.childNodes ?? [];
|
|
201
245
|
const childDepth = depth + 1;
|
|
202
246
|
for (let index = 0; index < siblings.length; index++) {
|
|
@@ -222,8 +266,10 @@ export function fromParse5Bounded(tree, file, chunkDepth = CONVERT_DEPTH) {
|
|
|
222
266
|
const root = fromParse5(tree, { file });
|
|
223
267
|
/** @type {any[]} */
|
|
224
268
|
const pending = [root];
|
|
225
|
-
while (grafts.size > 0 && pending.length > 0) {
|
|
269
|
+
while ((grafts.size > 0 || masked) && pending.length > 0) {
|
|
226
270
|
const node = pending.pop();
|
|
271
|
+
if (masked && node.tagName?.startsWith(TEMPLATE_MASK))
|
|
272
|
+
node.tagName = node.tagName.slice(TEMPLATE_MASK.length);
|
|
227
273
|
if (node.content) pending.push(node.content);
|
|
228
274
|
const children = node.children ?? [];
|
|
229
275
|
for (let i = 0; i < children.length; i++) {
|
package/src/html.mjs
CHANGED
|
@@ -1502,7 +1502,13 @@ export function isHiddenStyle(styleStr) {
|
|
|
1502
1502
|
// Scripting / resource-loading tags whose PRESENCE is reported to the model
|
|
1503
1503
|
// but whose content is preserved: their bodies are page source the model may
|
|
1504
1504
|
// legitimately need to inspect (how a page's scripts work, its styles, its
|
|
1505
|
-
// SVGs), so unlike hidden elements they are never removed.
|
|
1505
|
+
// SVGs), so unlike hidden elements they are never removed. An HTML `template`
|
|
1506
|
+
// is here because the browser parses its content into a fragment it never
|
|
1507
|
+
// renders — a reader of the source sees it, a reader of the page does not —
|
|
1508
|
+
// while that content is also where a component's UI markup legitimately lives.
|
|
1509
|
+
// A declarative shadow root is reported too: whether the parser attaches one
|
|
1510
|
+
// depends on its host's name and on the host having no earlier root, and a
|
|
1511
|
+
// root that fails to attach is an ordinary inert template.
|
|
1506
1512
|
export const REPORTED_TAGS = new Set([
|
|
1507
1513
|
"script",
|
|
1508
1514
|
"style",
|
|
@@ -1511,8 +1517,34 @@ export const REPORTED_TAGS = new Set([
|
|
|
1511
1517
|
"iframe",
|
|
1512
1518
|
"svg",
|
|
1513
1519
|
"math",
|
|
1520
|
+
"template",
|
|
1514
1521
|
]);
|
|
1515
1522
|
|
|
1523
|
+
/**
|
|
1524
|
+
* True for a template whose content the page never renders: every one but a
|
|
1525
|
+
* declarative shadow root (`shadowrootmode` of `open` or `closed`), whose
|
|
1526
|
+
* content the browser's parser attaches to the parent as a shadow tree.
|
|
1527
|
+
* @param {any} node a hast element with template content (a `content` root)
|
|
1528
|
+
* @returns {boolean}
|
|
1529
|
+
*/
|
|
1530
|
+
function isInertTemplate(node) {
|
|
1531
|
+
const mode = String(node.properties.shadowRootMode ?? "").toLowerCase();
|
|
1532
|
+
return mode !== "open" && mode !== "closed";
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* True when Layer 2 reports `node`'s presence (see {@link REPORTED_TAGS}).
|
|
1537
|
+
* Only an HTML `template` has template content; one inside SVG or MathML, or a
|
|
1538
|
+
* `template.x`, is an ordinary element whose children are scanned as children.
|
|
1539
|
+
* @param {any} node a hast element
|
|
1540
|
+
* @returns {boolean}
|
|
1541
|
+
*/
|
|
1542
|
+
function isReportedElement(node) {
|
|
1543
|
+
return node.tagName === "template"
|
|
1544
|
+
? node.content !== undefined
|
|
1545
|
+
: REPORTED_TAGS.has(node.tagName);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1516
1548
|
// HTML void elements: they never carry content and never emit a closing tag, so
|
|
1517
1549
|
// a hidden one (<img hidden>, <input hidden>, …) must be spliced as a single node
|
|
1518
1550
|
// — opening a balance region for it would run to the container's end (no close
|
|
@@ -1629,8 +1661,27 @@ function elementCascade(node) {
|
|
|
1629
1661
|
return cascade;
|
|
1630
1662
|
}
|
|
1631
1663
|
|
|
1632
|
-
//
|
|
1633
|
-
|
|
1664
|
+
// The template whose content each hast `content` root is, recorded by `walk`.
|
|
1665
|
+
const contentOwners = new WeakMap();
|
|
1666
|
+
|
|
1667
|
+
// The box `parent`'s children are laid out in: a `display:contents` parent
|
|
1668
|
+
// defers to its own parent, and a declarative shadow root's content to the
|
|
1669
|
+
// template's parent, the shadow host whose box its children render in.
|
|
1670
|
+
/** @param {any} parent @returns {any} */
|
|
1671
|
+
function layoutContainer(parent) {
|
|
1672
|
+
let container = parent;
|
|
1673
|
+
for (;;) {
|
|
1674
|
+
const template = contentOwners.get(container);
|
|
1675
|
+
if (template && !isInertTemplate(template))
|
|
1676
|
+
container = parents.get(template);
|
|
1677
|
+
else if (elementCascade(container).textOf("display") === "contents")
|
|
1678
|
+
container = parents.get(container);
|
|
1679
|
+
else return container;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// True when `node` lays out as a non-replaced INLINE box. A flex/grid layout
|
|
1684
|
+
// container (see {@link layoutContainer}) blockifies it.
|
|
1634
1685
|
/** @param {any} node @param {any} [parent] @returns {boolean} */
|
|
1635
1686
|
function isInlineBox(node, parent) {
|
|
1636
1687
|
if (!INLINE_ELEMENTS.has(node.tagName)) return false;
|
|
@@ -1642,10 +1693,7 @@ function isInlineBox(node, parent) {
|
|
|
1642
1693
|
BLOCKIFYING_VALUES.has(textOf("float"))
|
|
1643
1694
|
)
|
|
1644
1695
|
return false;
|
|
1645
|
-
|
|
1646
|
-
while (elementCascade(container).textOf("display") === "contents")
|
|
1647
|
-
container = parents.get(container);
|
|
1648
|
-
return !elementCascade(container)
|
|
1696
|
+
return !elementCascade(layoutContainer(parent))
|
|
1649
1697
|
.textOf("display")
|
|
1650
1698
|
.split(" ")
|
|
1651
1699
|
.some((token) => ITEM_BLOCKIFYING_DISPLAYS.has(token));
|
|
@@ -1893,6 +1941,13 @@ function hasDataSrc(el) {
|
|
|
1893
1941
|
* the `unist-util-visit` contract, which is what the call sites here are
|
|
1894
1942
|
* written against.
|
|
1895
1943
|
*
|
|
1944
|
+
* One addition to that contract: an HTML `template`'s content (hast keeps it
|
|
1945
|
+
* as a `content` root beside the element's empty `children`) is walked after
|
|
1946
|
+
* its children, with the template as its parent, and `SKIP` leaves it
|
|
1947
|
+
* unvisited too. Rendered or not (see {@link isInertTemplate}), that markup is
|
|
1948
|
+
* in the source a model reads, so a scan that stopped at `children` would pass
|
|
1949
|
+
* any hidden element, script or URL placed in it unseen.
|
|
1950
|
+
*
|
|
1896
1951
|
* Spelled out rather than imported because `unist-util-visit` allocates a fresh
|
|
1897
1952
|
* ancestors array and a closure per node, and on a document that is one long
|
|
1898
1953
|
* flat sibling list — 32k `<p>` elements per megabyte of ordinary HTML — that
|
|
@@ -1921,6 +1976,12 @@ function walk(tree, test, visitor, treeParent = undefined) {
|
|
|
1921
1976
|
: undefined;
|
|
1922
1977
|
if (result === EXIT) return;
|
|
1923
1978
|
if (result === SKIP) continue;
|
|
1979
|
+
if (node.type === "element" && node.content !== undefined) {
|
|
1980
|
+
contentOwners.set(node.content, node);
|
|
1981
|
+
nodes.push(node.content);
|
|
1982
|
+
indices.push(undefined);
|
|
1983
|
+
parents.push(node);
|
|
1984
|
+
}
|
|
1924
1985
|
const children = node.children;
|
|
1925
1986
|
if (children === undefined) continue;
|
|
1926
1987
|
for (let i = children.length - 1; i >= 0; i--) {
|
|
@@ -2163,7 +2224,7 @@ function scanFragmentTree(html, tree) {
|
|
|
2163
2224
|
const warned = newWarned();
|
|
2164
2225
|
/** @param {any} node */
|
|
2165
2226
|
const countElement = (node) => {
|
|
2166
|
-
if (
|
|
2227
|
+
if (isReportedElement(node)) countTag(warned, node.tagName);
|
|
2167
2228
|
if (hasDataSrc(node)) warned.dataSrc += 1;
|
|
2168
2229
|
};
|
|
2169
2230
|
// Subtrees still to scan, each with its parent: the tree itself, then the
|
|
@@ -2485,7 +2546,11 @@ function scanInlineChildren(node, text, ranges, warned) {
|
|
|
2485
2546
|
// A raw-text open tag starts an opaque region (a self-closing `/>`
|
|
2486
2547
|
// does not apply to these in HTML — they always open).
|
|
2487
2548
|
if (RAW_TEXT_ELEMENTS.has(el.tagName)) rawText = el.tagName;
|
|
2488
|
-
|
|
2549
|
+
// `el` is one tag parsed alone, so a `<template>` inside an inline
|
|
2550
|
+
// `<svg>`/`<math>` reads as HTML here and is reported, where the
|
|
2551
|
+
// source branch, which sees the namespace, does not: this errs toward
|
|
2552
|
+
// reporting.
|
|
2553
|
+
if (isReportedElement(el)) countTag(warned, el.tagName);
|
|
2489
2554
|
if (hasDataSrc(el)) warned.dataSrc += 1;
|
|
2490
2555
|
}
|
|
2491
2556
|
}
|
|
@@ -3628,20 +3693,40 @@ function documentUrls(text, parseMd, parseHtml) {
|
|
|
3628
3693
|
});
|
|
3629
3694
|
/** @type {string[]} */
|
|
3630
3695
|
const comments = [];
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3696
|
+
// Nodes inside an inert template's content, where nothing loads, submits or
|
|
3697
|
+
// redirects until a script clones it into the page.
|
|
3698
|
+
const inert = new Set();
|
|
3699
|
+
walk(
|
|
3700
|
+
parseHtml(text),
|
|
3701
|
+
null,
|
|
3702
|
+
(/** @type {any} */ node, _index, /** @type {any} */ parent) => {
|
|
3703
|
+
if (
|
|
3704
|
+
inert.has(parent) ||
|
|
3705
|
+
(parent?.content === node && isInertTemplate(parent))
|
|
3706
|
+
)
|
|
3707
|
+
inert.add(node);
|
|
3708
|
+
if (node.type === "comment") comments.push(node.value);
|
|
3709
|
+
else if (node.type === "element")
|
|
3710
|
+
for (const url of elementUrls(node))
|
|
3711
|
+
urls.push(
|
|
3712
|
+
inert.has(node)
|
|
3713
|
+
? { ...url, autoFetched: false, context: "resource" }
|
|
3714
|
+
: url,
|
|
3715
|
+
);
|
|
3716
|
+
},
|
|
3717
|
+
);
|
|
3635
3718
|
return { urls, comments };
|
|
3636
3719
|
}
|
|
3637
3720
|
|
|
3638
3721
|
// Every URL a Layer-3 detector reads: markdown links/images/definitions and
|
|
3639
|
-
// the URL attributes of every element, also inside comments
|
|
3640
|
-
// below consume this, so a node type is in scope for
|
|
3641
|
-
// `parseMarkdown` memoizes its tree, so back-to-back
|
|
3642
|
-
// A comment is invisible on the page but not to a
|
|
3643
|
-
// its text is read one level deep with its markup
|
|
3644
|
-
// names is not fetched and has no off-origin
|
|
3722
|
+
// the URL attributes of every element, also inside comments and template
|
|
3723
|
+
// content. Both detectors below consume this, so a node type is in scope for
|
|
3724
|
+
// all of them or for none; `parseMarkdown` memoizes its tree, so back-to-back
|
|
3725
|
+
// detectors share the parse. A comment is invisible on the page but not to a
|
|
3726
|
+
// model reading the source: its text is read one level deep with its markup
|
|
3727
|
+
// openers defused, and what it names is not fetched and has no off-origin
|
|
3728
|
+
// rule. An inert template's content gets the same two answers: nothing in it
|
|
3729
|
+
// is fetched, submitted or followed until a script clones it into the page.
|
|
3645
3730
|
/** @param {string} text @returns {CollectedUrl[]} */
|
|
3646
3731
|
function collectUrls(text) {
|
|
3647
3732
|
const { urls, comments } = documentUrls(text, parseMarkdown, parseFragment);
|
|
@@ -1,81 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Wormer). The licence is in `LICENSE-THIRD-PARTY` at the repository root.
|
|
2
|
+
* GENERATED by scripts/gen-gfm-autolink.mjs at install, pack and build time;
|
|
3
|
+
* git-ignored, never committed — DO NOT EDIT.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and costs one full walk per word character. 172 KB of prose behind a single
|
|
12
|
-
* `[` took 20.2s to parse; the same text takes 0.17s here. Layer 3 reads its
|
|
13
|
-
* URLs off this parse, so the cost lands on every `detectExfil` call.
|
|
14
|
-
*
|
|
15
|
-
* WHAT CHANGED. One function. `previousUnbalanced` now also records where the
|
|
16
|
-
* walk stopped, and a later walk resumes from that index rather than re-reading
|
|
17
|
-
* the run before it. The record is used only while `events[index]` still holds
|
|
18
|
-
* the same token, so a resolver that splices events invalidates it and the walk
|
|
19
|
-
* falls back to reading everything. Nothing else in this file differs from
|
|
20
|
-
* upstream — diff it against the published package to confirm.
|
|
21
|
-
*
|
|
22
|
-
* WHY NOT A PATCH. A `pnpm` patch fixes this repository's own installs and the
|
|
23
|
-
* plugin bundle built from them, and reaches no consumer of the published npm
|
|
24
|
-
* package (see the npm-consumer gap in the project history). A copy under
|
|
25
|
-
* `src/` ships.
|
|
26
|
-
*
|
|
27
|
-
* WHEN TO DELETE IT. When a published release of the upstream package carries
|
|
28
|
-
* an equivalent fix: drop this file and the assembly in `./gfm.mjs`, and go
|
|
29
|
-
* back to `remark-gfm`. `test/gfm-autolink-parity.test.mjs` pins the behaviour
|
|
30
|
-
* either way.
|
|
5
|
+
* `micromark-extension-gfm-autolink-literal@2.1.0`'s `lib/syntax.js` (MIT, (c) 2020
|
|
6
|
+
* Titus Wormer; licence in LICENSE-THIRD-PARTY) with the one-function
|
|
7
|
+
* `previousUnbalanced` performance fix from
|
|
8
|
+
* `scripts/patches/micromark-extension-gfm-autolink-literal@2.1.0.patch`. The
|
|
9
|
+
* generator's header says why it is patched and when to delete it.
|
|
31
10
|
*/
|
|
32
|
-
|
|
33
11
|
/**
|
|
34
12
|
* @import {Code, ConstructRecord, Event, Extension, Previous, State, TokenizeContext, Tokenizer} from 'micromark-util-types'
|
|
35
13
|
*/
|
|
36
14
|
|
|
37
|
-
import {
|
|
38
|
-
asciiAlpha,
|
|
39
|
-
asciiAlphanumeric,
|
|
40
|
-
asciiControl,
|
|
41
|
-
markdownLineEndingOrSpace,
|
|
42
|
-
unicodePunctuation,
|
|
43
|
-
unicodeWhitespace,
|
|
44
|
-
} from "micromark-util-character";
|
|
15
|
+
import { asciiAlpha, asciiAlphanumeric, asciiControl, markdownLineEndingOrSpace, unicodePunctuation, unicodeWhitespace } from 'micromark-util-character';
|
|
45
16
|
const wwwPrefix = {
|
|
46
17
|
tokenize: tokenizeWwwPrefix,
|
|
47
|
-
partial: true
|
|
18
|
+
partial: true
|
|
48
19
|
};
|
|
49
20
|
const domain = {
|
|
50
21
|
tokenize: tokenizeDomain,
|
|
51
|
-
partial: true
|
|
22
|
+
partial: true
|
|
52
23
|
};
|
|
53
24
|
const path = {
|
|
54
25
|
tokenize: tokenizePath,
|
|
55
|
-
partial: true
|
|
26
|
+
partial: true
|
|
56
27
|
};
|
|
57
28
|
const trail = {
|
|
58
29
|
tokenize: tokenizeTrail,
|
|
59
|
-
partial: true
|
|
30
|
+
partial: true
|
|
60
31
|
};
|
|
61
32
|
const emailDomainDotTrail = {
|
|
62
33
|
tokenize: tokenizeEmailDomainDotTrail,
|
|
63
|
-
partial: true
|
|
34
|
+
partial: true
|
|
64
35
|
};
|
|
65
36
|
const wwwAutolink = {
|
|
66
|
-
name:
|
|
37
|
+
name: 'wwwAutolink',
|
|
67
38
|
tokenize: tokenizeWwwAutolink,
|
|
68
|
-
previous: previousWww
|
|
39
|
+
previous: previousWww
|
|
69
40
|
};
|
|
70
41
|
const protocolAutolink = {
|
|
71
|
-
name:
|
|
42
|
+
name: 'protocolAutolink',
|
|
72
43
|
tokenize: tokenizeProtocolAutolink,
|
|
73
|
-
previous: previousProtocol
|
|
44
|
+
previous: previousProtocol
|
|
74
45
|
};
|
|
75
46
|
const emailAutolink = {
|
|
76
|
-
name:
|
|
47
|
+
name: 'emailAutolink',
|
|
77
48
|
tokenize: tokenizeEmailAutolink,
|
|
78
|
-
previous: previousEmail
|
|
49
|
+
previous: previousEmail
|
|
79
50
|
};
|
|
80
51
|
|
|
81
52
|
/** @type {ConstructRecord} */
|
|
@@ -91,7 +62,7 @@ const text = {};
|
|
|
91
62
|
*/
|
|
92
63
|
export function gfmAutolinkLiteral() {
|
|
93
64
|
return {
|
|
94
|
-
text
|
|
65
|
+
text
|
|
95
66
|
};
|
|
96
67
|
}
|
|
97
68
|
|
|
@@ -102,8 +73,7 @@ let code = 48;
|
|
|
102
73
|
while (code < 123) {
|
|
103
74
|
text[code] = emailAutolink;
|
|
104
75
|
code++;
|
|
105
|
-
if (code === 58) code = 65;
|
|
106
|
-
else if (code === 91) code = 97;
|
|
76
|
+
if (code === 58) code = 65;else if (code === 91) code = 97;
|
|
107
77
|
}
|
|
108
78
|
text[43] = emailAutolink;
|
|
109
79
|
text[45] = emailAutolink;
|
|
@@ -150,15 +120,11 @@ function tokenizeEmailAutolink(effects, ok, nok) {
|
|
|
150
120
|
* @type {State}
|
|
151
121
|
*/
|
|
152
122
|
function start(code) {
|
|
153
|
-
if (
|
|
154
|
-
!gfmAtext(code) ||
|
|
155
|
-
!previousEmail.call(self, self.previous) ||
|
|
156
|
-
previousUnbalanced(self.events)
|
|
157
|
-
) {
|
|
123
|
+
if (!gfmAtext(code) || !previousEmail.call(self, self.previous) || previousUnbalanced(self.events)) {
|
|
158
124
|
return nok(code);
|
|
159
125
|
}
|
|
160
|
-
effects.enter(
|
|
161
|
-
effects.enter(
|
|
126
|
+
effects.enter('literalAutolink');
|
|
127
|
+
effects.enter('literalAutolinkEmail');
|
|
162
128
|
return atext(code);
|
|
163
129
|
}
|
|
164
130
|
|
|
@@ -201,11 +167,7 @@ function tokenizeEmailAutolink(effects, ok, nok) {
|
|
|
201
167
|
function emailDomain(code) {
|
|
202
168
|
// Dot followed by alphanumerical (not `-` or `_`).
|
|
203
169
|
if (code === 46) {
|
|
204
|
-
return effects.check(
|
|
205
|
-
emailDomainDotTrail,
|
|
206
|
-
emailDomainAfter,
|
|
207
|
-
emailDomainDot,
|
|
208
|
-
)(code);
|
|
170
|
+
return effects.check(emailDomainDotTrail, emailDomainAfter, emailDomainDot)(code);
|
|
209
171
|
}
|
|
210
172
|
|
|
211
173
|
// Alphanumerical, `-`, and `_`.
|
|
@@ -254,8 +216,8 @@ function tokenizeEmailAutolink(effects, ok, nok) {
|
|
|
254
216
|
// Domain must not be empty, must include a dot, and must end in alphabetical.
|
|
255
217
|
// Source: <https://github.com/github/cmark-gfm/blob/ef1cfcb/extensions/autolink.c#L332>.
|
|
256
218
|
if (data && dot && asciiAlpha(self.previous)) {
|
|
257
|
-
effects.exit(
|
|
258
|
-
effects.exit(
|
|
219
|
+
effects.exit('literalAutolinkEmail');
|
|
220
|
+
effects.exit('literalAutolink');
|
|
259
221
|
return ok(code);
|
|
260
222
|
}
|
|
261
223
|
return nok(code);
|
|
@@ -288,22 +250,14 @@ function tokenizeWwwAutolink(effects, ok, nok) {
|
|
|
288
250
|
* @type {State}
|
|
289
251
|
*/
|
|
290
252
|
function wwwStart(code) {
|
|
291
|
-
if (
|
|
292
|
-
(code !== 87 && code !== 119) ||
|
|
293
|
-
!previousWww.call(self, self.previous) ||
|
|
294
|
-
previousUnbalanced(self.events)
|
|
295
|
-
) {
|
|
253
|
+
if (code !== 87 && code !== 119 || !previousWww.call(self, self.previous) || previousUnbalanced(self.events)) {
|
|
296
254
|
return nok(code);
|
|
297
255
|
}
|
|
298
|
-
effects.enter(
|
|
299
|
-
effects.enter(
|
|
256
|
+
effects.enter('literalAutolink');
|
|
257
|
+
effects.enter('literalAutolinkWww');
|
|
300
258
|
// Note: we *check*, so we can discard the `www.` we parsed.
|
|
301
259
|
// If it worked, we consider it as a part of the domain.
|
|
302
|
-
return effects.check(
|
|
303
|
-
wwwPrefix,
|
|
304
|
-
effects.attempt(domain, effects.attempt(path, wwwAfter), nok),
|
|
305
|
-
nok,
|
|
306
|
-
)(code);
|
|
260
|
+
return effects.check(wwwPrefix, effects.attempt(domain, effects.attempt(path, wwwAfter), nok), nok)(code);
|
|
307
261
|
}
|
|
308
262
|
|
|
309
263
|
/**
|
|
@@ -317,8 +271,8 @@ function tokenizeWwwAutolink(effects, ok, nok) {
|
|
|
317
271
|
* @type {State}
|
|
318
272
|
*/
|
|
319
273
|
function wwwAfter(code) {
|
|
320
|
-
effects.exit(
|
|
321
|
-
effects.exit(
|
|
274
|
+
effects.exit('literalAutolinkWww');
|
|
275
|
+
effects.exit('literalAutolink');
|
|
322
276
|
return ok(code);
|
|
323
277
|
}
|
|
324
278
|
}
|
|
@@ -336,7 +290,7 @@ function tokenizeWwwAutolink(effects, ok, nok) {
|
|
|
336
290
|
*/
|
|
337
291
|
function tokenizeProtocolAutolink(effects, ok, nok) {
|
|
338
292
|
const self = this;
|
|
339
|
-
let buffer =
|
|
293
|
+
let buffer = '';
|
|
340
294
|
let seen = false;
|
|
341
295
|
return protocolStart;
|
|
342
296
|
|
|
@@ -351,13 +305,9 @@ function tokenizeProtocolAutolink(effects, ok, nok) {
|
|
|
351
305
|
* @type {State}
|
|
352
306
|
*/
|
|
353
307
|
function protocolStart(code) {
|
|
354
|
-
if (
|
|
355
|
-
(
|
|
356
|
-
|
|
357
|
-
!previousUnbalanced(self.events)
|
|
358
|
-
) {
|
|
359
|
-
effects.enter("literalAutolink");
|
|
360
|
-
effects.enter("literalAutolinkHttp");
|
|
308
|
+
if ((code === 72 || code === 104) && previousProtocol.call(self, self.previous) && !previousUnbalanced(self.events)) {
|
|
309
|
+
effects.enter('literalAutolink');
|
|
310
|
+
effects.enter('literalAutolinkHttp');
|
|
361
311
|
buffer += String.fromCodePoint(code);
|
|
362
312
|
effects.consume(code);
|
|
363
313
|
return protocolPrefixInside;
|
|
@@ -385,7 +335,7 @@ function tokenizeProtocolAutolink(effects, ok, nok) {
|
|
|
385
335
|
}
|
|
386
336
|
if (code === 58) {
|
|
387
337
|
const protocol = buffer.toLowerCase();
|
|
388
|
-
if (protocol ===
|
|
338
|
+
if (protocol === 'http' || protocol === 'https') {
|
|
389
339
|
effects.consume(code);
|
|
390
340
|
return protocolSlashesInside;
|
|
391
341
|
}
|
|
@@ -428,17 +378,7 @@ function tokenizeProtocolAutolink(effects, ok, nok) {
|
|
|
428
378
|
function afterProtocol(code) {
|
|
429
379
|
// To do: this is different from `markdown-rs`:
|
|
430
380
|
// https://github.com/wooorm/markdown-rs/blob/b3a921c761309ae00a51fe348d8a43adbc54b518/src/construct/gfm_autolink_literal.rs#L172-L182
|
|
431
|
-
return code === null ||
|
|
432
|
-
asciiControl(code) ||
|
|
433
|
-
markdownLineEndingOrSpace(code) ||
|
|
434
|
-
unicodeWhitespace(code) ||
|
|
435
|
-
unicodePunctuation(code)
|
|
436
|
-
? nok(code)
|
|
437
|
-
: effects.attempt(
|
|
438
|
-
domain,
|
|
439
|
-
effects.attempt(path, protocolAfter),
|
|
440
|
-
nok,
|
|
441
|
-
)(code);
|
|
381
|
+
return code === null || asciiControl(code) || markdownLineEndingOrSpace(code) || unicodeWhitespace(code) || unicodePunctuation(code) ? nok(code) : effects.attempt(domain, effects.attempt(path, protocolAfter), nok)(code);
|
|
442
382
|
}
|
|
443
383
|
|
|
444
384
|
/**
|
|
@@ -452,8 +392,8 @@ function tokenizeProtocolAutolink(effects, ok, nok) {
|
|
|
452
392
|
* @type {State}
|
|
453
393
|
*/
|
|
454
394
|
function protocolAfter(code) {
|
|
455
|
-
effects.exit(
|
|
456
|
-
effects.exit(
|
|
395
|
+
effects.exit('literalAutolinkHttp');
|
|
396
|
+
effects.exit('literalAutolink');
|
|
457
397
|
return ok(code);
|
|
458
398
|
}
|
|
459
399
|
}
|
|
@@ -556,12 +496,7 @@ function tokenizeDomain(effects, ok, nok) {
|
|
|
556
496
|
// Instead of some new production for Unicode alphanumerics, markdown
|
|
557
497
|
// already has that for Unicode punctuation and whitespace, so use those.
|
|
558
498
|
// Source: <https://github.com/github/cmark-gfm/blob/ef1cfcb/extensions/autolink.c#L12>.
|
|
559
|
-
if (
|
|
560
|
-
code === null ||
|
|
561
|
-
markdownLineEndingOrSpace(code) ||
|
|
562
|
-
unicodeWhitespace(code) ||
|
|
563
|
-
(code !== 45 && unicodePunctuation(code))
|
|
564
|
-
) {
|
|
499
|
+
if (code === null || markdownLineEndingOrSpace(code) || unicodeWhitespace(code) || code !== 45 && unicodePunctuation(code)) {
|
|
565
500
|
return domainAfter(code);
|
|
566
501
|
}
|
|
567
502
|
seen = true;
|
|
@@ -656,30 +591,10 @@ function tokenizePath(effects, ok) {
|
|
|
656
591
|
// Check whether this trailing punctuation marker is optionally
|
|
657
592
|
// followed by more trailing markers, and then followed
|
|
658
593
|
// by an end.
|
|
659
|
-
if (
|
|
660
|
-
code === 33 ||
|
|
661
|
-
code === 34 ||
|
|
662
|
-
code === 38 ||
|
|
663
|
-
code === 39 ||
|
|
664
|
-
code === 41 ||
|
|
665
|
-
code === 42 ||
|
|
666
|
-
code === 44 ||
|
|
667
|
-
code === 46 ||
|
|
668
|
-
code === 58 ||
|
|
669
|
-
code === 59 ||
|
|
670
|
-
code === 60 ||
|
|
671
|
-
code === 63 ||
|
|
672
|
-
code === 93 ||
|
|
673
|
-
code === 95 ||
|
|
674
|
-
code === 126
|
|
675
|
-
) {
|
|
594
|
+
if (code === 33 || code === 34 || code === 38 || code === 39 || code === 41 || code === 42 || code === 44 || code === 46 || code === 58 || code === 59 || code === 60 || code === 63 || code === 93 || code === 95 || code === 126) {
|
|
676
595
|
return effects.check(trail, ok, pathAtPunctuation)(code);
|
|
677
596
|
}
|
|
678
|
-
if (
|
|
679
|
-
code === null ||
|
|
680
|
-
markdownLineEndingOrSpace(code) ||
|
|
681
|
-
unicodeWhitespace(code)
|
|
682
|
-
) {
|
|
597
|
+
if (code === null || markdownLineEndingOrSpace(code) || unicodeWhitespace(code)) {
|
|
683
598
|
return ok(code);
|
|
684
599
|
}
|
|
685
600
|
effects.consume(code);
|
|
@@ -736,20 +651,7 @@ function tokenizeTrail(effects, ok, nok) {
|
|
|
736
651
|
*/
|
|
737
652
|
function trail(code) {
|
|
738
653
|
// Regular trailing punctuation.
|
|
739
|
-
if (
|
|
740
|
-
code === 33 ||
|
|
741
|
-
code === 34 ||
|
|
742
|
-
code === 39 ||
|
|
743
|
-
code === 41 ||
|
|
744
|
-
code === 42 ||
|
|
745
|
-
code === 44 ||
|
|
746
|
-
code === 46 ||
|
|
747
|
-
code === 58 ||
|
|
748
|
-
code === 59 ||
|
|
749
|
-
code === 63 ||
|
|
750
|
-
code === 95 ||
|
|
751
|
-
code === 126
|
|
752
|
-
) {
|
|
654
|
+
if (code === 33 || code === 34 || code === 39 || code === 41 || code === 42 || code === 44 || code === 46 || code === 58 || code === 59 || code === 63 || code === 95 || code === 126) {
|
|
753
655
|
effects.consume(code);
|
|
754
656
|
return trail;
|
|
755
657
|
}
|
|
@@ -770,13 +672,10 @@ function tokenizeTrail(effects, ok, nok) {
|
|
|
770
672
|
return trailBracketAfter;
|
|
771
673
|
}
|
|
772
674
|
if (
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
markdownLineEndingOrSpace(code) ||
|
|
778
|
-
unicodeWhitespace(code)
|
|
779
|
-
) {
|
|
675
|
+
// `<` is an end.
|
|
676
|
+
code === 60 ||
|
|
677
|
+
// So is whitespace.
|
|
678
|
+
code === null || markdownLineEndingOrSpace(code) || unicodeWhitespace(code)) {
|
|
780
679
|
return ok(code);
|
|
781
680
|
}
|
|
782
681
|
return nok(code);
|
|
@@ -798,13 +697,7 @@ function tokenizeTrail(effects, ok, nok) {
|
|
|
798
697
|
function trailBracketAfter(code) {
|
|
799
698
|
// Whitespace or something that could start a resource or reference is the end.
|
|
800
699
|
// Switch back to trail otherwise.
|
|
801
|
-
if (
|
|
802
|
-
code === null ||
|
|
803
|
-
code === 40 ||
|
|
804
|
-
code === 91 ||
|
|
805
|
-
markdownLineEndingOrSpace(code) ||
|
|
806
|
-
unicodeWhitespace(code)
|
|
807
|
-
) {
|
|
700
|
+
if (code === null || code === 40 || code === 91 || markdownLineEndingOrSpace(code) || unicodeWhitespace(code)) {
|
|
808
701
|
return ok(code);
|
|
809
702
|
}
|
|
810
703
|
return trail(code);
|
|
@@ -908,16 +801,7 @@ function tokenizeEmailDomainDotTrail(effects, ok, nok) {
|
|
|
908
801
|
* @type {Previous}
|
|
909
802
|
*/
|
|
910
803
|
function previousWww(code) {
|
|
911
|
-
return (
|
|
912
|
-
code === null ||
|
|
913
|
-
code === 40 ||
|
|
914
|
-
code === 42 ||
|
|
915
|
-
code === 95 ||
|
|
916
|
-
code === 91 ||
|
|
917
|
-
code === 93 ||
|
|
918
|
-
code === 126 ||
|
|
919
|
-
markdownLineEndingOrSpace(code)
|
|
920
|
-
);
|
|
804
|
+
return code === null || code === 40 || code === 42 || code === 95 || code === 91 || code === 93 || code === 126 || markdownLineEndingOrSpace(code);
|
|
921
805
|
}
|
|
922
806
|
|
|
923
807
|
/**
|
|
@@ -947,13 +831,7 @@ function previousEmail(code) {
|
|
|
947
831
|
* @returns {boolean}
|
|
948
832
|
*/
|
|
949
833
|
function gfmAtext(code) {
|
|
950
|
-
return (
|
|
951
|
-
code === 43 ||
|
|
952
|
-
code === 45 ||
|
|
953
|
-
code === 46 ||
|
|
954
|
-
code === 95 ||
|
|
955
|
-
asciiAlphanumeric(code)
|
|
956
|
-
);
|
|
834
|
+
return code === 43 || code === 45 || code === 46 || code === 95 || asciiAlphanumeric(code);
|
|
957
835
|
}
|
|
958
836
|
|
|
959
837
|
/**
|
|
@@ -965,10 +843,7 @@ function previousUnbalanced(events) {
|
|
|
965
843
|
let result = false;
|
|
966
844
|
while (index--) {
|
|
967
845
|
const token = events[index][1];
|
|
968
|
-
if (
|
|
969
|
-
(token.type === "labelLink" || token.type === "labelImage") &&
|
|
970
|
-
!token._balanced
|
|
971
|
-
) {
|
|
846
|
+
if ((token.type === 'labelLink' || token.type === 'labelImage') && !token._balanced) {
|
|
972
847
|
result = true;
|
|
973
848
|
break;
|
|
974
849
|
}
|
|
@@ -30,7 +30,10 @@ export function parseHtmlFragment(html: string): any;
|
|
|
30
30
|
* child its parent's conversion reads (an unclosed element ends where its last
|
|
31
31
|
* child does). One walk then swaps each placeholder for its subtree. The result
|
|
32
32
|
* is the tree a single `fromParse5` call returns, which
|
|
33
|
-
* `html-tree-adapter.test` holds it to
|
|
33
|
+
* `html-tree-adapter.test` holds it to — except that an element the converter
|
|
34
|
+
* would take for a template with no content to take (see
|
|
35
|
+
* {@link CONVERTS_AS_TEMPLATE}) converts as an ordinary element instead of
|
|
36
|
+
* throwing.
|
|
34
37
|
*
|
|
35
38
|
* Mutates `tree`: {@link parseHtmlFragment} owns the parse and drops it after.
|
|
36
39
|
* @param {any} tree
|