@citation-media/html-parser 0.1.0 → 0.1.2

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/dist/clean.js CHANGED
@@ -1,4 +1,4 @@
1
- import { looksBinary } from "./text.js";
1
+ import { looksBinary, readAttribute } from "./text.js";
2
2
  import { absoluteUrl, isResource, linkKind } from "./urls.js";
3
3
  const cookieConsent = [
4
4
  "[id^='brlbs-cmpnt']",
@@ -19,6 +19,10 @@ const remove = {
19
19
  };
20
20
  /** The cleaned page as HTML. */
21
21
  export const cleanHtml = (input, options) => {
22
+ // An empty page has nothing to stream; some runtimes never finish transforming an empty body.
23
+ if (!(input instanceof Response) && input.trim() === "") {
24
+ return Promise.resolve("");
25
+ }
22
26
  const base = new URL(options.url);
23
27
  const removed = options.remove ?? {};
24
28
  let rewriter = new HTMLRewriter();
@@ -122,7 +126,7 @@ export const cleanHtml = (input, options) => {
122
126
  if (links) {
123
127
  rewriter = rewriter.on("a", {
124
128
  element(element) {
125
- const href = element.getAttribute("href");
129
+ const href = readAttribute(element, "href");
126
130
  if (!href) {
127
131
  return;
128
132
  }
package/dist/parse.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { MarkdownWriter } from "./markdown.js";
2
- import { collapseWhitespace, decodeEntities, removeHyphenation, } from "./text.js";
2
+ import { collapseWhitespace, decodeEntities, readAttribute, removeHyphenation, } from "./text.js";
3
3
  import { absoluteUrl, isResource, linkKind } from "./urls.js";
4
4
  const blockTags = new Set([
5
5
  "br",
@@ -56,18 +56,28 @@ const hiddenFromMarkdown = [
56
56
  ".visually-hidden",
57
57
  ];
58
58
  const maxHeadings = 200;
59
- const maxImages = 500;
60
59
  const maxResources = 500;
61
- // Follows an element to its end tag. Void elements such as <img> have none, and HTMLRewriter throws
62
- // for them; elements closed implicitly, such as <li> without </li>, end with their parent.
63
- const enter = (element, onEnd) => {
60
+ /**
61
+ * Follows an element to its end tag. HTMLRewriter keeps only the last end-tag handler an element
62
+ * gets, so every handler adds its action to the element's list and registers the whole list; the
63
+ * last registration then runs them all. Void elements such as <img> have no end tag, and
64
+ * HTMLRewriter throws for them; elements closed implicitly, such as <li> without </li>, end with
65
+ * their parent.
66
+ */
67
+ const enter = (pass, element, onEnd) => {
68
+ const actions = [...pass.endActions, onEnd];
64
69
  try {
65
- element.onEndTag(onEnd);
66
- return true;
70
+ element.onEndTag(() => {
71
+ for (const action of actions) {
72
+ action();
73
+ }
74
+ });
67
75
  }
68
76
  catch {
69
77
  return false;
70
78
  }
79
+ pass.endActions = actions;
80
+ return true;
71
81
  };
72
82
  const clean = (text) => collapseWhitespace(removeHyphenation(decodeEntities(text))).trim();
73
83
  const addLink = (links, found, base) => {
@@ -96,7 +106,7 @@ const collecting = (pass) => pass.inContext > 0;
96
106
  const writing = (pass) => collecting(pass) && pass.hidden === 0 && pass.markdown !== undefined;
97
107
  const hide = (pass) => ({
98
108
  element(element) {
99
- if (enter(element, () => (pass.hidden -= 1))) {
109
+ if (enter(pass, element, () => (pass.hidden -= 1))) {
100
110
  pass.hidden += 1;
101
111
  }
102
112
  },
@@ -108,7 +118,7 @@ const onStructure = (rewriter, pass) => {
108
118
  next = next.on(context, {
109
119
  element(element) {
110
120
  pass.contextFound = true;
111
- if (enter(element, () => (pass.inContext -= 1))) {
121
+ if (enter(pass, element, () => (pass.inContext -= 1))) {
112
122
  pass.inContext += 1;
113
123
  }
114
124
  },
@@ -127,7 +137,7 @@ const onStructure = (rewriter, pass) => {
127
137
  if (!collecting(pass)) {
128
138
  return;
129
139
  }
130
- const classes = element.getAttribute("class")?.split(/\s+/u) ?? [];
140
+ const classes = readAttribute(element, "class")?.split(/\s+/u) ?? [];
131
141
  for (const prefix of classPrefixes) {
132
142
  if (classes.some((name) => name.startsWith(prefix))) {
133
143
  pass.classCounts[prefix] = (pass.classCounts[prefix] ?? 0) + 1;
@@ -138,7 +148,7 @@ const onStructure = (rewriter, pass) => {
138
148
  return;
139
149
  }
140
150
  pass.markdown?.open(tag);
141
- enter(element, () => {
151
+ enter(pass, element, () => {
142
152
  if (writing(pass)) {
143
153
  pass.markdown?.close(tag);
144
154
  }
@@ -148,7 +158,7 @@ const onStructure = (rewriter, pass) => {
148
158
  };
149
159
  const onLinks = (rewriter, pass) => rewriter.on("a[href]", {
150
160
  element(element) {
151
- const href = element.getAttribute("href") ?? "";
161
+ const href = readAttribute(element, "href") ?? "";
152
162
  if (!collecting(pass) || !href) {
153
163
  return;
154
164
  }
@@ -156,16 +166,16 @@ const onLinks = (rewriter, pass) => rewriter.on("a[href]", {
156
166
  const text = [];
157
167
  pass.link = text;
158
168
  // Attributes are readable only while this handler runs, not in the end-tag handler.
159
- const label = element.getAttribute("aria-label") ?? undefined;
160
- const rel = element.getAttribute("rel") ?? undefined;
161
- const target = element.getAttribute("target") ?? undefined;
169
+ const label = readAttribute(element, "aria-label") ?? undefined;
170
+ const rel = readAttribute(element, "rel") ?? undefined;
171
+ const target = readAttribute(element, "target") ?? undefined;
162
172
  const written = writing(pass) &&
163
173
  pass.markdownOptions?.links !== false &&
164
174
  !href.startsWith("#");
165
175
  if (written) {
166
176
  pass.markdown?.openLink(url);
167
177
  }
168
- enter(element, () => {
178
+ enter(pass, element, () => {
169
179
  if (written) {
170
180
  pass.markdown?.closeLink();
171
181
  }
@@ -178,23 +188,25 @@ const onLinks = (rewriter, pass) => rewriter.on("a[href]", {
178
188
  });
179
189
  const onImages = (rewriter, pass) => rewriter.on("img", {
180
190
  element(element) {
181
- // Lazy-loading scripts often keep the real address in data-src until the image scrolls in.
182
- const src = element.getAttribute("src") ?? element.getAttribute("data-src");
183
- if (!collecting(pass) || !src || src.startsWith("data:")) {
191
+ // Lazy-loading scripts keep the real address in data-src and a placeholder in src.
192
+ const src = [
193
+ readAttribute(element, "src"),
194
+ readAttribute(element, "data-src"),
195
+ readAttribute(element, "data-lazy-src"),
196
+ ].find((value) => value && !value.startsWith("data:"));
197
+ if (!collecting(pass) || !src) {
184
198
  return;
185
199
  }
186
200
  const url = absoluteUrl(src, pass.base);
187
- const alt = element.getAttribute("alt");
188
- if (pass.options.images &&
189
- pass.images.size < maxImages &&
190
- !pass.images.has(url)) {
201
+ const alt = readAttribute(element, "alt");
202
+ if (pass.options.images && !pass.images.has(url)) {
191
203
  pass.images.set(url, {
192
204
  alt: alt === null ? null : clean(alt),
193
- height: element.getAttribute("height") ?? undefined,
194
- loading: element.getAttribute("loading") ?? undefined,
205
+ height: readAttribute(element, "height") ?? undefined,
206
+ loading: readAttribute(element, "loading") ?? undefined,
195
207
  srcset: element.hasAttribute("srcset"),
196
208
  url,
197
- width: element.getAttribute("width") ?? undefined,
209
+ width: readAttribute(element, "width") ?? undefined,
198
210
  });
199
211
  }
200
212
  if (writing(pass) && pass.markdownOptions?.images) {
@@ -208,11 +220,11 @@ const onResources = (rewriter, pass) => {
208
220
  .on("script", {
209
221
  element(element) {
210
222
  if (resources.scripts.length < maxResources) {
211
- const src = element.getAttribute("src");
223
+ const src = readAttribute(element, "src");
212
224
  resources.scripts.push({
213
225
  attributes: [...element.attributes].flatMap(([name]) => name ? [name] : []),
214
226
  src: src ? absoluteUrl(src, base) : undefined,
215
- type: element.getAttribute("type") ?? undefined,
227
+ type: readAttribute(element, "type") ?? undefined,
216
228
  });
217
229
  }
218
230
  },
@@ -220,7 +232,7 @@ const onResources = (rewriter, pass) => {
220
232
  .on("iframe[src]", {
221
233
  element(element) {
222
234
  if (resources.frames.length < maxResources) {
223
- resources.frames.push(absoluteUrl(element.getAttribute("src") ?? "", base));
235
+ resources.frames.push(absoluteUrl(readAttribute(element, "src") ?? "", base));
224
236
  }
225
237
  },
226
238
  })
@@ -228,8 +240,8 @@ const onResources = (rewriter, pass) => {
228
240
  element(element) {
229
241
  if (resources.links.length < maxResources) {
230
242
  resources.links.push({
231
- href: absoluteUrl(element.getAttribute("href") ?? "", base),
232
- rel: element.getAttribute("rel") ?? "",
243
+ href: absoluteUrl(readAttribute(element, "href") ?? "", base),
244
+ rel: readAttribute(element, "rel") ?? "",
233
245
  });
234
246
  }
235
247
  },
@@ -245,13 +257,13 @@ const metaFields = new Map([
245
257
  const onMeta = (rewriter, pass) => rewriter
246
258
  .on("html", {
247
259
  element(element) {
248
- pass.meta.language = element.getAttribute("lang") ?? undefined;
260
+ pass.meta.language = readAttribute(element, "lang") ?? undefined;
249
261
  },
250
262
  })
251
263
  .on("title", {
252
264
  element(element) {
253
265
  pass.title = [];
254
- enter(element, () => {
266
+ enter(pass, element, () => {
255
267
  pass.meta.title = clean((pass.title ?? []).join(""));
256
268
  pass.title = undefined;
257
269
  });
@@ -259,18 +271,18 @@ const onMeta = (rewriter, pass) => rewriter
259
271
  })
260
272
  .on("meta", {
261
273
  element(element) {
262
- const name = element.getAttribute("name") ??
263
- element.getAttribute("property") ??
274
+ const name = readAttribute(element, "name") ??
275
+ readAttribute(element, "property") ??
264
276
  "";
265
277
  const field = metaFields.get(name.toLowerCase());
266
278
  if (field) {
267
- pass.meta[field] = element.getAttribute("content") ?? undefined;
279
+ pass.meta[field] = readAttribute(element, "content") ?? undefined;
268
280
  }
269
281
  },
270
282
  })
271
283
  .on("link[rel='canonical']", {
272
284
  element(element) {
273
- const href = element.getAttribute("href");
285
+ const href = readAttribute(element, "href");
274
286
  pass.meta.canonical = href ? absoluteUrl(href, pass.base) : undefined;
275
287
  },
276
288
  });
@@ -282,7 +294,7 @@ const onHeadings = (rewriter, pass) => rewriter.on("h1, h2, h3, h4, h5, h6", {
282
294
  const level = Number(element.tagName.slice(1));
283
295
  pass.headingCounts[level - 1] = (pass.headingCounts[level - 1] ?? 0) + 1;
284
296
  pass.heading = { level, text: [] };
285
- enter(element, () => {
297
+ enter(pass, element, () => {
286
298
  const text = clean(pass.heading?.text.join("") ?? "");
287
299
  if (pass.heading && text && pass.headings.length < maxHeadings) {
288
300
  pass.headings.push({ level: pass.heading.level, text });
@@ -338,6 +350,7 @@ export const parseHtml = async (input, options) => {
338
350
  base: new URL(options.url),
339
351
  classCounts: Object.fromEntries((options.classPrefixes ?? []).map((prefix) => [prefix, 0])),
340
352
  contextFound: false,
353
+ endActions: [],
341
354
  heading: undefined,
342
355
  headingCounts: [0, 0, 0, 0, 0, 0],
343
356
  headings: [],
@@ -356,7 +369,17 @@ export const parseHtml = async (input, options) => {
356
369
  resources: { frames: [], links: [], scripts: [] },
357
370
  title: undefined,
358
371
  };
359
- let rewriter = onImages(onLinks(onStructure(new HTMLRewriter(), pass), pass), pass);
372
+ // An empty page has nothing to stream; some runtimes never finish transforming an empty body.
373
+ if (!(input instanceof Response) && input.trim() === "") {
374
+ return resultOf(pass);
375
+ }
376
+ // Registered first, so every element starts with an empty list of end-tag actions.
377
+ const first = new HTMLRewriter().on("*", {
378
+ element() {
379
+ pass.endActions = [];
380
+ },
381
+ });
382
+ let rewriter = onImages(onLinks(onStructure(first, pass), pass), pass);
360
383
  if (options.resources) {
361
384
  rewriter = onResources(rewriter, pass);
362
385
  }
package/dist/text.d.ts CHANGED
@@ -14,3 +14,8 @@ export declare const collapseWhitespace: (text: string) => string;
14
14
  * conservative so umlauts and other normal characters never trigger it.
15
15
  */
16
16
  export declare const looksBinary: (text: string) => boolean;
17
+ /**
18
+ * An attribute's value with its entities decoded. HTMLRewriter returns attributes as written in the
19
+ * source, so `href="?a=1&amp;b=2"` would otherwise keep its `&amp;`.
20
+ */
21
+ export declare const readAttribute: (element: Element, name: string) => string | null;
package/dist/text.js CHANGED
@@ -3,38 +3,261 @@
3
3
  * parser collects chunks per run of text and decodes them here, so an entity split across two
4
4
  * chunks is still decoded correctly.
5
5
  */
6
+ // The named entities of HTML 4, which cover what pages use in practice; numeric ones work anyway.
6
7
  const named = new Map(Object.entries({
7
- Auml: "Ä",
8
- Ouml: "Ö",
9
- Uuml: "Ü",
8
+ AElig: "\u00C6",
9
+ Aacute: "\u00C1",
10
+ Acirc: "\u00C2",
11
+ Agrave: "\u00C0",
12
+ Alpha: "\u0391",
13
+ Aring: "\u00C5",
14
+ Atilde: "\u00C3",
15
+ Auml: "\u00C4",
16
+ Beta: "\u0392",
17
+ Ccedil: "\u00C7",
18
+ Chi: "\u03A7",
19
+ Dagger: "\u2021",
20
+ Delta: "\u0394",
21
+ ETH: "\u00D0",
22
+ Eacute: "\u00C9",
23
+ Ecirc: "\u00CA",
24
+ Egrave: "\u00C8",
25
+ Epsilon: "\u0395",
26
+ Eta: "\u0397",
27
+ Euml: "\u00CB",
28
+ Gamma: "\u0393",
29
+ Iacute: "\u00CD",
30
+ Icirc: "\u00CE",
31
+ Igrave: "\u00CC",
32
+ Iota: "\u0399",
33
+ Iuml: "\u00CF",
34
+ Kappa: "\u039A",
35
+ Lambda: "\u039B",
36
+ Mu: "\u039C",
37
+ Ntilde: "\u00D1",
38
+ Nu: "\u039D",
39
+ OElig: "\u0152",
40
+ Oacute: "\u00D3",
41
+ Ocirc: "\u00D4",
42
+ Ograve: "\u00D2",
43
+ Omega: "\u03A9",
44
+ Omicron: "\u039F",
45
+ Oslash: "\u00D8",
46
+ Otilde: "\u00D5",
47
+ Ouml: "\u00D6",
48
+ Phi: "\u03A6",
49
+ Pi: "\u03A0",
50
+ Prime: "\u2033",
51
+ Psi: "\u03A8",
52
+ Rho: "\u03A1",
53
+ Scaron: "\u0160",
54
+ Sigma: "\u03A3",
55
+ THORN: "\u00DE",
56
+ Tau: "\u03A4",
57
+ Theta: "\u0398",
58
+ Uacute: "\u00DA",
59
+ Ucirc: "\u00DB",
60
+ Ugrave: "\u00D9",
61
+ Upsilon: "\u03A5",
62
+ Uuml: "\u00DC",
63
+ Xi: "\u039E",
64
+ Yacute: "\u00DD",
65
+ Yuml: "\u0178",
66
+ Zeta: "\u0396",
67
+ aacute: "\u00E1",
68
+ acirc: "\u00E2",
69
+ acute: "\u00B4",
70
+ aelig: "\u00E6",
71
+ agrave: "\u00E0",
72
+ alefsym: "\u2135",
73
+ alpha: "\u03B1",
10
74
  amp: "&",
75
+ and: "\u2227",
76
+ ang: "\u2220",
11
77
  apos: "'",
12
- auml: "ä",
13
- bdquo: "„",
14
- bull: "•",
15
- copy: "©",
16
- euro: "€",
78
+ aring: "\u00E5",
79
+ asymp: "\u2248",
80
+ atilde: "\u00E3",
81
+ auml: "\u00E4",
82
+ bdquo: "\u201E",
83
+ beta: "\u03B2",
84
+ brvbar: "\u00A6",
85
+ bull: "\u2022",
86
+ cap: "\u2229",
87
+ ccedil: "\u00E7",
88
+ cedil: "\u00B8",
89
+ cent: "\u00A2",
90
+ chi: "\u03C7",
91
+ circ: "\u02C6",
92
+ clubs: "\u2663",
93
+ cong: "\u2245",
94
+ copy: "\u00A9",
95
+ crarr: "\u21B5",
96
+ cup: "\u222A",
97
+ curren: "\u00A4",
98
+ dArr: "\u21D3",
99
+ dagger: "\u2020",
100
+ darr: "\u2193",
101
+ deg: "\u00B0",
102
+ delta: "\u03B4",
103
+ diams: "\u2666",
104
+ divide: "\u00F7",
105
+ eacute: "\u00E9",
106
+ ecirc: "\u00EA",
107
+ egrave: "\u00E8",
108
+ empty: "\u2205",
109
+ emsp: "\u2003",
110
+ ensp: "\u2002",
111
+ epsilon: "\u03B5",
112
+ equiv: "\u2261",
113
+ eta: "\u03B7",
114
+ eth: "\u00F0",
115
+ euml: "\u00EB",
116
+ euro: "\u20AC",
117
+ exist: "\u2203",
118
+ fnof: "\u0192",
119
+ forall: "\u2200",
120
+ frac12: "\u00BD",
121
+ frac14: "\u00BC",
122
+ frac34: "\u00BE",
123
+ frasl: "\u2044",
124
+ gamma: "\u03B3",
125
+ ge: "\u2265",
17
126
  gt: ">",
18
- hellip: "…",
19
- laquo: "«",
20
- ldquo: "“",
21
- lsquo: "‘",
127
+ hArr: "\u21D4",
128
+ harr: "\u2194",
129
+ hearts: "\u2665",
130
+ hellip: "\u2026",
131
+ iacute: "\u00ED",
132
+ icirc: "\u00EE",
133
+ iexcl: "\u00A1",
134
+ igrave: "\u00EC",
135
+ image: "\u2111",
136
+ infin: "\u221E",
137
+ int: "\u222B",
138
+ iota: "\u03B9",
139
+ iquest: "\u00BF",
140
+ isin: "\u2208",
141
+ iuml: "\u00EF",
142
+ kappa: "\u03BA",
143
+ lArr: "\u21D0",
144
+ lambda: "\u03BB",
145
+ lang: "\u2329",
146
+ laquo: "\u00AB",
147
+ larr: "\u2190",
148
+ lceil: "\u2308",
149
+ ldquo: "\u201C",
150
+ le: "\u2264",
151
+ lfloor: "\u230A",
152
+ lowast: "\u2217",
153
+ loz: "\u25CA",
154
+ lrm: "\u200E",
155
+ lsaquo: "\u2039",
156
+ lsquo: "\u2018",
22
157
  lt: "<",
23
- mdash: "—",
24
- middot: "·",
25
- nbsp: " ",
26
- ndash: "–",
27
- ouml: "ö",
158
+ macr: "\u00AF",
159
+ mdash: "\u2014",
160
+ micro: "\u00B5",
161
+ middot: "\u00B7",
162
+ minus: "\u2212",
163
+ mu: "\u03BC",
164
+ nabla: "\u2207",
165
+ nbsp: "\u00A0",
166
+ ndash: "\u2013",
167
+ ne: "\u2260",
168
+ ni: "\u220B",
169
+ not: "\u00AC",
170
+ notin: "\u2209",
171
+ nsub: "\u2284",
172
+ ntilde: "\u00F1",
173
+ nu: "\u03BD",
174
+ oacute: "\u00F3",
175
+ ocirc: "\u00F4",
176
+ oelig: "\u0153",
177
+ ograve: "\u00F2",
178
+ oline: "\u203E",
179
+ omega: "\u03C9",
180
+ omicron: "\u03BF",
181
+ oplus: "\u2295",
182
+ or: "\u2228",
183
+ ordf: "\u00AA",
184
+ ordm: "\u00BA",
185
+ oslash: "\u00F8",
186
+ otilde: "\u00F5",
187
+ otimes: "\u2297",
188
+ ouml: "\u00F6",
189
+ para: "\u00B6",
190
+ part: "\u2202",
191
+ permil: "\u2030",
192
+ perp: "\u22A5",
193
+ phi: "\u03C6",
194
+ pi: "\u03C0",
195
+ piv: "\u03D6",
196
+ plusmn: "\u00B1",
197
+ pound: "\u00A3",
198
+ prime: "\u2032",
199
+ prod: "\u220F",
200
+ prop: "\u221D",
201
+ psi: "\u03C8",
28
202
  quot: '"',
29
- raquo: "»",
30
- rdquo: "”",
31
- reg: "®",
32
- rsquo: "’",
33
- sbquo: "‚",
203
+ rArr: "\u21D2",
204
+ radic: "\u221A",
205
+ rang: "\u232A",
206
+ raquo: "\u00BB",
207
+ rarr: "\u2192",
208
+ rceil: "\u2309",
209
+ rdquo: "\u201D",
210
+ real: "\u211C",
211
+ reg: "\u00AE",
212
+ rfloor: "\u230B",
213
+ rho: "\u03C1",
214
+ rlm: "\u200F",
215
+ rsaquo: "\u203A",
216
+ rsquo: "\u2019",
217
+ sbquo: "\u201A",
218
+ scaron: "\u0161",
219
+ sdot: "\u22C5",
220
+ sect: "\u00A7",
34
221
  shy: "",
35
- szlig: "ß",
36
- trade: "™",
37
- uuml: "ü",
222
+ sigma: "\u03C3",
223
+ sigmaf: "\u03C2",
224
+ sim: "\u223C",
225
+ spades: "\u2660",
226
+ sub: "\u2282",
227
+ sube: "\u2286",
228
+ sum: "\u2211",
229
+ sup: "\u2283",
230
+ sup1: "\u00B9",
231
+ sup2: "\u00B2",
232
+ sup3: "\u00B3",
233
+ supe: "\u2287",
234
+ szlig: "\u00DF",
235
+ tau: "\u03C4",
236
+ there4: "\u2234",
237
+ theta: "\u03B8",
238
+ thetasym: "\u03D1",
239
+ thinsp: "\u2009",
240
+ thorn: "\u00FE",
241
+ tilde: "\u02DC",
242
+ times: "\u00D7",
243
+ trade: "\u2122",
244
+ uArr: "\u21D1",
245
+ uacute: "\u00FA",
246
+ uarr: "\u2191",
247
+ ucirc: "\u00FB",
248
+ ugrave: "\u00F9",
249
+ uml: "\u00A8",
250
+ upsih: "\u03D2",
251
+ upsilon: "\u03C5",
252
+ uuml: "\u00FC",
253
+ weierp: "\u2118",
254
+ xi: "\u03BE",
255
+ yacute: "\u00FD",
256
+ yen: "\u00A5",
257
+ yuml: "\u00FF",
258
+ zeta: "\u03B6",
259
+ zwj: "\u200D",
260
+ zwnj: "\u200C",
38
261
  }));
39
262
  const fromCodePoint = (value) => {
40
263
  try {
@@ -75,3 +298,11 @@ export const looksBinary = (text) => {
75
298
  const control = text.match(controlCharacters)?.length ?? 0;
76
299
  return (text.includes("\u0000") || control > 20 || control / text.length > 0.05);
77
300
  };
301
+ /**
302
+ * An attribute's value with its entities decoded. HTMLRewriter returns attributes as written in the
303
+ * source, so `href="?a=1&amp;b=2"` would otherwise keep its `&amp;`.
304
+ */
305
+ export const readAttribute = (element, name) => {
306
+ const value = element.getAttribute(name);
307
+ return value === null ? null : decodeEntities(value);
308
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citation-media/html-parser",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Streaming HTML parser for Cloudflare Workers: links, images, resources, and Markdown without a DOM, built on HTMLRewriter.",
5
5
  "keywords": [
6
6
  "cloudflare-workers",