spiderlint 0.0.0 → 0.143.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.
Files changed (186) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +244 -0
  3. package/bin/spiderlint.js +11 -0
  4. package/dist/agent.js +46 -0
  5. package/dist/cache/extractors.js +37 -0
  6. package/dist/cache/http.js +99 -0
  7. package/dist/cache/index.js +101 -0
  8. package/dist/cache/purge.js +63 -0
  9. package/dist/cache/status.js +52 -0
  10. package/dist/cli.js +602 -0
  11. package/dist/color.js +10 -0
  12. package/dist/config/environment.js +88 -0
  13. package/dist/config/index.js +104 -0
  14. package/dist/config/policy.js +170 -0
  15. package/dist/config/schema.js +127 -0
  16. package/dist/crawl/body.js +92 -0
  17. package/dist/crawl/browser.js +498 -0
  18. package/dist/crawl/crawl.js +58 -0
  19. package/dist/crawl/diversity.js +153 -0
  20. package/dist/crawl/dns.js +194 -0
  21. package/dist/crawl/fetch.js +48 -0
  22. package/dist/crawl/frontier.js +370 -0
  23. package/dist/crawl/guard.js +69 -0
  24. package/dist/crawl/http.js +171 -0
  25. package/dist/crawl/links.js +89 -0
  26. package/dist/crawl/log.js +27 -0
  27. package/dist/crawl/network.js +88 -0
  28. package/dist/crawl/probe.js +107 -0
  29. package/dist/crawl/profile.js +33 -0
  30. package/dist/crawl/rdap.js +86 -0
  31. package/dist/crawl/resolve.js +132 -0
  32. package/dist/crawl/resources.js +233 -0
  33. package/dist/crawl/robots.js +100 -0
  34. package/dist/crawl/route.js +92 -0
  35. package/dist/crawl/scope.js +32 -0
  36. package/dist/crawl/scratch.js +15 -0
  37. package/dist/crawl/sitemap.js +187 -0
  38. package/dist/crawl/svcb.js +59 -0
  39. package/dist/crawl/tls-hello.js +297 -0
  40. package/dist/crawl/tls-probe.js +71 -0
  41. package/dist/facts/browser.js +138 -0
  42. package/dist/facts/byline.js +17 -0
  43. package/dist/facts/co2.js +27 -0
  44. package/dist/facts/csp.js +71 -0
  45. package/dist/facts/export.js +79 -0
  46. package/dist/facts/flatten.js +33 -0
  47. package/dist/facts/graph.js +73 -0
  48. package/dist/facts/headers.js +330 -0
  49. package/dist/facts/html.js +230 -0
  50. package/dist/facts/labels.js +89 -0
  51. package/dist/facts/language.js +32 -0
  52. package/dist/facts/parity.js +38 -0
  53. package/dist/facts/read-note.js +11 -0
  54. package/dist/facts/resources.js +46 -0
  55. package/dist/facts/robots.js +13 -0
  56. package/dist/facts/sites.js +140 -0
  57. package/dist/facts/transport.js +200 -0
  58. package/dist/facts/types.js +4 -0
  59. package/dist/facts/vendors.js +76 -0
  60. package/dist/fold/index.js +94 -0
  61. package/dist/groups/assign.js +45 -0
  62. package/dist/groups/sample.js +60 -0
  63. package/dist/i18n.js +92 -0
  64. package/dist/index.js +607 -0
  65. package/dist/interrupt.js +37 -0
  66. package/dist/logger.js +95 -0
  67. package/dist/plugins/axe.js +104 -0
  68. package/dist/plugins/carbon-txt.js +96 -0
  69. package/dist/plugins/cookies-registry.js +25 -0
  70. package/dist/plugins/cookies.js +168 -0
  71. package/dist/plugins/css.js +328 -0
  72. package/dist/plugins/dns.js +864 -0
  73. package/dist/plugins/feed-content.js +132 -0
  74. package/dist/plugins/feed-model.js +282 -0
  75. package/dist/plugins/feeds.js +836 -0
  76. package/dist/plugins/html-validate.js +191 -0
  77. package/dist/plugins/htmlhint.js +95 -0
  78. package/dist/plugins/icons.js +302 -0
  79. package/dist/plugins/images.js +269 -0
  80. package/dist/plugins/index.js +286 -0
  81. package/dist/plugins/keyboard.js +194 -0
  82. package/dist/plugins/lighthouse.js +110 -0
  83. package/dist/plugins/link-text.js +73 -0
  84. package/dist/plugins/list.js +22 -0
  85. package/dist/plugins/live.js +254 -0
  86. package/dist/plugins/mail.js +985 -0
  87. package/dist/plugins/manifest.js +97 -0
  88. package/dist/plugins/markup.js +98 -0
  89. package/dist/plugins/messages.js +25 -0
  90. package/dist/plugins/network.js +192 -0
  91. package/dist/plugins/origin.js +488 -0
  92. package/dist/plugins/profile-links.js +102 -0
  93. package/dist/plugins/schema-registry.js +101 -0
  94. package/dist/plugins/sshfp.js +159 -0
  95. package/dist/plugins/stack.js +161 -0
  96. package/dist/plugins/structured-data.js +358 -0
  97. package/dist/plugins/tls-probe.js +298 -0
  98. package/dist/plugins/tls-registry.js +418 -0
  99. package/dist/plugins/tls-scan.js +280 -0
  100. package/dist/plugins/trackers-registry.js +27 -0
  101. package/dist/plugins/trackers.js +56 -0
  102. package/dist/plugins/types.js +7 -0
  103. package/dist/plugins/visit.js +50 -0
  104. package/dist/plugins/well-known-registry.js +111 -0
  105. package/dist/plugins/well-known.js +732 -0
  106. package/dist/progress.js +162 -0
  107. package/dist/report/agent.js +122 -0
  108. package/dist/report/checkstyle.js +22 -0
  109. package/dist/report/csv.js +18 -0
  110. package/dist/report/html.js +266 -0
  111. package/dist/report/human.js +220 -0
  112. package/dist/report/index.js +16 -0
  113. package/dist/report/json.js +8 -0
  114. package/dist/report/printable.js +29 -0
  115. package/dist/report/rating.js +26 -0
  116. package/dist/report/sarif.js +100 -0
  117. package/dist/report/stats.js +46 -0
  118. package/dist/rules/builtin.js +465 -0
  119. package/dist/rules/caching.js +103 -0
  120. package/dist/rules/catalog.js +139 -0
  121. package/dist/rules/clock.js +48 -0
  122. package/dist/rules/declarative.js +234 -0
  123. package/dist/rules/deprecated.js +45 -0
  124. package/dist/rules/disclosure.js +99 -0
  125. package/dist/rules/fix.js +21 -0
  126. package/dist/rules/i18n.js +131 -0
  127. package/dist/rules/insights.js +100 -0
  128. package/dist/rules/length.js +52 -0
  129. package/dist/rules/message.js +48 -0
  130. package/dist/rules/relations.js +88 -0
  131. package/dist/rules/robots.js +75 -0
  132. package/dist/rules/rulesets.js +134 -0
  133. package/dist/rules/run.js +75 -0
  134. package/dist/rules/score.js +61 -0
  135. package/dist/rules/types.js +7 -0
  136. package/dist/rules/url.js +90 -0
  137. package/dist/server/analytics.js +49 -0
  138. package/dist/server/api.js +134 -0
  139. package/dist/server/clients.js +54 -0
  140. package/dist/server/clock.js +33 -0
  141. package/dist/server/compress.js +58 -0
  142. package/dist/server/health.js +14 -0
  143. package/dist/server/jobs.js +82 -0
  144. package/dist/server/main.js +83 -0
  145. package/dist/server/page.js +161 -0
  146. package/dist/server/policy.js +145 -0
  147. package/dist/server/providers.js +60 -0
  148. package/dist/server/queue.js +50 -0
  149. package/dist/server/scan.js +39 -0
  150. package/dist/server/settings.js +197 -0
  151. package/dist/server/web.js +353 -0
  152. package/dist/server/worker.js +129 -0
  153. package/dist/store/disk.js +215 -0
  154. package/dist/store/memory.js +18 -0
  155. package/dist/telemetry-environment.js +9 -0
  156. package/dist/telemetry.js +104 -0
  157. package/locales/es/LC_MESSAGES/messages.po +1864 -0
  158. package/locales/uk/LC_MESSAGES/messages.po +1864 -0
  159. package/package.json +134 -2
  160. package/presets/browser.yaml +26 -0
  161. package/presets/graph.yaml +35 -0
  162. package/presets/i18n.yaml +18 -0
  163. package/presets/insights.yaml +12 -0
  164. package/presets/links.yaml +22 -0
  165. package/presets/parity.yaml +59 -0
  166. package/presets/performance.yaml +271 -0
  167. package/presets/recommended.yaml +50 -0
  168. package/presets/redirects.yaml +23 -0
  169. package/presets/resources.yaml +29 -0
  170. package/presets/robots.yaml +13 -0
  171. package/presets/security-headers.yaml +257 -0
  172. package/presets/seo.yaml +380 -0
  173. package/presets/sitemap.yaml +34 -0
  174. package/presets/suggestions.yaml +97 -0
  175. package/presets/sustainability.yaml +16 -0
  176. package/presets/tls.yaml +72 -0
  177. package/presets/vendor.yaml +14 -0
  178. package/presets/web-comprehensive.yaml +17 -0
  179. package/presets/web-quick.yaml +15 -0
  180. package/src/server/apple-touch-icon.png +0 -0
  181. package/src/server/logo-36.png +0 -0
  182. package/src/server/logo-72.png +0 -0
  183. package/src/server/logo.png +0 -0
  184. package/vendors/dns.yaml +21 -0
  185. package/vendors/paths.yaml +55 -0
  186. package/vendors/stack.yaml +57 -0
