@citation-media/html-parser 0.1.0 → 0.1.1

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
@@ -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();
package/dist/parse.js CHANGED
@@ -58,16 +58,27 @@ const hiddenFromMarkdown = [
58
58
  const maxHeadings = 200;
59
59
  const maxImages = 500;
60
60
  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) => {
61
+ /**
62
+ * Follows an element to its end tag. HTMLRewriter keeps only the last end-tag handler an element
63
+ * gets, so every handler adds its action to the element's list and registers the whole list; the
64
+ * last registration then runs them all. Void elements such as <img> have no end tag, and
65
+ * HTMLRewriter throws for them; elements closed implicitly, such as <li> without </li>, end with
66
+ * their parent.
67
+ */
68
+ const enter = (pass, element, onEnd) => {
69
+ const actions = [...pass.endActions, onEnd];
64
70
  try {
65
- element.onEndTag(onEnd);
66
- return true;
71
+ element.onEndTag(() => {
72
+ for (const action of actions) {
73
+ action();
74
+ }
75
+ });
67
76
  }
68
77
  catch {
69
78
  return false;
70
79
  }
80
+ pass.endActions = actions;
81
+ return true;
71
82
  };
72
83
  const clean = (text) => collapseWhitespace(removeHyphenation(decodeEntities(text))).trim();
73
84
  const addLink = (links, found, base) => {
@@ -96,7 +107,7 @@ const collecting = (pass) => pass.inContext > 0;
96
107
  const writing = (pass) => collecting(pass) && pass.hidden === 0 && pass.markdown !== undefined;
97
108
  const hide = (pass) => ({
98
109
  element(element) {
99
- if (enter(element, () => (pass.hidden -= 1))) {
110
+ if (enter(pass, element, () => (pass.hidden -= 1))) {
100
111
  pass.hidden += 1;
101
112
  }
102
113
  },
@@ -108,7 +119,7 @@ const onStructure = (rewriter, pass) => {
108
119
  next = next.on(context, {
109
120
  element(element) {
110
121
  pass.contextFound = true;
111
- if (enter(element, () => (pass.inContext -= 1))) {
122
+ if (enter(pass, element, () => (pass.inContext -= 1))) {
112
123
  pass.inContext += 1;
113
124
  }
114
125
  },
@@ -138,7 +149,7 @@ const onStructure = (rewriter, pass) => {
138
149
  return;
139
150
  }
140
151
  pass.markdown?.open(tag);
141
- enter(element, () => {
152
+ enter(pass, element, () => {
142
153
  if (writing(pass)) {
143
154
  pass.markdown?.close(tag);
144
155
  }
@@ -165,7 +176,7 @@ const onLinks = (rewriter, pass) => rewriter.on("a[href]", {
165
176
  if (written) {
166
177
  pass.markdown?.openLink(url);
167
178
  }
168
- enter(element, () => {
179
+ enter(pass, element, () => {
169
180
  if (written) {
170
181
  pass.markdown?.closeLink();
171
182
  }
@@ -251,7 +262,7 @@ const onMeta = (rewriter, pass) => rewriter
251
262
  .on("title", {
252
263
  element(element) {
253
264
  pass.title = [];
254
- enter(element, () => {
265
+ enter(pass, element, () => {
255
266
  pass.meta.title = clean((pass.title ?? []).join(""));
256
267
  pass.title = undefined;
257
268
  });
@@ -282,7 +293,7 @@ const onHeadings = (rewriter, pass) => rewriter.on("h1, h2, h3, h4, h5, h6", {
282
293
  const level = Number(element.tagName.slice(1));
283
294
  pass.headingCounts[level - 1] = (pass.headingCounts[level - 1] ?? 0) + 1;
284
295
  pass.heading = { level, text: [] };
285
- enter(element, () => {
296
+ enter(pass, element, () => {
286
297
  const text = clean(pass.heading?.text.join("") ?? "");
287
298
  if (pass.heading && text && pass.headings.length < maxHeadings) {
288
299
  pass.headings.push({ level: pass.heading.level, text });
@@ -338,6 +349,7 @@ export const parseHtml = async (input, options) => {
338
349
  base: new URL(options.url),
339
350
  classCounts: Object.fromEntries((options.classPrefixes ?? []).map((prefix) => [prefix, 0])),
340
351
  contextFound: false,
352
+ endActions: [],
341
353
  heading: undefined,
342
354
  headingCounts: [0, 0, 0, 0, 0, 0],
343
355
  headings: [],
@@ -356,7 +368,17 @@ export const parseHtml = async (input, options) => {
356
368
  resources: { frames: [], links: [], scripts: [] },
357
369
  title: undefined,
358
370
  };
359
- let rewriter = onImages(onLinks(onStructure(new HTMLRewriter(), pass), pass), pass);
371
+ // An empty page has nothing to stream; some runtimes never finish transforming an empty body.
372
+ if (!(input instanceof Response) && input.trim() === "") {
373
+ return resultOf(pass);
374
+ }
375
+ // Registered first, so every element starts with an empty list of end-tag actions.
376
+ const first = new HTMLRewriter().on("*", {
377
+ element() {
378
+ pass.endActions = [];
379
+ },
380
+ });
381
+ let rewriter = onImages(onLinks(onStructure(first, pass), pass), pass);
360
382
  if (options.resources) {
361
383
  rewriter = onResources(rewriter, pass);
362
384
  }
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.1",
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",