@firedrill-tools/unstructured 0.1.4
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 +201 -0
- package/README.md +275 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/overloaded.scenario.json +11 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/run-response-lost.scenario.json +11 -0
- package/firedrill/small-responses.scenario.json +21 -0
- package/firedrill/tight-limits.scenario.json +21 -0
- package/firedrill/tools/unstructured/behavior.mjs +69 -0
- package/firedrill/tools/unstructured/lib/connectors.mjs +68 -0
- package/firedrill/tools/unstructured/lib/errors.mjs +58 -0
- package/firedrill/tools/unstructured/lib/gzip.mjs +38 -0
- package/firedrill/tools/unstructured/lib/identity.mjs +19 -0
- package/firedrill/tools/unstructured/lib/ids.mjs +36 -0
- package/firedrill/tools/unstructured/lib/jobs-derive.mjs +92 -0
- package/firedrill/tools/unstructured/lib/multipart.mjs +171 -0
- package/firedrill/tools/unstructured/lib/pages.mjs +40 -0
- package/firedrill/tools/unstructured/lib/partition/chunk.mjs +243 -0
- package/firedrill/tools/unstructured/lib/partition/csv.mjs +85 -0
- package/firedrill/tools/unstructured/lib/partition/csvout.mjs +25 -0
- package/firedrill/tools/unstructured/lib/partition/elements.mjs +171 -0
- package/firedrill/tools/unstructured/lib/partition/email.mjs +269 -0
- package/firedrill/tools/unstructured/lib/partition/html-tokens.mjs +134 -0
- package/firedrill/tools/unstructured/lib/partition/html-util.mjs +99 -0
- package/firedrill/tools/unstructured/lib/partition/html.mjs +211 -0
- package/firedrill/tools/unstructured/lib/partition/index.mjs +122 -0
- package/firedrill/tools/unstructured/lib/partition/markdown.mjs +220 -0
- package/firedrill/tools/unstructured/lib/partition/other.mjs +118 -0
- package/firedrill/tools/unstructured/lib/partition/text.mjs +53 -0
- package/firedrill/tools/unstructured/lib/sha256.mjs +161 -0
- package/firedrill/tools/unstructured/lib/store.mjs +33 -0
- package/firedrill/tools/unstructured/lib/util.mjs +149 -0
- package/firedrill/tools/unstructured/lib/validate.mjs +115 -0
- package/firedrill/tools/unstructured/lib/wire-multipart.mjs +78 -0
- package/firedrill/tools/unstructured/lib/wire.mjs +154 -0
- package/firedrill/tools/unstructured/ops/connectors.mjs +129 -0
- package/firedrill/tools/unstructured/ops/jobs.mjs +83 -0
- package/firedrill/tools/unstructured/ops/nodes.mjs +107 -0
- package/firedrill/tools/unstructured/ops/partition.mjs +112 -0
- package/firedrill/tools/unstructured/ops/workflows.mjs +180 -0
- package/firedrill/tools/unstructured/unstructured.tool.json +4892 -0
- package/firedrill/unstructured-archivist.drill.json +68 -0
- package/firedrill/unstructured-chunking.drill.json +67 -0
- package/firedrill/unstructured-connectors.drill.json +121 -0
- package/firedrill/unstructured-denied.drill.json +58 -0
- package/firedrill/unstructured-fresh-actor.drill.json +68 -0
- package/firedrill/unstructured-overloaded.drill.json +51 -0
- package/firedrill/unstructured-partition-errors.drill.json +66 -0
- package/firedrill/unstructured-partition.drill.json +95 -0
- package/firedrill/unstructured-rate-limited.drill.json +66 -0
- package/firedrill/unstructured-revoked-key.drill.json +773 -0
- package/firedrill/unstructured-run-lost.drill.json +51 -0
- package/firedrill/unstructured-small-responses.drill.json +173 -0
- package/firedrill/unstructured-tight-limits.drill.json +203 -0
- package/firedrill/unstructured-workflows-jobs.drill.json +167 -0
- package/firedrill/world.json +1556 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +1114 -0
- package/test/conformance.mjs +37 -0
- package/test/flows/access.mjs +54 -0
- package/test/flows/chunking.mjs +95 -0
- package/test/flows/connectors.mjs +76 -0
- package/test/flows/errors.mjs +115 -0
- package/test/flows/faults.mjs +73 -0
- package/test/flows/partition.mjs +225 -0
- package/test/flows/workflows.mjs +123 -0
- package/test/hostile-gen.mjs +0 -0
- package/test/hostile.mjs +155 -0
- package/test/lib.mjs +113 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// RFC 822 / RFC 5322 e-mail with RFC 2046 multipart bodies. Every scan is a single forward pass over lines with an index
|
|
2
|
+
// that strictly advances; malformed structure fails INVALID_FILE (422) instead of being guessed at.
|
|
3
|
+
import { partitionHtml } from "./html.mjs";
|
|
4
|
+
import { partitionText } from "./text.mjs";
|
|
5
|
+
import { base64Decode, utf8Decode } from "../util.mjs";
|
|
6
|
+
|
|
7
|
+
/** At most this many header lines (folded continuation lines included) in one header block. */
|
|
8
|
+
export const MAX_HEADER_LINES = 10000;
|
|
9
|
+
/** At most this many MIME parts in one message, all nesting levels together. */
|
|
10
|
+
export const MAX_PARTS = 1000;
|
|
11
|
+
/** A multipart body may nest multipart parts this deep (the message body itself is level 1). */
|
|
12
|
+
export const MAX_MULTIPART_DEPTH = 4;
|
|
13
|
+
/** RFC 2046 §5.1.1: a boundary is 1-70 characters from `bchars`, not ending in a space. */
|
|
14
|
+
const BOUNDARY_RE = /^[0-9A-Za-z'()+_,\-./:=? ]{0,69}[0-9A-Za-z'()+_,\-./:=?]$/;
|
|
15
|
+
/** RFC 5322 §2.2: a field name is printable US-ASCII except the colon. */
|
|
16
|
+
const FIELD_NAME_RE = /^[!-9;-~]{1,200}$/;
|
|
17
|
+
|
|
18
|
+
const invalid = (detail) => ({ ok: false, message: `File is not a valid eml: ${detail}` });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Header block of `text` starting at `start`: runs until the first empty line (consumed) or the end of the text; folded
|
|
22
|
+
* continuation lines are joined with one space. A line that is neither a field, a continuation nor empty fails.
|
|
23
|
+
* @returns { ok: true, headers: Map(lowercased name -> first value), bodyStart } or { ok: false, message }
|
|
24
|
+
*/
|
|
25
|
+
export function parseHeaderBlock(text, start) {
|
|
26
|
+
const headers = new Map();
|
|
27
|
+
let pos = start;
|
|
28
|
+
let last = null;
|
|
29
|
+
let lines = 0;
|
|
30
|
+
while (pos < text.length) {
|
|
31
|
+
const eol = text.indexOf("\n", pos);
|
|
32
|
+
const lineEnd = eol < 0 ? text.length : eol;
|
|
33
|
+
const next = eol < 0 ? text.length : eol + 1;
|
|
34
|
+
if (next <= pos) return invalid("header scan did not advance");
|
|
35
|
+
const line = text.slice(pos, lineEnd);
|
|
36
|
+
pos = next;
|
|
37
|
+
if (line.length === 0) return { ok: true, headers, bodyStart: pos };
|
|
38
|
+
lines += 1;
|
|
39
|
+
if (lines > MAX_HEADER_LINES) return invalid(`header block longer than ${MAX_HEADER_LINES} lines`);
|
|
40
|
+
if (line[0] === " " || line[0] === "\t") {
|
|
41
|
+
if (last === null) return invalid("continuation line before any header field");
|
|
42
|
+
if (last.first) headers.set(last.name, `${headers.get(last.name)} ${line.trim()}`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const colon = line.indexOf(":");
|
|
46
|
+
const name = colon > 0 ? line.slice(0, colon).trimEnd() : "";
|
|
47
|
+
if (!FIELD_NAME_RE.test(name)) return invalid("header block must end with a blank line before the body");
|
|
48
|
+
const key = name.toLowerCase();
|
|
49
|
+
const first = !headers.has(key);
|
|
50
|
+
if (first) headers.set(key, line.slice(colon + 1).trim());
|
|
51
|
+
last = { name: key, first };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, headers, bodyStart: text.length };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `type/subtype; name=value; name="quoted value"`: the media type and parameter names are lowercased, parameter values keep
|
|
58
|
+
* their case (RFC 2045 §5.1; boundaries are case-sensitive). One pass over the value; quoted-pair escapes are honoured.
|
|
59
|
+
* @returns { type, params: Map } or null when the value is not a media type
|
|
60
|
+
*/
|
|
61
|
+
export function parseContentType(value) {
|
|
62
|
+
const params = new Map();
|
|
63
|
+
let i = value.indexOf(";");
|
|
64
|
+
const type = (i < 0 ? value : value.slice(0, i)).trim().toLowerCase();
|
|
65
|
+
if (!/^[!#-'*+\-.0-9A-Z^-~]+\/[!#-'*+\-.0-9A-Z^-~]+$/i.test(type)) return null;
|
|
66
|
+
while (i >= 0 && i < value.length) {
|
|
67
|
+
i += 1; // past ';'
|
|
68
|
+
const eq = value.indexOf("=", i);
|
|
69
|
+
if (eq < 0) break;
|
|
70
|
+
const name = value.slice(i, eq).trim().toLowerCase();
|
|
71
|
+
let j = eq + 1;
|
|
72
|
+
while (j < value.length && (value[j] === " " || value[j] === "\t")) j += 1;
|
|
73
|
+
let paramValue = "";
|
|
74
|
+
if (value[j] === '"') {
|
|
75
|
+
j += 1;
|
|
76
|
+
const parts = [];
|
|
77
|
+
while (j < value.length && value[j] !== '"') {
|
|
78
|
+
if (value[j] === "\\" && j + 1 < value.length) j += 1;
|
|
79
|
+
parts.push(value[j]);
|
|
80
|
+
j += 1;
|
|
81
|
+
}
|
|
82
|
+
if (j >= value.length) return null; // unterminated quoted string
|
|
83
|
+
paramValue = parts.join("");
|
|
84
|
+
j += 1;
|
|
85
|
+
const semi = value.indexOf(";", j);
|
|
86
|
+
if (value.slice(j, semi < 0 ? value.length : semi).trim().length > 0) return null;
|
|
87
|
+
i = semi;
|
|
88
|
+
} else {
|
|
89
|
+
const semi = value.indexOf(";", j);
|
|
90
|
+
paramValue = value.slice(j, semi < 0 ? value.length : semi).trim();
|
|
91
|
+
i = semi;
|
|
92
|
+
}
|
|
93
|
+
if (name.length > 0 && !params.has(name)) params.set(name, paramValue);
|
|
94
|
+
}
|
|
95
|
+
return { type, params };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Splits a multipart body into its part strings. Delimiter lines start at a line start with `--boundary`, optionally `--`
|
|
100
|
+
* (the close delimiter), then only transport padding. The preamble before the first delimiter and the epilogue after the
|
|
101
|
+
* close delimiter are ignored (RFC 2046 §5.1.1).
|
|
102
|
+
* @returns { ok: true, parts: string[] } or { ok: false, message }
|
|
103
|
+
*/
|
|
104
|
+
export function splitMultipart(body, boundary, budget) {
|
|
105
|
+
const dash = `--${boundary}`;
|
|
106
|
+
const parts = [];
|
|
107
|
+
let pos = 0;
|
|
108
|
+
let partStart = -1;
|
|
109
|
+
while (pos < body.length) {
|
|
110
|
+
const eol = body.indexOf("\n", pos);
|
|
111
|
+
const lineEnd = eol < 0 ? body.length : eol;
|
|
112
|
+
const next = eol < 0 ? body.length : eol + 1;
|
|
113
|
+
if (next <= pos) return invalid("multipart scan did not advance");
|
|
114
|
+
if (lineEnd - pos >= dash.length && body.startsWith(dash, pos)) {
|
|
115
|
+
let k = pos + dash.length;
|
|
116
|
+
const close = body.startsWith("--", k);
|
|
117
|
+
if (close) k += 2;
|
|
118
|
+
while (k < lineEnd && (body[k] === " " || body[k] === "\t" || body[k] === "\r")) k += 1;
|
|
119
|
+
if (k === lineEnd) {
|
|
120
|
+
if (partStart >= 0) {
|
|
121
|
+
// The line break before a delimiter belongs to the delimiter.
|
|
122
|
+
parts.push(body.slice(partStart, Math.max(partStart, pos - 1)));
|
|
123
|
+
budget.parts += 1;
|
|
124
|
+
if (budget.parts > MAX_PARTS) return invalid(`more than ${MAX_PARTS} MIME parts`);
|
|
125
|
+
}
|
|
126
|
+
if (close) {
|
|
127
|
+
if (partStart < 0) return invalid("multipart body has a close delimiter before any part");
|
|
128
|
+
return { ok: true, parts };
|
|
129
|
+
}
|
|
130
|
+
if (eol < 0) return invalid(`boundary delimiter "${dash}" is not followed by a line break`);
|
|
131
|
+
partStart = next;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
pos = next;
|
|
135
|
+
}
|
|
136
|
+
return invalid(partStart < 0 ? `multipart body has no "${dash}" delimiter` : `multipart body has no closing "${dash}--" delimiter`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Walks one entity (headers already parsed) and returns the first text part in document order, depth first. Every multipart
|
|
141
|
+
* level is validated completely before a part is chosen, so a malformed later part fails even when an earlier text part exists.
|
|
142
|
+
* @returns { ok: true, part: { body, html } | null } or { ok: false, message }
|
|
143
|
+
*/
|
|
144
|
+
function firstTextPart(headers, body, depth, budget) {
|
|
145
|
+
const raw = headers.get("content-type");
|
|
146
|
+
const contentType = raw === undefined || raw.length === 0 ? { type: "text/plain", params: new Map() } : parseContentType(raw);
|
|
147
|
+
if (contentType === null) return invalid("malformed Content-Type header");
|
|
148
|
+
if (!contentType.type.startsWith("multipart/")) {
|
|
149
|
+
return { ok: true, part: contentType.type.startsWith("text/") ? { body, html: contentType.type === "text/html" } : null };
|
|
150
|
+
}
|
|
151
|
+
if (depth > MAX_MULTIPART_DEPTH) return invalid(`multipart parts nested deeper than ${MAX_MULTIPART_DEPTH} levels`);
|
|
152
|
+
const boundary = contentType.params.get("boundary");
|
|
153
|
+
if (boundary === undefined || boundary.length === 0) return invalid("multipart Content-Type has no boundary");
|
|
154
|
+
if (boundary.length > 70 || !BOUNDARY_RE.test(boundary)) return invalid("multipart boundary is not 1-70 RFC 2046 boundary characters");
|
|
155
|
+
const split = splitMultipart(body, boundary, budget);
|
|
156
|
+
if (!split.ok) return split;
|
|
157
|
+
let chosen = null;
|
|
158
|
+
for (const partText of split.parts) {
|
|
159
|
+
const parsed = parseHeaderBlock(partText, 0);
|
|
160
|
+
if (!parsed.ok) return parsed;
|
|
161
|
+
const inner = firstTextPart(parsed.headers, partText.slice(parsed.bodyStart), depth + 1, budget);
|
|
162
|
+
if (!inner.ok) return inner;
|
|
163
|
+
if (chosen === null && inner.part !== null) chosen = inner.part;
|
|
164
|
+
}
|
|
165
|
+
return { ok: true, part: chosen };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* RFC 5322 address list: commas split addresses only outside quoted strings, angle-addr brackets and comments. One pass.
|
|
170
|
+
*/
|
|
171
|
+
export function addresses(value) {
|
|
172
|
+
if (typeof value !== "string" || value.length === 0) return [];
|
|
173
|
+
const out = [];
|
|
174
|
+
let start = 0;
|
|
175
|
+
let quoted = false;
|
|
176
|
+
let angle = 0;
|
|
177
|
+
let comment = 0;
|
|
178
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
179
|
+
const c = value[i];
|
|
180
|
+
if (quoted) {
|
|
181
|
+
if (c === "\\") i += 1;
|
|
182
|
+
else if (c === '"') quoted = false;
|
|
183
|
+
} else if (c === "\\") i += 1;
|
|
184
|
+
else if (c === '"') quoted = true;
|
|
185
|
+
else if (c === "(") comment += 1;
|
|
186
|
+
else if (c === ")" && comment > 0) comment -= 1;
|
|
187
|
+
else if (c === "<" && comment === 0) angle += 1;
|
|
188
|
+
else if (c === ">" && angle > 0 && comment === 0) angle -= 1;
|
|
189
|
+
else if (c === "," && angle === 0 && comment === 0) {
|
|
190
|
+
out.push(value.slice(start, i));
|
|
191
|
+
start = i + 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
out.push(value.slice(start));
|
|
195
|
+
return out.map((a) => a.trim()).filter((a) => a.length > 0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const WORD_CHARSETS = new Map([["utf-8", "utf-8"], ["utf8", "utf-8"], ["us-ascii", "utf-8"], ["iso-8859-1", "latin1"], ["latin1", "latin1"]]);
|
|
199
|
+
const ENCODED_WORD = /=\?([A-Za-z0-9_\-]{1,40})(?:\*[A-Za-z0-9\-]{1,20})?\?([BbQq])\?([^?\s]*)\?=/g;
|
|
200
|
+
|
|
201
|
+
function decodeWord(charset, encoding, text) {
|
|
202
|
+
const target = WORD_CHARSETS.get(charset.toLowerCase());
|
|
203
|
+
if (target === undefined) return null;
|
|
204
|
+
let bytes;
|
|
205
|
+
if (encoding === "B" || encoding === "b") {
|
|
206
|
+
bytes = base64Decode(text);
|
|
207
|
+
if (bytes === null) return null;
|
|
208
|
+
} else {
|
|
209
|
+
bytes = [];
|
|
210
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
211
|
+
const code = text.charCodeAt(i);
|
|
212
|
+
if (text[i] === "_") bytes.push(32);
|
|
213
|
+
else if (text[i] === "=" && /^[0-9A-Fa-f]{2}$/.test(text.slice(i + 1, i + 3))) {
|
|
214
|
+
bytes.push(parseInt(text.slice(i + 1, i + 3), 16));
|
|
215
|
+
i += 2;
|
|
216
|
+
} else if (code < 128) bytes.push(code);
|
|
217
|
+
else return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (target === "latin1") {
|
|
221
|
+
let out = "";
|
|
222
|
+
for (const b of bytes) out += String.fromCharCode(b);
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
return utf8Decode(bytes);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** RFC 2047 encoded words (UTF-8, US-ASCII, ISO-8859-1; B and Q) in an unstructured header; whitespace between two encoded words is dropped. Undecodable words stay literal. */
|
|
229
|
+
export function decodeEncodedWords(value) {
|
|
230
|
+
if (typeof value !== "string" || !value.includes("=?")) return value;
|
|
231
|
+
let out = "";
|
|
232
|
+
let last = 0;
|
|
233
|
+
let previousWordEnd = -1;
|
|
234
|
+
for (const match of value.matchAll(ENCODED_WORD)) {
|
|
235
|
+
const decoded = decodeWord(match[1], match[2], match[3]);
|
|
236
|
+
if (decoded === null) continue;
|
|
237
|
+
const gap = value.slice(last, match.index);
|
|
238
|
+
out += previousWordEnd === last && /^[ \t]*$/.test(gap) ? "" : gap;
|
|
239
|
+
out += decoded;
|
|
240
|
+
last = match.index + match[0].length;
|
|
241
|
+
previousWordEnd = last;
|
|
242
|
+
}
|
|
243
|
+
return out + value.slice(last);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function partitionEmail(builder, input) {
|
|
247
|
+
// CRLF, LF and bare CR line endings may be mixed; each ends a line (as in Python's e-mail parser).
|
|
248
|
+
const text = input.indexOf("\r") >= 0 ? input.replace(/\r\n?/g, "\n") : input;
|
|
249
|
+
// An mbox "From " envelope line before the headers is skipped (Python's parser records it as the unix-from).
|
|
250
|
+
const headerStart = text.startsWith("From ") ? (text.indexOf("\n") < 0 ? text.length : text.indexOf("\n") + 1) : 0;
|
|
251
|
+
const top = parseHeaderBlock(text, headerStart);
|
|
252
|
+
if (!top.ok) return top;
|
|
253
|
+
const { headers } = top;
|
|
254
|
+
if (!headers.has("from") && !headers.has("subject") && !headers.has("to")) return { ok: false, message: "File is not a valid eml" };
|
|
255
|
+
const found = firstTextPart(headers, text.slice(top.bodyStart), 1, { parts: 0 });
|
|
256
|
+
if (!found.ok) return found;
|
|
257
|
+
// Address lists are split before encoded words are decoded, so an encoded comma never splits an address.
|
|
258
|
+
const list = (key) => addresses(headers.get(key)).map(decodeEncodedWords);
|
|
259
|
+
if (headers.has("subject")) headers.set("subject", decodeEncodedWords(headers.get("subject")));
|
|
260
|
+
const extra = { sent_from: list("from"), sent_to: list("to") };
|
|
261
|
+
if (headers.has("cc")) extra.cc_recipient = list("cc");
|
|
262
|
+
if (headers.has("bcc")) extra.bcc_recipient = list("bcc");
|
|
263
|
+
if (headers.has("subject")) extra.subject = headers.get("subject");
|
|
264
|
+
if (headers.has("message-id")) extra.email_message_id = headers.get("message-id");
|
|
265
|
+
builder.setExtraMetadata(extra);
|
|
266
|
+
if (headers.has("subject") && headers.get("subject").length > 0) builder.add("Title", headers.get("subject"), { category_depth: 0 });
|
|
267
|
+
if (found.part === null) return { ok: true };
|
|
268
|
+
return found.part.html ? partitionHtml(builder, found.part.body) : partitionText(builder, found.part.body);
|
|
269
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Linear, single-pass HTML tokenizer. One explicit index moves forward over the input; every scan is bounded by the tag
|
|
2
|
+
// or raw-text element it belongs to, nothing is lowercased or rescanned per tag, and no regular expression touches the
|
|
3
|
+
// caller's markup. Tokens are streamed to a sink so a document never needs a token array.
|
|
4
|
+
import { decodeEntities } from "./html-util.mjs";
|
|
5
|
+
|
|
6
|
+
const RAW_TEXT = new Set(["script", "style"]);
|
|
7
|
+
const WANTED_ATTRS = new Set(["class", "href", "alt", "src", "style"]);
|
|
8
|
+
|
|
9
|
+
const isSpace = (c) => c === 32 || c === 9 || c === 10 || c === 13 || c === 12;
|
|
10
|
+
const isNameChar = (c) => (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 45 || c === 58 || c === 95;
|
|
11
|
+
|
|
12
|
+
/** End index (exclusive) of the tag or attribute name starting at `i`. */
|
|
13
|
+
function readName(text, i) {
|
|
14
|
+
let j = i;
|
|
15
|
+
while (j < text.length && isNameChar(text.charCodeAt(j))) j += 1;
|
|
16
|
+
return j;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Parses attributes in `text[i..end)`; every scan stops at `end`, so the cost is the tag's own length. */
|
|
20
|
+
function readAttrs(text, i, end) {
|
|
21
|
+
const attrs = Object.create(null);
|
|
22
|
+
while (i < end) {
|
|
23
|
+
while (i < end && isSpace(text.charCodeAt(i))) i += 1;
|
|
24
|
+
if (i >= end || text.charCodeAt(i) === 47) break; // "/"
|
|
25
|
+
const nameEnd = readName(text, i);
|
|
26
|
+
if (nameEnd === i) {
|
|
27
|
+
i += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const name = text.slice(i, nameEnd).toLowerCase();
|
|
31
|
+
i = nameEnd;
|
|
32
|
+
while (i < end && isSpace(text.charCodeAt(i))) i += 1;
|
|
33
|
+
let value = "";
|
|
34
|
+
if (i < end && text.charCodeAt(i) === 61) { // "="
|
|
35
|
+
i += 1;
|
|
36
|
+
while (i < end && isSpace(text.charCodeAt(i))) i += 1;
|
|
37
|
+
const quote = i < end ? text.charCodeAt(i) : 0;
|
|
38
|
+
if (quote === 34 || quote === 39) {
|
|
39
|
+
let j = i + 1;
|
|
40
|
+
while (j < end && text.charCodeAt(j) !== quote) j += 1;
|
|
41
|
+
value = text.slice(i + 1, j);
|
|
42
|
+
i = j + 1;
|
|
43
|
+
} else {
|
|
44
|
+
let j = i;
|
|
45
|
+
while (j < end && !isSpace(text.charCodeAt(j))) j += 1;
|
|
46
|
+
value = text.slice(i, j);
|
|
47
|
+
i = j;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (WANTED_ATTRS.has(name) && attrs[name] === undefined) attrs[name] = decodeEntities(value);
|
|
51
|
+
}
|
|
52
|
+
return attrs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when `text[at..]` starts with `name` ignoring ASCII case and the name ends there. */
|
|
56
|
+
function matchesName(text, at, name) {
|
|
57
|
+
for (let k = 0; k < name.length; k += 1) {
|
|
58
|
+
const c = text.charCodeAt(at + k);
|
|
59
|
+
if (c !== name.charCodeAt(k) && (c | 32) !== name.charCodeAt(k)) return false;
|
|
60
|
+
}
|
|
61
|
+
return !isNameChar(text.charCodeAt(at + name.length));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Index of the `</name` closing a raw-text element, searched forward from `from` only (linear over the element). */
|
|
65
|
+
function findRawEnd(text, name, from) {
|
|
66
|
+
let at = text.indexOf("</", from);
|
|
67
|
+
while (at >= 0) {
|
|
68
|
+
if (matchesName(text, at + 2, name)) return at;
|
|
69
|
+
at = text.indexOf("</", at + 2);
|
|
70
|
+
}
|
|
71
|
+
return -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Streams tokens to `sink`: { kind: "text", text }, { kind: "open", name, attrs, selfClosing } or { kind: "close", name }.
|
|
76
|
+
* `sink` returns undefined to continue or an error message to stop. Returns { error } when the sink stopped or the
|
|
77
|
+
* markup has an unterminated tag, else {}.
|
|
78
|
+
*/
|
|
79
|
+
export function tokenize(html, sink) {
|
|
80
|
+
const n = html.length;
|
|
81
|
+
let i = 0;
|
|
82
|
+
let error;
|
|
83
|
+
const emit = (token) => {
|
|
84
|
+
error = sink(token);
|
|
85
|
+
return error === undefined;
|
|
86
|
+
};
|
|
87
|
+
while (i < n) {
|
|
88
|
+
const lt = html.indexOf("<", i);
|
|
89
|
+
if (lt < 0) {
|
|
90
|
+
if (!emit({ kind: "text", text: decodeEntities(html.slice(i)) })) return { error };
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (lt > i && !emit({ kind: "text", text: decodeEntities(html.slice(i, lt)) })) return { error };
|
|
94
|
+
if (html.startsWith("<!--", lt)) {
|
|
95
|
+
const end = html.indexOf("-->", lt + 4);
|
|
96
|
+
i = end < 0 ? n : end + 3;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
|
|
100
|
+
const end = html.indexOf(">", lt + 2);
|
|
101
|
+
i = end < 0 ? n : end + 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const closing = html.charCodeAt(lt + 1) === 47;
|
|
105
|
+
const nameStart = closing ? lt + 2 : lt + 1;
|
|
106
|
+
const nameEnd = readName(html, nameStart);
|
|
107
|
+
if (nameEnd === nameStart) {
|
|
108
|
+
if (!emit({ kind: "text", text: "<" })) return { error };
|
|
109
|
+
i = lt + 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const gt = html.indexOf(">", nameEnd);
|
|
113
|
+
if (gt < 0) return { error: "File is not valid html" };
|
|
114
|
+
const name = html.slice(nameStart, nameEnd).toLowerCase();
|
|
115
|
+
i = gt + 1;
|
|
116
|
+
if (closing) {
|
|
117
|
+
if (!emit({ kind: "close", name })) return { error };
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const selfClosing = html.charCodeAt(gt - 1) === 47 && gt - 1 >= nameEnd;
|
|
121
|
+
if (!emit({ kind: "open", name, attrs: readAttrs(html, nameEnd, selfClosing ? gt - 1 : gt), selfClosing })) return { error };
|
|
122
|
+
if (RAW_TEXT.has(name) && !selfClosing) {
|
|
123
|
+
const end = findRawEnd(html, name, i);
|
|
124
|
+
if (end < 0) {
|
|
125
|
+
i = n;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const close = html.indexOf(">", end);
|
|
129
|
+
if (!emit({ kind: "close", name })) return { error };
|
|
130
|
+
i = close < 0 ? n : close + 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// HTML escaping and entity decoding without regular expressions over caller text.
|
|
2
|
+
|
|
3
|
+
export function escapeHtml(text) {
|
|
4
|
+
let out = "";
|
|
5
|
+
for (const ch of text) {
|
|
6
|
+
if (ch === "&") out += "&";
|
|
7
|
+
else if (ch === "<") out += "<";
|
|
8
|
+
else if (ch === ">") out += ">";
|
|
9
|
+
else if (ch === '"') out += """;
|
|
10
|
+
else out += ch;
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NAMED = new Map([
|
|
16
|
+
["amp", "&"], ["lt", "<"], ["gt", ">"], ["quot", '"'], ["apos", "'"], ["nbsp", "\u00a0"], ["middot", "\u00b7"], ["copy", "\u00a9"], ["reg", "\u00ae"],
|
|
17
|
+
["trade", "\u2122"], ["mdash", "\u2014"], ["ndash", "\u2013"], ["hellip", "\u2026"], ["laquo", "\u00ab"], ["raquo", "\u00bb"], ["ldquo", "\u201c"],
|
|
18
|
+
["rdquo", "\u201d"], ["lsquo", "\u2018"], ["rsquo", "\u2019"], ["euro", "\u20ac"], ["pound", "\u00a3"], ["deg", "\u00b0"], ["times", "\u00d7"], ["bull", "\u2022"],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** Decodes named and numeric character references; unknown references are kept literally. */
|
|
22
|
+
export function decodeEntities(text) {
|
|
23
|
+
if (!text.includes("&")) return text;
|
|
24
|
+
let out = "";
|
|
25
|
+
let i = 0;
|
|
26
|
+
while (i < text.length) {
|
|
27
|
+
const ch = text[i];
|
|
28
|
+
if (ch !== "&") {
|
|
29
|
+
out += ch;
|
|
30
|
+
i += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// The `;` is searched within the next 12 code units only (the longest recognised reference), so a run of `&` without
|
|
34
|
+
// any `;` costs a bounded scan per `&` and the pass stays linear.
|
|
35
|
+
let end = -1;
|
|
36
|
+
const limit = Math.min(text.length, i + 13);
|
|
37
|
+
for (let j = i + 1; j < limit; j += 1)
|
|
38
|
+
if (text.charCodeAt(j) === 59) {
|
|
39
|
+
end = j;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
if (end < 0) {
|
|
43
|
+
out += ch;
|
|
44
|
+
i += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const name = text.slice(i + 1, end);
|
|
48
|
+
let decoded = null;
|
|
49
|
+
if (NAMED.has(name)) decoded = NAMED.get(name);
|
|
50
|
+
else if (name.length > 1 && name[0] === "#") {
|
|
51
|
+
const hex = name[1] === "x" || name[1] === "X";
|
|
52
|
+
const digits = name.slice(hex ? 2 : 1);
|
|
53
|
+
const valid = digits.length > 0 && digits.length <= 7 && [...digits].every((d) => (hex ? /[0-9A-Fa-f]/.test(d) : /[0-9]/.test(d)));
|
|
54
|
+
if (valid) {
|
|
55
|
+
const code = parseInt(digits, hex ? 16 : 10);
|
|
56
|
+
if (code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff)) decoded = String.fromCodePoint(code);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (decoded === null) {
|
|
60
|
+
out += ch;
|
|
61
|
+
i += 1;
|
|
62
|
+
} else {
|
|
63
|
+
out += decoded;
|
|
64
|
+
i = end + 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isCollapsible = (c) => c === 32 || c === 9 || c === 10 || c === 13 || c === 12 || c === 0xa0;
|
|
71
|
+
|
|
72
|
+
/** Runs of ASCII whitespace and NBSP become one space; leading and trailing runs are dropped. Linear, no regex. */
|
|
73
|
+
export function collapseSpace(text) {
|
|
74
|
+
const parts = [];
|
|
75
|
+
let start = -1;
|
|
76
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
77
|
+
if (isCollapsible(text.charCodeAt(i))) {
|
|
78
|
+
if (start >= 0) {
|
|
79
|
+
parts.push(text.slice(start, i));
|
|
80
|
+
start = -1;
|
|
81
|
+
}
|
|
82
|
+
} else if (start < 0) start = i;
|
|
83
|
+
}
|
|
84
|
+
if (start >= 0) parts.push(text.slice(start));
|
|
85
|
+
return parts.join(" ");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** True when the whitespace-separated `value` (an HTML class attribute) contains `token`. */
|
|
89
|
+
export function hasClassToken(value, token) {
|
|
90
|
+
let start = -1;
|
|
91
|
+
for (let i = 0; i <= value.length; i += 1) {
|
|
92
|
+
const space = i === value.length || isCollapsible(value.charCodeAt(i));
|
|
93
|
+
if (space) {
|
|
94
|
+
if (start >= 0 && i - start === token.length && value.startsWith(token, start)) return true;
|
|
95
|
+
start = -1;
|
|
96
|
+
} else if (start < 0) start = i;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|