@crewhaus/tool-data 0.7.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.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * A hand-written XML / HTML-fragment reader.
3
+ *
4
+ * ## What it is
5
+ *
6
+ * A well-formedness-checking parser for the XML that shows up in API
7
+ * responses, RSS feeds, SVG, and configuration files. It is not a conformant
8
+ * XML processor and does not claim to be one.
9
+ *
10
+ * ## Supported
11
+ *
12
+ * - Elements, nesting, attributes (single- or double-quoted), self-closing
13
+ * tags, text content, and CDATA sections.
14
+ * - The five predefined entities (`& < > " '`) and
15
+ * numeric character references (`A`, `A`). A reference outside
16
+ * the Unicode range, or naming a surrogate, is an error rather than a
17
+ * crash.
18
+ * - Comments, processing instructions (`<?xml ... ?>`) and `<!DOCTYPE ...>`
19
+ * are skipped, not returned.
20
+ * - Namespace prefixes are kept verbatim as part of the name (`ns:tag`);
21
+ * they are not resolved to URIs.
22
+ * - In `html` mode, the void elements (`br`, `img`, `input`, `meta`, `link`,
23
+ * `hr`, `area`, `base`, `col`, `embed`, `param`, `source`, `track`, `wbr`)
24
+ * are treated as self-closing, unquoted attribute values are accepted, and
25
+ * tag names are compared case-insensitively.
26
+ *
27
+ * ## Not supported — each raises an error rather than guessing
28
+ *
29
+ * - DTD-declared entities, parameter entities and external entity references
30
+ * (so this parser cannot be made to fetch anything: there is no resolver).
31
+ * - Validation against a DTD or schema, `xml:space`, and attribute-value
32
+ * normalization beyond entity expansion.
33
+ * - Mismatched or unclosed tags are an error in `xml` mode. In `html` mode a
34
+ * mismatched close tag that matches an open ancestor closes the
35
+ * intervening elements, which is what browsers do; anything else is an
36
+ * error.
37
+ * - Raw-text elements (`<script>`, `<style>`) are parsed as ordinary
38
+ * elements, so `<` inside them must be escaped.
39
+ */
40
+ export class XmlError extends Error {
41
+ line;
42
+ constructor(message, line) {
43
+ super(message);
44
+ this.line = line;
45
+ }
46
+ }
47
+ export function isElement(node) {
48
+ return Object.hasOwn(node, "name");
49
+ }
50
+ const VOID_ELEMENTS = new Set([
51
+ "area",
52
+ "base",
53
+ "br",
54
+ "col",
55
+ "embed",
56
+ "hr",
57
+ "img",
58
+ "input",
59
+ "link",
60
+ "meta",
61
+ "param",
62
+ "source",
63
+ "track",
64
+ "wbr",
65
+ ]);
66
+ /** Parse a document or fragment into a list of top-level nodes. */
67
+ export function parseXml(text, options) {
68
+ const src = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
69
+ let i = 0;
70
+ let line = 1;
71
+ const root = [];
72
+ const stack = [];
73
+ const current = () => {
74
+ const top = stack[stack.length - 1];
75
+ return top === undefined ? root : top.children;
76
+ };
77
+ const advance = (to) => {
78
+ for (let k = i; k < to; k++)
79
+ if (src[k] === "\n")
80
+ line += 1;
81
+ i = to;
82
+ };
83
+ const pushText = (raw) => {
84
+ if (raw === "")
85
+ return;
86
+ const value = decodeEntities(raw, line);
87
+ if (options.trimWhitespace && value.trim() === "")
88
+ return;
89
+ current().push({ text: options.trimWhitespace ? value.trim() : value });
90
+ };
91
+ while (i < src.length) {
92
+ const lt = src.indexOf("<", i);
93
+ if (lt < 0) {
94
+ pushText(src.slice(i));
95
+ break;
96
+ }
97
+ if (lt > i) {
98
+ const raw = src.slice(i, lt);
99
+ advance(lt);
100
+ pushText(raw);
101
+ }
102
+ if (src.startsWith("<!--", i)) {
103
+ const end = src.indexOf("-->", i + 4);
104
+ if (end < 0)
105
+ throw new XmlError("unterminated comment", line);
106
+ advance(end + 3);
107
+ continue;
108
+ }
109
+ if (src.startsWith("<![CDATA[", i)) {
110
+ const end = src.indexOf("]]>", i + 9);
111
+ if (end < 0)
112
+ throw new XmlError("unterminated CDATA section", line);
113
+ const raw = src.slice(i + 9, end);
114
+ if (!(options.trimWhitespace && raw.trim() === ""))
115
+ current().push({ text: raw });
116
+ advance(end + 3);
117
+ continue;
118
+ }
119
+ if (src.startsWith("<?", i)) {
120
+ const end = src.indexOf("?>", i + 2);
121
+ if (end < 0)
122
+ throw new XmlError("unterminated processing instruction", line);
123
+ advance(end + 2);
124
+ continue;
125
+ }
126
+ if (src.startsWith("<!", i)) {
127
+ if (/^<!ENTITY/i.test(src.slice(i))) {
128
+ throw new XmlError("entity declarations are not supported", line);
129
+ }
130
+ const end = src.indexOf(">", i + 2);
131
+ if (end < 0)
132
+ throw new XmlError("unterminated declaration", line);
133
+ advance(end + 1);
134
+ continue;
135
+ }
136
+ if (src.startsWith("</", i)) {
137
+ const end = src.indexOf(">", i + 2);
138
+ if (end < 0)
139
+ throw new XmlError("unterminated close tag", line);
140
+ const rawName = src.slice(i + 2, end).trim();
141
+ const name = options.mode === "html" ? rawName.toLowerCase() : rawName;
142
+ const depth = findOpen(stack, name);
143
+ if (depth < 0) {
144
+ throw new XmlError(`close tag </${rawName}> has no matching open tag`, line);
145
+ }
146
+ if (depth !== stack.length - 1 && options.mode === "xml") {
147
+ const open = stack[stack.length - 1];
148
+ throw new XmlError(`expected </${open.name}>, found </${rawName}>`, line);
149
+ }
150
+ stack.length = depth;
151
+ advance(end + 1);
152
+ continue;
153
+ }
154
+ // An open tag.
155
+ const tag = readTag(src, i, line, options.mode);
156
+ const element = { name: tag.name, attributes: tag.attributes, children: [] };
157
+ current().push(element);
158
+ advance(tag.end);
159
+ const selfClosing = tag.selfClosing || (options.mode === "html" && VOID_ELEMENTS.has(tag.name.toLowerCase()));
160
+ if (!selfClosing) {
161
+ if (stack.length >= options.maxDepth) {
162
+ throw new XmlError(`nesting deeper than ${options.maxDepth} elements`, line);
163
+ }
164
+ stack.push(element);
165
+ }
166
+ }
167
+ if (stack.length > 0 && options.mode === "xml") {
168
+ const open = stack[stack.length - 1];
169
+ throw new XmlError(`element <${open.name}> is never closed`, line);
170
+ }
171
+ return root;
172
+ }
173
+ function findOpen(stack, name) {
174
+ for (let d = stack.length - 1; d >= 0; d--) {
175
+ if (stack[d].name === name)
176
+ return d;
177
+ }
178
+ return -1;
179
+ }
180
+ const NAME_CHAR = /[A-Za-z0-9_.:\-]/;
181
+ function readTag(src, start, line, mode) {
182
+ let i = start + 1;
183
+ const nameStart = i;
184
+ while (i < src.length && NAME_CHAR.test(src[i]))
185
+ i += 1;
186
+ if (i === nameStart)
187
+ throw new XmlError("expected an element name after '<'", line);
188
+ const raw = src.slice(nameStart, i);
189
+ const name = mode === "html" ? raw.toLowerCase() : raw;
190
+ const attributes = {};
191
+ for (;;) {
192
+ while (i < src.length && /\s/.test(src[i]))
193
+ i += 1;
194
+ if (i >= src.length)
195
+ throw new XmlError(`unterminated tag <${raw}>`, line);
196
+ if (src.startsWith("/>", i))
197
+ return { name, attributes, selfClosing: true, end: i + 2 };
198
+ if (src[i] === ">")
199
+ return { name, attributes, selfClosing: false, end: i + 1 };
200
+ const attrStart = i;
201
+ while (i < src.length && NAME_CHAR.test(src[i]))
202
+ i += 1;
203
+ if (i === attrStart) {
204
+ throw new XmlError(`unexpected ${JSON.stringify(src[i] ?? "")} in tag <${raw}>`, line);
205
+ }
206
+ const attrRaw = src.slice(attrStart, i);
207
+ const attrName = mode === "html" ? attrRaw.toLowerCase() : attrRaw;
208
+ while (i < src.length && /\s/.test(src[i]))
209
+ i += 1;
210
+ if (src[i] !== "=") {
211
+ // A bare attribute is HTML's `<input disabled>`; XML requires a value.
212
+ if (mode === "xml")
213
+ throw new XmlError(`attribute "${attrRaw}" has no value`, line);
214
+ attributes[attrName] = "";
215
+ continue;
216
+ }
217
+ i += 1;
218
+ while (i < src.length && /\s/.test(src[i]))
219
+ i += 1;
220
+ const quote = src[i];
221
+ if (quote === '"' || quote === "'") {
222
+ const end = src.indexOf(quote, i + 1);
223
+ if (end < 0)
224
+ throw new XmlError(`unterminated value for "${attrRaw}"`, line);
225
+ attributes[attrName] = decodeEntities(src.slice(i + 1, end), line);
226
+ i = end + 1;
227
+ continue;
228
+ }
229
+ if (mode === "xml")
230
+ throw new XmlError(`value for "${attrRaw}" must be quoted`, line);
231
+ const valueStart = i;
232
+ while (i < src.length && !/[\s>]/.test(src[i]))
233
+ i += 1;
234
+ attributes[attrName] = decodeEntities(src.slice(valueStart, i), line);
235
+ }
236
+ }
237
+ const NAMED_ENTITIES = {
238
+ amp: "&",
239
+ lt: "<",
240
+ gt: ">",
241
+ quot: '"',
242
+ apos: "'",
243
+ };
244
+ /** Expand the predefined entities and numeric references; anything else is an error. */
245
+ export function decodeEntities(text, line) {
246
+ if (!text.includes("&"))
247
+ return text;
248
+ let out = "";
249
+ for (let i = 0; i < text.length; i++) {
250
+ const c = text[i];
251
+ if (c !== "&") {
252
+ out += c;
253
+ continue;
254
+ }
255
+ const semi = text.indexOf(";", i + 1);
256
+ if (semi < 0 || semi - i > 12) {
257
+ throw new XmlError("a bare '&' must be written as '&amp;'", line);
258
+ }
259
+ const body = text.slice(i + 1, semi);
260
+ i = semi;
261
+ if (body.startsWith("#x") || body.startsWith("#X")) {
262
+ out += codePoint(body, Number.parseInt(body.slice(2), 16), line);
263
+ continue;
264
+ }
265
+ if (body.startsWith("#")) {
266
+ out += codePoint(body, Number.parseInt(body.slice(1), 10), line);
267
+ continue;
268
+ }
269
+ const named = NAMED_ENTITIES[body];
270
+ if (named === undefined) {
271
+ throw new XmlError(`unknown entity &${body}; — only the five predefined ones are known`, line);
272
+ }
273
+ out += named;
274
+ }
275
+ return out;
276
+ }
277
+ /**
278
+ * Turn a parsed character-reference number into its character, refusing
279
+ * anything Unicode has no code point for. Without the range check
280
+ * `String.fromCodePoint` throws a `RangeError`, which would escape the tool
281
+ * as a crash instead of arriving as a readable result — `&#-1;` and
282
+ * `&#x110000;` are a caller's malformed input, not a bug in the parser.
283
+ */
284
+ function codePoint(body, code, line) {
285
+ if (!Number.isInteger(code) || code < 0 || code > 0x10ffff) {
286
+ throw new XmlError(`character reference &${body}; is not a Unicode code point`, line);
287
+ }
288
+ if (code >= 0xd800 && code <= 0xdfff) {
289
+ throw new XmlError(`character reference &${body}; is an unpaired surrogate`, line);
290
+ }
291
+ return String.fromCodePoint(code);
292
+ }
293
+ /**
294
+ * Collapse the explicit tree into the compact shape most callers want:
295
+ * attributes under `@name`, text under `#text`, and repeated child elements
296
+ * as arrays. An element with one text child and no attributes becomes that
297
+ * string, which is what makes the result pleasant to query.
298
+ */
299
+ export function toCompact(nodes) {
300
+ const out = {};
301
+ const texts = [];
302
+ for (const node of nodes) {
303
+ if (!isElement(node)) {
304
+ texts.push(node.text);
305
+ continue;
306
+ }
307
+ const value = compactElement(node);
308
+ const existing = out[node.name];
309
+ if (existing === undefined)
310
+ out[node.name] = value;
311
+ else if (Array.isArray(existing))
312
+ existing.push(value);
313
+ else
314
+ out[node.name] = [existing, value];
315
+ }
316
+ const text = texts.join("").trim();
317
+ if (Object.keys(out).length === 0)
318
+ return text;
319
+ if (text !== "")
320
+ out["#text"] = text;
321
+ return out;
322
+ }
323
+ function compactElement(el) {
324
+ const attrs = Object.keys(el.attributes);
325
+ const inner = toCompact(el.children);
326
+ if (attrs.length === 0)
327
+ return inner;
328
+ const out = {};
329
+ for (const a of attrs)
330
+ out[`@${a}`] = el.attributes[a];
331
+ if (typeof inner === "string") {
332
+ if (inner !== "")
333
+ out["#text"] = inner;
334
+ return out;
335
+ }
336
+ Object.assign(out, inner);
337
+ return out;
338
+ }
339
+ /** All the text under a node, concatenated — the XPath `string()` of it. */
340
+ export function textContent(node) {
341
+ if (!isElement(node))
342
+ return node.text;
343
+ return node.children.map(textContent).join("");
344
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A hand-written YAML reader and writer for the subset that configuration
3
+ * files actually use. There is no YAML dependency here, so the subset below
4
+ * is exactly what is supported — read it as a contract, not a summary.
5
+ *
6
+ * ## Read: supported
7
+ *
8
+ * - Block mappings (`key: value`), nested by space indentation.
9
+ * - Block sequences (`- item`), including a compact map on the dash line
10
+ * (`- name: a`) and nested sequences.
11
+ * - Plain, single-quoted and double-quoted scalars. Double-quoted strings
12
+ * honour `\n \t \r \" \\ \/ \0` and `\uXXXX`.
13
+ * - Core-schema typing of plain scalars: `null`, `~` and an empty value are
14
+ * null; `true`/`false` are booleans; integers, decimals, exponents, `0x`
15
+ * and `0o` literals are numbers; everything else is a string.
16
+ * - Flow collections: `[1, two, {a: b}]` and `{a: 1, b: [2]}`, nested.
17
+ * - Block scalars `|` and `>`, with the `-` and `+` chomping indicators.
18
+ * - `#` comments, on their own line or after a value.
19
+ * - A leading `---` and a trailing `...`.
20
+ * - Quoted keys, and keys containing a colon when quoted.
21
+ *
22
+ * `.inf`, `-.inf` and `.nan` read as the JavaScript infinities and NaN, and
23
+ * write back out as `.inf` / `.nan`. JSON has no way to spell any of them,
24
+ * so a YAML-to-JSON conversion renders them `null` — round-tripping a
25
+ * document that uses them has to stay within YAML or TOML.
26
+ *
27
+ * ## Read: rejected with an explicit error, never guessed at
28
+ *
29
+ * Anchors and aliases (`&a`, `*a`), merge keys (`<<:`), explicit tags
30
+ * (`!!str`), multiple documents in one stream, complex keys (`? `), tab
31
+ * characters in indentation, and dates — a date stays a string, because
32
+ * silently producing a `Date` would make the round trip lossy.
33
+ *
34
+ * ## Write
35
+ *
36
+ * Block style only, two-space indent, keys in their existing order. Strings
37
+ * are quoted whenever leaving them bare would change how they read back
38
+ * (empty, padded, or looking like a number, boolean or null). Multi-line
39
+ * strings are written double-quoted with `\n` escapes rather than as block
40
+ * scalars, so the output is unambiguous.
41
+ */
42
+ export declare class YamlError extends Error {
43
+ readonly line: number;
44
+ constructor(message: string, line: number);
45
+ }
46
+ /** Parse a YAML document into JSON values. Throws `YamlError` with a line number. */
47
+ export declare function parseYaml(text: string): unknown;
48
+ /** Find the `:` that separates a key from its value, ignoring quotes and flow markers. */
49
+ export declare function findKeyColon(content: string): number;
50
+ /** Remove a trailing ` # comment`, respecting quotes. */
51
+ export declare function stripComment(text: string): string;
52
+ /** Parse a single value: flow collection, quoted string, or plain scalar. */
53
+ export declare function parseScalarValue(text: string, lineNo: number): unknown;
54
+ /** Core-schema typing for an unquoted scalar. */
55
+ export declare function plainScalar(t: string): unknown;
56
+ /** Parse a flow collection (`[...]` or `{...}`) including nesting. */
57
+ export declare function parseFlow(text: string, lineNo: number): unknown;
58
+ /** True when a string can be written bare without changing meaning on re-read. */
59
+ export declare function canWritePlain(s: string): boolean;
60
+ /** Serialize a JSON value as block-style YAML with a two-space indent. */
61
+ export declare function stringifyYaml(value: unknown, indentLevel?: number): string;