@@ -0,0 +1,132 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { Parser } from "htmlparser2";
5
+ const SAMPLE = 60;
6
+ const LITERAL = new Set(["pre", "code", "kbd", "samp", "script", "style"]);
7
+ const UNSAFE = new Set(["script", "iframe", "object", "embed", "form", "frame", "frameset", "applet", "base", "meta", "link"]);
8
+ const URL_ATTRIBUTES = { a: ["href"], img: ["src", "srcset"], source: ["src", "srcset"], video: ["src", "poster"], audio: ["src"], iframe: ["src"], link: ["href"] };
9
+ // Markdown signs, each counted once per item; two different signs make it Markdown.
10
+ const MARKDOWN = [
11
+ ["heading", /^#{1,6}[ \t]+\S/m],
12
+ ["bold", /\*\*[^*\n]+\*\*/],
13
+ ["fence", /^```/m],
14
+ ["link", /\[[^\]\n]+\]\((?:https?:|\/|\.\/|#)[^)\s]*\)/],
15
+ ["list", /^[ \t]*[-*+][ \t]+\S.*\n[ \t]*[-*+][ \t]+\S/m],
16
+ ["front matter", /^---\n(?:[\w-]+:.*\n)+---/],
17
+ ];
18
+ const MDX_STATEMENT = /^[ \t]*(?:import\s+[\w{}\s,*]+\s+from\s+["'][^"']+["']|export\s+(?:const|default|function)\s)/m;
19
+ const MDX_EXPRESSION = /(?<![{$])\{(?!\{)[ \t]*[A-Za-z_$][\w$.]*(?:\([^)]*\))?[ \t]*\}(?!\})/;
20
+ const TEMPLATE = /\{\{[^}]*\}\}|\{%[^%]*%\}|\$\{[^}]*\}|\[object Object\]/;
21
+ const NULLISH = /^(?:undefined|null|NaN)$/;
22
+ const ESCAPED_TAG = /<\/?(?:p|a|div|span|br|img|em|strong|ul|ol|li|h[1-6]|blockquote|figure|code|pre)\b[^>]*>/i;
23
+ const ESCAPED_ENTITY = /&(?:amp|lt|gt|quot|#\d+|#x[\da-f]+|[a-z]+);/i;
24
+ const MOJIBAKE = /Ã[\u{80}-\u{BF}]|â€[\u{80}-\u{BF}™œ“”˜]/u;
25
+ // The line of `text` holding the match of `pattern`.
26
+ const lineOf = (text, pattern) => {
27
+ const index = pattern.exec(text)?.index ?? 0;
28
+ const end = text.indexOf("\n", index);
29
+ return text.slice(text.lastIndexOf("\n", index) + 1, end === -1 ? undefined : end);
30
+ };
31
+ const clip = (text) => {
32
+ const flat = text.replaceAll(/\s+/g, " ").trim();
33
+ return flat.length > SAMPLE ? `${flat.slice(0, SAMPLE - 1)}…` : flat;
34
+ };
35
+ // Whether a URL attribute value resolves on its own, without the feed’s or the page’s address.
36
+ const isAbsolute = (value) => /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value.trim()) || value.trim() === "";
37
+ // Problems of one HTML or text body, each a short sample; HTML is parsed, never matched as a whole.
38
+ export function judgeContent(content) {
39
+ const found = {};
40
+ const add = (problem, sample) => {
41
+ const list = (found[problem] ??= []);
42
+ if (!list.includes(sample))
43
+ list.push(sample);
44
+ };
45
+ const prose = [];
46
+ if (content.type === "text") {
47
+ prose.push(content.value);
48
+ if (/<\/?[a-z][a-z\d]*(?:\s[^<>]*)?>/i.test(content.value))
49
+ add("content-type", `${content.field} is plain text but holds tags`);
50
+ }
51
+ else {
52
+ const literal = [];
53
+ let element = "";
54
+ let buffer = "";
55
+ // Judges the text gathered since the last tag, since the parser hands each decoded entity over on its own.
56
+ const flush = () => {
57
+ const text = buffer;
58
+ buffer = "";
59
+ if (!text)
60
+ return;
61
+ prose.push(text);
62
+ if (element && NULLISH.test(text.trim()))
63
+ add("template-leak", `<${element}>${text.trim()}</${element}>`);
64
+ if (ESCAPED_TAG.test(text))
65
+ add("double-escaped", clip(ESCAPED_TAG.exec(text)?.[0] ?? text));
66
+ if (ESCAPED_ENTITY.test(text))
67
+ add("double-escaped", ESCAPED_ENTITY.exec(text)?.[0] ?? text);
68
+ };
69
+ const parser = new Parser({
70
+ onopentag(name, attributes) {
71
+ flush();
72
+ const lower = name.toLowerCase();
73
+ element = lower;
74
+ if (LITERAL.has(lower))
75
+ literal.push(lower);
76
+ if (/^[A-Z]/.test(name) && literal.length === 0)
77
+ add("raw-markup", `<${name}>`);
78
+ if (UNSAFE.has(lower))
79
+ add("unsafe-html", `<${lower}>`);
80
+ for (const [attribute, value] of Object.entries(attributes)) {
81
+ if (/^on[a-z]+$/i.test(attribute))
82
+ add("unsafe-html", `${attribute} on <${lower}>`);
83
+ if (attribute === "style" && /position\s*:\s*(?:fixed|absolute)/i.test(value))
84
+ add("unsafe-html", `positioned style on <${lower}>`);
85
+ if (URL_ATTRIBUTES[lower]?.includes(attribute.toLowerCase()) && !content.base && value.split(",").some((part) => !isAbsolute(part.trim().split(/\s+/, 1)[0] ?? "")))
86
+ add("relative-url", `${attribute}="${clip(value)}"`);
87
+ }
88
+ },
89
+ ontext(text) {
90
+ if (literal.length === 0)
91
+ buffer += text;
92
+ },
93
+ onclosetag(name) {
94
+ flush();
95
+ if (literal.at(-1) === name.toLowerCase())
96
+ literal.pop();
97
+ element = "";
98
+ },
99
+ }, { lowerCaseTags: false, lowerCaseAttributeNames: false, decodeEntities: true, recognizeSelfClosing: true });
100
+ parser.write(content.value);
101
+ parser.end();
102
+ flush();
103
+ }
104
+ const text = prose.join("");
105
+ if (MDX_STATEMENT.test(text))
106
+ add("raw-markup", clip(MDX_STATEMENT.exec(text)?.[0] ?? ""));
107
+ if (MDX_EXPRESSION.test(text))
108
+ add("raw-markup", clip(MDX_EXPRESSION.exec(text)?.[0] ?? ""));
109
+ const signs = MARKDOWN.filter(([, pattern]) => pattern.test(text));
110
+ if (signs.length >= 2)
111
+ add("raw-markup", `Markdown ${signs.map(([name]) => name).join(", ")}: ${clip(lineOf(text, signs[0]?.[1] ?? /^/))}`);
112
+ if (TEMPLATE.test(text))
113
+ add("template-leak", clip(TEMPLATE.exec(text)?.[0] ?? ""));
114
+ if (MOJIBAKE.test(text))
115
+ add("mojibake", clip(MOJIBAKE.exec(text)?.[0] ?? ""));
116
+ return found;
117
+ }
118
+ // Problems of an item title, which every format holds as plain text unless Atom says otherwise.
119
+ export function judgeTitle(title, type) {
120
+ if (!title.trim())
121
+ return ["empty title"];
122
+ if (type !== "text")
123
+ return [];
124
+ const tag = /<\/?[a-z][a-z\d]*(?:\s[^<>]*)?>/i.exec(title)?.[0];
125
+ const entity = ESCAPED_ENTITY.exec(title)?.[0];
126
+ return [...(tag ? [`tag ${tag}`] : []), ...(entity ? [`entity ${entity}`] : []), ...(TEMPLATE.test(title) || NULLISH.test(title.trim()) ? [`template ${clip(title)}`] : [])];
127
+ }
128
+ // Visible words of a body, to compare an item with the page it links.
129
+ export function wordsOf(content) {
130
+ const text = content.type === "text" ? content.value : content.value.replaceAll(/<[^>]*>/g, " ");
131
+ return text.split(/\s+/).filter(Boolean).length;
132
+ }
@@ -0,0 +1,282 @@
1
+ // SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-only
4
+ import { SaxesParser } from "saxes";
5
+ import { resolve } from "../rules/builtin.js";
6
+ /* eslint-disable unicorn/prefer-https -- namespace names compare as exact strings, scheme included */
7
+ export const NS = {
8
+ atom: "http://www.w3.org/2005/Atom",
9
+ rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
10
+ rss1: "http://purl.org/rss/1.0/",
11
+ content: "http://purl.org/rss/1.0/modules/content/",
12
+ dc: "http://purl.org/dc/elements/1.1/",
13
+ itunes: "http://www.itunes.com/dtds/podcast-1.0.dtd",
14
+ podcast: "https://podcastindex.org/namespace/1.0",
15
+ history: "http://purl.org/syndication/history/1.0",
16
+ xml: "http://www.w3.org/XML/1998/namespace",
17
+ };
18
+ // Elements RSS 2.0 defines on a channel and on an item.
19
+ const RSS_CHANNEL = new Set(["title", "link", "description", "language", "copyright", "managingEditor", "webMaster", "pubDate", "lastBuildDate", "category", "generator", "docs", "cloud", "ttl", "image", "rating", "textInput", "skipHours", "skipDays", "item"]);
20
+ const RSS_ITEM = new Set(["title", "link", "description", "author", "category", "comments", "enclosure", "guid", "pubDate", "source"]);
21
+ const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
22
+ const escapeText = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
23
+ // The markup of a parsed element, as an `xhtml` construct’s content is read.
24
+ function serialize(node) {
25
+ const attributes = Object.entries(node.attributes)
26
+ .map(([name, value]) => ` ${name}="${escapeText(value).replaceAll('"', "&quot;")}"`)
27
+ .join("");
28
+ const inner = node.markup ?? "";
29
+ return !inner && VOID.has(node.local) ? `<${node.local}${attributes}/>` : `<${node.local}${attributes}>${inner}</${node.local}>`;
30
+ }
31
+ // The element tree of an XML document, `xml:base` resolved down it; throws on the first well-formedness error.
32
+ export function parseXml(body, base) {
33
+ const parser = new SaxesParser({ xmlns: true });
34
+ const stack = [];
35
+ const namespaces = new Set();
36
+ let root;
37
+ let stylesheet;
38
+ parser.on("processinginstruction", (instruction) => {
39
+ if (!root && instruction.target === "xml-stylesheet")
40
+ stylesheet ??= /\bhref\s*=\s*["']([^"']*)["']/.exec(instruction.body)?.[1];
41
+ });
42
+ parser.on("opentag", (tag) => {
43
+ const parent = stack.at(-1);
44
+ const attributes = Object.fromEntries(Object.values(tag.attributes).map((attribute) => [attribute.name, attribute.value]));
45
+ const bindings = Object.entries(tag.ns ?? {});
46
+ for (const [prefix, uri] of bindings)
47
+ if (prefix)
48
+ namespaces.add(uri);
49
+ const declared = attributes["xml:base"] ? resolve(attributes["xml:base"], parent?.base ?? base) : parent?.base;
50
+ const node = { local: tag.local, uri: tag.uri, attributes, children: [], text: "", ...(declared && { base: declared }) };
51
+ if (parent?.markup !== undefined)
52
+ node.markup = "";
53
+ if (parent && parent.uri === NS.atom && parent.attributes.type === "xhtml" && ["content", "summary", "title", "subtitle", "rights"].includes(parent.local))
54
+ node.markup = "";
55
+ parent?.children.push(node);
56
+ root ??= node;
57
+ stack.push(node);
58
+ });
59
+ const append = (chunk) => {
60
+ const node = stack.at(-1);
61
+ if (!node)
62
+ return;
63
+ node.text += chunk;
64
+ if (node.markup !== undefined)
65
+ node.markup += escapeText(chunk);
66
+ };
67
+ parser.on("text", append);
68
+ parser.on("cdata", append);
69
+ parser.on("closetag", () => {
70
+ const node = stack.pop();
71
+ const parent = stack.at(-1);
72
+ if (node && parent?.markup !== undefined)
73
+ parent.markup += serialize(node);
74
+ });
75
+ parser.write(body).close();
76
+ if (!root)
77
+ throw new Error("document has no root element");
78
+ return { root, ...(stylesheet && { stylesheet }), namespaces };
79
+ }
80
+ const childrenOf = (node, local, uri = "") => (node?.children ?? []).filter((child) => child.local === local && child.uri === uri);
81
+ const childOf = (node, local, uri = "") => childrenOf(node, local, uri)[0];
82
+ const textOf = (node) => node?.text.trim() || undefined;
83
+ const typeOf = (node) => (node?.attributes.type === "html" || node?.attributes.type === "xhtml" ? node.attributes.type : "text");
84
+ const dated = (node, field) => (textOf(node) ? { field, raw: textOf(node) } : undefined);
85
+ const emailsOf = (node, names) => names.flatMap((name) => childrenOf(node, name).flatMap((child) => (textOf(child) ? [{ field: name, raw: textOf(child) }] : [])));
86
+ // Un-namespaced children `known` does not name, once each.
87
+ const unknownIn = (node, known) => [...new Set((node?.children ?? []).filter((child) => child.uri === "" && !known.has(child.local)).map((child) => child.local))];
88
+ // Atom links of `node` by relation, the default relation `alternate`, resolved against the element’s base.
89
+ function atomLinks(node, base) {
90
+ const links = {};
91
+ for (const link of childrenOf(node, "link", NS.atom)) {
92
+ const relation = link.attributes.rel ?? "alternate";
93
+ (links[relation] ??= []).push(resolve(link.attributes.href ?? "", link.base ?? base));
94
+ }
95
+ return links;
96
+ }
97
+ // RFC 5005 archive relations a feed names.
98
+ function archivesOf(node, base) {
99
+ const links = atomLinks(node, base);
100
+ return Object.fromEntries(["current", "prev-archive", "next-archive"].flatMap((relation) => (links[relation]?.[0] ? [[relation, links[relation][0]]] : [])));
101
+ }
102
+ const itunesOf = (node) => Object.fromEntries((node?.children ?? []).filter((child) => child.uri === NS.itunes).map((child) => [child.local, child.attributes.href ?? child.attributes.text ?? child.text.trim()]));
103
+ // An RSS 2.0 or RSS 1.0 item.
104
+ function rssItem(node, position, isRdf) {
105
+ const ns = isRdf ? NS.rss1 : "";
106
+ const guid = childOf(node, "guid");
107
+ const id = textOf(guid) ?? node.attributes["rdf:about"];
108
+ const contents = [childOf(node, "description", ns), childOf(node, "encoded", NS.content)].flatMap((child) => (child && child.text.trim() ? [{ field: child.uri === NS.content ? "content:encoded" : "description", type: "html", value: child.text, ...(child.base && { base: child.base }) }] : []));
109
+ const title = childOf(node, "title", ns);
110
+ const link = textOf(childOf(node, "link", ns));
111
+ const missing = [...(!title && !childOf(node, "description", ns) ? ["title or description"] : []), ...(isRdf && !link ? ["link"] : [])];
112
+ const enclosures = childrenOf(node, "enclosure").map((child) => ({ url: child.attributes.url, length: child.attributes.length, type: child.attributes.type }));
113
+ return {
114
+ position,
115
+ ...(id && { id }),
116
+ isPermalink: !isRdf && guid !== undefined && guid.attributes.isPermaLink !== "false",
117
+ ...(link && { link }),
118
+ ...(title && { title: title.text.trim() }),
119
+ titleType: "text",
120
+ ...((dated(childOf(node, "pubDate"), "pubDate") ?? dated(childOf(node, "date", NS.dc), "dc:date")) && { published: dated(childOf(node, "pubDate"), "pubDate") ?? dated(childOf(node, "date", NS.dc), "dc:date") }),
121
+ hasAuthor: childOf(node, "author") !== undefined || childOf(node, "creator", NS.dc) !== undefined,
122
+ emails: emailsOf(node, ["author"]),
123
+ contents,
124
+ enclosures,
125
+ unknown: isRdf ? [] : unknownIn(node, RSS_ITEM),
126
+ missing,
127
+ durations: childrenOf(node, "duration", NS.itunes).map((child) => child.text.trim()),
128
+ };
129
+ }
130
+ // An RSS 2.0, 0.9x or 1.0 document.
131
+ function readRss(root, namespaces, base) {
132
+ const isRdf = root.uri === NS.rdf;
133
+ const ns = isRdf ? NS.rss1 : "";
134
+ const channel = childOf(root, "channel", ns);
135
+ const items = isRdf ? childrenOf(root, "item", NS.rss1) : childrenOf(channel, "item");
136
+ const links = atomLinks(channel, base);
137
+ const missing = [...(channel ? ["title", "link", "description"].filter((name) => !childOf(channel, name, ns)) : ["channel"]), ...(!isRdf && !root.attributes.version ? ["version attribute"] : [])];
138
+ const podcastGuid = textOf(childOf(channel, "guid", NS.podcast));
139
+ const locked = textOf(childOf(channel, "locked", NS.podcast));
140
+ // RSS 2.0 names the channel date `lastBuildDate`, and `pubDate` where it means the content date.
141
+ const updated = dated(childOf(channel, "lastBuildDate"), "lastBuildDate") ?? dated(childOf(channel, "pubDate"), "pubDate");
142
+ return {
143
+ format: "rss",
144
+ ...(root.attributes.version && { version: root.attributes.version }),
145
+ ...(textOf(childOf(channel, "title", ns)) && { title: textOf(childOf(channel, "title", ns)) }),
146
+ ...(textOf(childOf(channel, "link", ns)) && { link: textOf(childOf(channel, "link", ns)) }),
147
+ ...((textOf(childOf(channel, "language")) ?? textOf(childOf(channel, "language", NS.dc))) && { language: textOf(childOf(channel, "language")) ?? textOf(childOf(channel, "language", NS.dc)) }),
148
+ ...(updated && { updated }),
149
+ ...(textOf(childOf(channel, "ttl")) && { ttl: textOf(childOf(channel, "ttl")) }),
150
+ ...(links.self?.[0] && { self: links.self[0] }),
151
+ hubs: links.hub ?? [],
152
+ archives: archivesOf(channel, base),
153
+ isComplete: childOf(channel, "complete", NS.history) !== undefined,
154
+ hasAuthor: true,
155
+ emails: emailsOf(channel, ["managingEditor", "webMaster"]),
156
+ unknown: isRdf ? [] : unknownIn(channel, RSS_CHANNEL),
157
+ missing,
158
+ namespaces,
159
+ itunes: itunesOf(channel),
160
+ ...(podcastGuid && { podcastGuid }),
161
+ ...(locked !== undefined && { locked }),
162
+ items: items.map((node, index) => rssItem(node, index + 1, isRdf)),
163
+ };
164
+ }
165
+ // An Atom text construct as content.
166
+ function atomContent(node, field) {
167
+ if (!node || node.attributes.src)
168
+ return [];
169
+ const type = typeOf(node);
170
+ const value = type === "xhtml" ? (node.children.at(0)?.markup ?? node.markup ?? "") : node.text;
171
+ return value.trim() ? [{ field, type, value, ...(node.base && { base: node.base }) }] : [];
172
+ }
173
+ // An Atom entry.
174
+ function atomEntry(node, position, base) {
175
+ const title = childOf(node, "title", NS.atom);
176
+ const links = atomLinks(node, base);
177
+ const id = textOf(childOf(node, "id", NS.atom));
178
+ const hasAuthor = childOf(node, "author", NS.atom) !== undefined || childOf(childOf(node, "source", NS.atom), "author", NS.atom) !== undefined;
179
+ return {
180
+ position,
181
+ ...(id && { id }),
182
+ isPermalink: false,
183
+ ...(links.alternate?.[0] && { link: links.alternate[0] }),
184
+ ...(title && { title: title.text.trim() }),
185
+ titleType: typeOf(title),
186
+ ...(dated(childOf(node, "published", NS.atom), "published") && { published: dated(childOf(node, "published", NS.atom), "published") }),
187
+ ...(dated(childOf(node, "updated", NS.atom), "updated") && { updated: dated(childOf(node, "updated", NS.atom), "updated") }),
188
+ hasAuthor,
189
+ emails: [],
190
+ contents: [...atomContent(childOf(node, "content", NS.atom), "content"), ...atomContent(childOf(node, "summary", NS.atom), "summary")],
191
+ enclosures: childrenOf(node, "link", NS.atom)
192
+ .filter((link) => link.attributes.rel === "enclosure")
193
+ .map((link) => ({ url: link.attributes.href, length: link.attributes.length, type: link.attributes.type })),
194
+ unknown: [],
195
+ missing: ["id", "title", "updated"].filter((name) => !childOf(node, name, NS.atom)),
196
+ durations: [],
197
+ };
198
+ }
199
+ // An Atom document.
200
+ function readAtom(root, namespaces, base) {
201
+ const links = atomLinks(root, base);
202
+ const language = root.attributes["xml:lang"];
203
+ return {
204
+ format: "atom",
205
+ ...(textOf(childOf(root, "title", NS.atom)) && { title: textOf(childOf(root, "title", NS.atom)) }),
206
+ ...(links.alternate?.[0] && { link: links.alternate[0] }),
207
+ ...(language && { language }),
208
+ ...(dated(childOf(root, "updated", NS.atom), "updated") && { updated: dated(childOf(root, "updated", NS.atom), "updated") }),
209
+ ...(links.self?.[0] && { self: links.self[0] }),
210
+ hubs: links.hub ?? [],
211
+ archives: archivesOf(root, base),
212
+ isComplete: childOf(root, "complete", NS.history) !== undefined,
213
+ hasAuthor: childOf(root, "author", NS.atom) !== undefined,
214
+ emails: [],
215
+ unknown: [],
216
+ missing: ["id", "title", "updated"].filter((name) => !childOf(root, name, NS.atom)),
217
+ namespaces,
218
+ itunes: {},
219
+ items: childrenOf(root, "entry", NS.atom).map((node, index) => atomEntry(node, index + 1, base)),
220
+ };
221
+ }
222
+ // The XML model of an RSS or Atom document; throws on the first well-formedness error.
223
+ export function readXml(body, base) {
224
+ const { root, stylesheet, namespaces } = parseXml(body, base);
225
+ const model = root.uri === NS.atom && root.local === "feed" ? readAtom(root, namespaces, base) : readRss(root, namespaces, base);
226
+ return { ...model, ...(stylesheet && { stylesheet: resolve(stylesheet, base) }) };
227
+ }
228
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
229
+ const string = (value) => (typeof value === "string" && value.trim() ? value : undefined);
230
+ const hasAuthors = (value) => (Array.isArray(value.authors) && value.authors.length > 0) || isObject(value.author);
231
+ // A JSON Feed item.
232
+ function jsonItem(value, position) {
233
+ const item = isObject(value) ? value : {};
234
+ const id = typeof item.id === "number" ? String(item.id) : string(item.id);
235
+ const contents = [
236
+ ...(string(item.content_html) ? [{ field: "content_html", type: "html", value: item.content_html }] : []),
237
+ ...(string(item.content_text) ? [{ field: "content_text", type: "text", value: item.content_text }] : []),
238
+ ...(string(item.summary) ? [{ field: "summary", type: "text", value: item.summary }] : []),
239
+ ];
240
+ return {
241
+ position,
242
+ ...(id && { id }),
243
+ isPermalink: false,
244
+ ...(string(item.url) && { link: item.url }),
245
+ ...(string(item.title) && { title: item.title }),
246
+ titleType: "text",
247
+ ...(string(item.date_published) && { published: { field: "date_published", raw: item.date_published } }),
248
+ ...(string(item.date_modified) && { updated: { field: "date_modified", raw: item.date_modified } }),
249
+ hasAuthor: hasAuthors(item),
250
+ emails: [],
251
+ contents,
252
+ enclosures: (Array.isArray(item.attachments) ? item.attachments.filter(isObject) : []).map((attachment) => ({ url: string(attachment.url), length: attachment.size_in_bytes === undefined ? undefined : String(attachment.size_in_bytes), type: string(attachment.mime_type) })),
253
+ unknown: isObject(item.author) ? ["author"] : [],
254
+ missing: [...(id ? [] : ["id"]), ...(contents.some((content) => content.field.startsWith("content_")) ? [] : ["content_html or content_text"])],
255
+ durations: [],
256
+ };
257
+ }
258
+ // The model of a JSON Feed; undefined when the document is JSON but no JSON Feed.
259
+ export function readJson(body, base) {
260
+ const feed = JSON.parse(body);
261
+ if (!isObject(feed) || typeof feed.version !== "string" || !/^https:\/\/jsonfeed\.org\/version\//.test(feed.version))
262
+ return undefined;
263
+ const hubs = (Array.isArray(feed.hubs) ? feed.hubs.filter(isObject) : []).flatMap((hub) => (string(hub.url) ? [resolve(hub.url, base)] : []));
264
+ return {
265
+ format: "json",
266
+ version: feed.version,
267
+ ...(string(feed.title) && { title: feed.title }),
268
+ ...(string(feed.home_page_url) && { link: feed.home_page_url }),
269
+ ...(string(feed.language) && { language: feed.language }),
270
+ ...(string(feed.feed_url) && { self: resolve(feed.feed_url, base) }),
271
+ hubs,
272
+ archives: {},
273
+ isComplete: false,
274
+ hasAuthor: hasAuthors(feed),
275
+ emails: [],
276
+ unknown: isObject(feed.author) ? ["author"] : [],
277
+ missing: [...(string(feed.title) ? [] : ["title"]), ...(Array.isArray(feed.items) ? [] : ["items"])],
278
+ namespaces: new Set(),
279
+ itunes: {},
280
+ items: (Array.isArray(feed.items) ? feed.items : []).map((item, index) => jsonItem(item, index + 1)),
281
+ };
282
+ }