@eksml/xml 0.1.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 (63) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +588 -0
  3. package/dist/converters/fromLossless.d.mts +14 -0
  4. package/dist/converters/fromLossless.d.mts.map +1 -0
  5. package/dist/converters/fromLossless.mjs +35 -0
  6. package/dist/converters/fromLossless.mjs.map +1 -0
  7. package/dist/converters/fromLossy.d.mts +18 -0
  8. package/dist/converters/fromLossy.d.mts.map +1 -0
  9. package/dist/converters/fromLossy.mjs +91 -0
  10. package/dist/converters/fromLossy.mjs.map +1 -0
  11. package/dist/converters/lossless.d.mts +39 -0
  12. package/dist/converters/lossless.d.mts.map +1 -0
  13. package/dist/converters/lossless.mjs +74 -0
  14. package/dist/converters/lossless.mjs.map +1 -0
  15. package/dist/converters/lossy.d.mts +42 -0
  16. package/dist/converters/lossy.d.mts.map +1 -0
  17. package/dist/converters/lossy.mjs +158 -0
  18. package/dist/converters/lossy.mjs.map +1 -0
  19. package/dist/htmlConstants-D6fsKbZ-.mjs +30 -0
  20. package/dist/htmlConstants-D6fsKbZ-.mjs.map +1 -0
  21. package/dist/parser-BfdEfWDg.d.mts +95 -0
  22. package/dist/parser-BfdEfWDg.d.mts.map +1 -0
  23. package/dist/parser-CYq309aR.mjs +479 -0
  24. package/dist/parser-CYq309aR.mjs.map +1 -0
  25. package/dist/parser.d.mts +2 -0
  26. package/dist/parser.mjs +2 -0
  27. package/dist/sax.d.mts +64 -0
  28. package/dist/sax.d.mts.map +1 -0
  29. package/dist/sax.mjs +70 -0
  30. package/dist/sax.mjs.map +1 -0
  31. package/dist/saxEngine-BDnD7ruG.mjs +750 -0
  32. package/dist/saxEngine-BDnD7ruG.mjs.map +1 -0
  33. package/dist/utilities/index.d.mts +88 -0
  34. package/dist/utilities/index.d.mts.map +1 -0
  35. package/dist/utilities/index.mjs +87 -0
  36. package/dist/utilities/index.mjs.map +1 -0
  37. package/dist/writer.d.mts +58 -0
  38. package/dist/writer.d.mts.map +1 -0
  39. package/dist/writer.mjs +357 -0
  40. package/dist/writer.mjs.map +1 -0
  41. package/dist/xmlParseStream.d.mts +138 -0
  42. package/dist/xmlParseStream.d.mts.map +1 -0
  43. package/dist/xmlParseStream.mjs +313 -0
  44. package/dist/xmlParseStream.mjs.map +1 -0
  45. package/package.json +100 -0
  46. package/src/converters/fromLossless.ts +80 -0
  47. package/src/converters/fromLossy.ts +180 -0
  48. package/src/converters/lossless.ts +116 -0
  49. package/src/converters/lossy.ts +274 -0
  50. package/src/parser.ts +728 -0
  51. package/src/sax.ts +157 -0
  52. package/src/saxEngine.ts +1157 -0
  53. package/src/utilities/escapeRegExp.ts +19 -0
  54. package/src/utilities/filter.ts +63 -0
  55. package/src/utilities/getElementById.ts +21 -0
  56. package/src/utilities/getElementsByClassName.ts +22 -0
  57. package/src/utilities/htmlConstants.ts +26 -0
  58. package/src/utilities/index.ts +7 -0
  59. package/src/utilities/isElementNode.ts +19 -0
  60. package/src/utilities/isTextNode.ts +19 -0
  61. package/src/utilities/toContentString.ts +23 -0
  62. package/src/writer.ts +650 -0
  63. package/src/xmlParseStream.ts +597 -0
@@ -0,0 +1,313 @@
1
+ import { n as HTML_VOID_ELEMENTS, t as HTML_RAW_CONTENT_TAGS } from "./htmlConstants-D6fsKbZ-.mjs";
2
+ import { t as saxEngine } from "./saxEngine-BDnD7ruG.mjs";
3
+ import { convertItemToLossy } from "./converters/lossy.mjs";
4
+ import { convertItemToLossless } from "./converters/lossless.mjs";
5
+ //#region src/xmlParseStream.ts
6
+ const EQ = 61;
7
+ const SQUOTE = 39;
8
+ const DQUOTE = 34;
9
+ const TAB = 9;
10
+ const LF = 10;
11
+ const CR = 13;
12
+ const SPACE = 32;
13
+ /**
14
+ * Convert a SAX `Attributes` object into the format used by `parse()`:
15
+ * - Returns `null` when there are no attributes.
16
+ * - Returns an `Object.create(null)` prototype-free record otherwise.
17
+ */
18
+ function toNodeAttributes(attributes) {
19
+ const keys = Object.keys(attributes);
20
+ if (keys.length === 0) return null;
21
+ const out = Object.create(null);
22
+ for (let i = 0; i < keys.length; i++) out[keys[i]] = attributes[keys[i]];
23
+ return out;
24
+ }
25
+ /**
26
+ * Parse a processing instruction body string into an attributes record.
27
+ * e.g. `version="1.0" encoding="UTF-8"` -> `{ version: "1.0", encoding: "UTF-8" }`
28
+ *
29
+ * This replicates the attribute-parsing behavior of `parse()` so that PIs
30
+ * emitted by `XmlParseStream` match the `{ tagName: "?xml", attributes: {...} }`
31
+ * format that consumers expect.
32
+ *
33
+ * Returns `null` when the body contains no attributes, matching `parse()`.
34
+ * Uses `Object.create(null)` for prototype-free attribute records.
35
+ */
36
+ function parsePIAttributes(body) {
37
+ let attributes = null;
38
+ const bodyLength = body.length;
39
+ let i = 0;
40
+ while (i < bodyLength) {
41
+ let charCode = body.charCodeAt(i);
42
+ if (charCode === SPACE || charCode === TAB || charCode === LF || charCode === CR) {
43
+ i++;
44
+ continue;
45
+ }
46
+ const nameStart = i;
47
+ while (i < bodyLength) {
48
+ charCode = body.charCodeAt(i);
49
+ if (charCode === EQ || charCode === SPACE || charCode === TAB || charCode === LF || charCode === CR) break;
50
+ i++;
51
+ }
52
+ if (i === nameStart) {
53
+ i++;
54
+ continue;
55
+ }
56
+ const name = body.substring(nameStart, i);
57
+ if (attributes === null) attributes = Object.create(null);
58
+ while (i < bodyLength) {
59
+ charCode = body.charCodeAt(i);
60
+ if (charCode !== SPACE && charCode !== TAB && charCode !== LF && charCode !== CR) break;
61
+ i++;
62
+ }
63
+ if (i < bodyLength && body.charCodeAt(i) === EQ) {
64
+ i++;
65
+ while (i < bodyLength) {
66
+ charCode = body.charCodeAt(i);
67
+ if (charCode !== SPACE && charCode !== TAB && charCode !== LF && charCode !== CR) break;
68
+ i++;
69
+ }
70
+ if (i < bodyLength) {
71
+ const quoteCharCode = body.charCodeAt(i);
72
+ if (quoteCharCode === DQUOTE || quoteCharCode === SQUOTE) {
73
+ const quoteCharacter = body[i];
74
+ i++;
75
+ const valueStartIndex = i;
76
+ const end = body.indexOf(quoteCharacter, i);
77
+ if (end === -1) {
78
+ attributes[name] = body.substring(valueStartIndex);
79
+ i = bodyLength;
80
+ } else {
81
+ attributes[name] = body.substring(valueStartIndex, end);
82
+ i = end + 1;
83
+ }
84
+ } else {
85
+ const valueStartIndex = i;
86
+ while (i < bodyLength) {
87
+ charCode = body.charCodeAt(i);
88
+ if (charCode === SPACE || charCode === TAB || charCode === LF || charCode === CR) break;
89
+ i++;
90
+ }
91
+ attributes[name] = body.substring(valueStartIndex, i);
92
+ }
93
+ } else attributes[name] = null;
94
+ } else attributes[name] = null;
95
+ }
96
+ return attributes;
97
+ }
98
+ /**
99
+ * A Web Streams `TransformStream` that incrementally parses XML chunks into
100
+ * `TNode` subtrees (or lossy/lossless objects). Works in browsers, Node.js
101
+ * 18+, Deno, and Bun.
102
+ *
103
+ * Follows the platform stream class convention (like `TextDecoderStream`,
104
+ * `DecompressionStream`, etc.) — instantiate with `new` and use with
105
+ * `.pipeThrough()`.
106
+ *
107
+ * Internally powered by the SAX engine with a tree-construction layer
108
+ * that assembles `TNode` subtrees and emits them as they complete.
109
+ *
110
+ * By default, nodes are emitted when a top-level element closes (depth 0).
111
+ * Use the `select` option to emit specific elements as they close at any depth,
112
+ * without waiting for the entire document root to finish.
113
+ *
114
+ * Use the `output` option to choose the emitted format:
115
+ * - `'dom'` (default) — raw `TNode | string`
116
+ * - `'lossy'` — compact lossy objects (`LossyValue`)
117
+ * - `'lossless'` — order-preserving objects (`LosslessEntry`)
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * import { XmlParseStream } from '@eksml/xml/stream';
122
+ *
123
+ * const response = await fetch('/feed.xml');
124
+ * const reader = response.body
125
+ * .pipeThrough(new TextDecoderStream())
126
+ * .pipeThrough(new XmlParseStream())
127
+ * .getReader();
128
+ *
129
+ * while (true) {
130
+ * const { done, value } = await reader.read();
131
+ * if (done) break;
132
+ * console.log(value); // TNode or string
133
+ * }
134
+ * ```
135
+ */
136
+ var XmlParseStream = class extends TransformStream {
137
+ constructor(options) {
138
+ const resolvedOptions = options ?? {};
139
+ let skipBytes = typeof resolvedOptions.offset === "string" ? resolvedOptions.offset.length : resolvedOptions.offset || 0;
140
+ const isHtml = resolvedOptions.html === true;
141
+ const selfClosingTags = resolvedOptions.selfClosingTags ?? (isHtml ? HTML_VOID_ELEMENTS : []);
142
+ const rawContentTags = resolvedOptions.rawContentTags ?? (isHtml ? HTML_RAW_CONTENT_TAGS : []);
143
+ const keepComments = resolvedOptions.keepComments === true;
144
+ const selectSet = resolvedOptions.select == null ? null : typeof resolvedOptions.select === "string" ? new Set([resolvedOptions.select]) : resolvedOptions.select.length > 0 ? new Set(resolvedOptions.select) : null;
145
+ const outputMode = resolvedOptions.output ?? "dom";
146
+ const convert = outputMode === "lossy" ? convertItemToLossy : outputMode === "lossless" ? convertItemToLossless : ((item) => item);
147
+ let streamController = null;
148
+ const stack = [];
149
+ let depth = 0;
150
+ const selectDepths = [];
151
+ function currentParent() {
152
+ return stack.length > 0 ? stack[stack.length - 1] : null;
153
+ }
154
+ function emitOrAttach(item) {
155
+ const parent = currentParent();
156
+ if (parent) parent.children.push(item);
157
+ else if (streamController) streamController.enqueue(convert(item));
158
+ }
159
+ /** Whether we are currently inside a selected subtree. */
160
+ function insideSelection() {
161
+ return selectDepths.length > 0;
162
+ }
163
+ /** The TNode at the top of the stack, or null. */
164
+ function selectParent() {
165
+ return stack.length > 0 ? stack[stack.length - 1] : null;
166
+ }
167
+ function defaultOnOpenTag(tagName, attributes) {
168
+ const node = {
169
+ tagName,
170
+ attributes: toNodeAttributes(attributes),
171
+ children: []
172
+ };
173
+ const parent = currentParent();
174
+ if (parent) parent.children.push(node);
175
+ stack.push(node);
176
+ }
177
+ function defaultOnCloseTag(_tagName) {
178
+ const node = stack.pop();
179
+ if (!node) return;
180
+ if (stack.length === 0 && streamController) streamController.enqueue(convert(node));
181
+ }
182
+ function defaultOnText(text) {
183
+ const parent = currentParent();
184
+ if (parent) parent.children.push(text);
185
+ }
186
+ function defaultOnCdata(data) {
187
+ const parent = currentParent();
188
+ if (parent) parent.children.push(data);
189
+ }
190
+ function defaultOnComment(comment) {
191
+ if (!keepComments) return;
192
+ const parent = currentParent();
193
+ if (parent) parent.children.push(comment);
194
+ else if (streamController) streamController.enqueue(convert(comment));
195
+ }
196
+ function defaultOnProcessingInstruction(name, body) {
197
+ emitOrAttach({
198
+ tagName: "?" + name,
199
+ attributes: parsePIAttributes(body),
200
+ children: []
201
+ });
202
+ }
203
+ function defaultOnDoctype(tagName, attributes) {
204
+ emitOrAttach({
205
+ tagName,
206
+ attributes: toNodeAttributes(attributes),
207
+ children: []
208
+ });
209
+ }
210
+ function selectOnOpenTag(tagName, attributes) {
211
+ depth++;
212
+ if (insideSelection()) {
213
+ const node = {
214
+ tagName,
215
+ attributes: toNodeAttributes(attributes),
216
+ children: []
217
+ };
218
+ const parent = selectParent();
219
+ if (parent) parent.children.push(node);
220
+ stack.push(node);
221
+ if (selectSet.has(tagName)) selectDepths.push(depth);
222
+ } else if (selectSet.has(tagName)) {
223
+ selectDepths.push(depth);
224
+ const node = {
225
+ tagName,
226
+ attributes: toNodeAttributes(attributes),
227
+ children: []
228
+ };
229
+ stack.push(node);
230
+ }
231
+ }
232
+ function selectOnCloseTag(_tagName) {
233
+ if (insideSelection()) {
234
+ const topSelectDepth = selectDepths[selectDepths.length - 1];
235
+ if (depth === topSelectDepth) {
236
+ const node = stack.pop();
237
+ if (node && streamController) streamController.enqueue(convert(node));
238
+ selectDepths.pop();
239
+ } else stack.pop();
240
+ }
241
+ depth--;
242
+ }
243
+ function selectOnText(text) {
244
+ if (!insideSelection()) return;
245
+ const parent = selectParent();
246
+ if (parent) parent.children.push(text);
247
+ }
248
+ function selectOnCdata(data) {
249
+ if (!insideSelection()) return;
250
+ const parent = selectParent();
251
+ if (parent) parent.children.push(data);
252
+ }
253
+ function selectOnComment(comment) {
254
+ if (!keepComments) return;
255
+ if (!insideSelection()) return;
256
+ const parent = selectParent();
257
+ if (parent) parent.children.push(comment);
258
+ }
259
+ function selectOnProcessingInstruction(name, body) {
260
+ if (!insideSelection()) return;
261
+ const node = {
262
+ tagName: "?" + name,
263
+ attributes: parsePIAttributes(body),
264
+ children: []
265
+ };
266
+ const parent = selectParent();
267
+ if (parent) parent.children.push(node);
268
+ }
269
+ function selectOnDoctype(tagName, attributes) {
270
+ if (!insideSelection()) return;
271
+ const node = {
272
+ tagName,
273
+ attributes: toNodeAttributes(attributes),
274
+ children: []
275
+ };
276
+ const parent = selectParent();
277
+ if (parent) parent.children.push(node);
278
+ }
279
+ const parser = saxEngine({
280
+ selfClosingTags,
281
+ rawContentTags,
282
+ onOpenTag: selectSet ? selectOnOpenTag : defaultOnOpenTag,
283
+ onCloseTag: selectSet ? selectOnCloseTag : defaultOnCloseTag,
284
+ onText: selectSet ? selectOnText : defaultOnText,
285
+ onCdata: selectSet ? selectOnCdata : defaultOnCdata,
286
+ onComment: selectSet ? selectOnComment : defaultOnComment,
287
+ onProcessingInstruction: selectSet ? selectOnProcessingInstruction : defaultOnProcessingInstruction,
288
+ onDoctype: selectSet ? selectOnDoctype : defaultOnDoctype
289
+ });
290
+ super({
291
+ transform(chunk, controller) {
292
+ streamController = controller;
293
+ if (skipBytes > 0) {
294
+ if (chunk.length <= skipBytes) {
295
+ skipBytes -= chunk.length;
296
+ return;
297
+ }
298
+ chunk = chunk.substring(skipBytes);
299
+ skipBytes = 0;
300
+ }
301
+ parser.write(chunk);
302
+ },
303
+ flush(controller) {
304
+ streamController = controller;
305
+ parser.close();
306
+ }
307
+ });
308
+ }
309
+ };
310
+ //#endregion
311
+ export { XmlParseStream };
312
+
313
+ //# sourceMappingURL=xmlParseStream.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xmlParseStream.mjs","names":[],"sources":["../src/xmlParseStream.ts"],"sourcesContent":["import type { TNode } from '#src/parser.ts';\nimport { saxEngine } from '#src/saxEngine.ts';\nimport type { Attributes } from '#src/saxEngine.ts';\nimport {\n HTML_VOID_ELEMENTS,\n HTML_RAW_CONTENT_TAGS,\n} from '#src/utilities/htmlConstants.ts';\nimport { convertItemToLossy } from '#src/converters/lossy.ts';\nimport type { LossyValue } from '#src/converters/lossy.ts';\nimport { convertItemToLossless } from '#src/converters/lossless.ts';\nimport type { LosslessEntry } from '#src/converters/lossless.ts';\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Options for `XmlParseStream`.\n *\n * Only the fields that the transform layer actually consumes are exposed here.\n * This keeps the API honest — options like `trimWhitespace`, `entities`, and\n * `strict` belong to the synchronous `parse()` function, not the streaming\n * tree-builder.\n */\nexport interface XmlParseStreamOptions {\n /**\n * Starting byte offset — skip this many leading characters from the input.\n * When a string is passed, its `.length` is used as the offset.\n */\n offset?: number | string;\n /** Enable HTML parsing mode (sets default selfClosingTags/rawContentTags). */\n html?: boolean;\n /**\n * Tag names that are self-closing (void elements).\n * Defaults to standard HTML void elements when `html` is `true`, else `[]`.\n */\n selfClosingTags?: string[];\n /**\n * Tag names whose content is raw text (not parsed as XML/HTML).\n * Defaults to `[\"script\", \"style\"]` when `html` is `true`, else `[]`.\n */\n rawContentTags?: string[];\n /** Keep XML comments in the output. Defaults to `false`. */\n keepComments?: boolean;\n /**\n * Emit only elements matching these tag names instead of waiting for the\n * entire top-level tree to close.\n *\n * When set, each matching element is emitted as a standalone `TNode` subtree\n * the moment its close tag is encountered, regardless of nesting depth.\n * Non-matching ancestor elements are **not** built or emitted — the stream\n * only produces the selected subtrees.\n *\n * When multiple selected tags are nested (e.g. selecting both `item` and\n * `sub` where `<sub>` appears inside `<item>`), each matching element\n * is emitted independently as it closes. The inner element appears both as a\n * separate emission **and** as a child within its ancestor's subtree.\n *\n * Accepts a single tag name or an array of tag names.\n *\n * @example\n * ```ts\n * // Given:\n * // <root>\n * // <item>\n * // <sub>1</sub><box>a</box>\n * // </item>\n * // <item>\n * // <sub>2</sub><box>b</box>\n * // </item>\n * // </root>\n * //\n * // Without select: emits one big <root> TNode after </root>\n * // With select: \"item\": emits two <item> TNodes as each closes\n * const stream = new XmlParseStream({ select: 'item' });\n *\n * // Nested selection: emits each <sub> as it closes, then the\n * // containing <item> (which still includes the <sub> as a child).\n * const stream2 = new XmlParseStream({ select: ['item', 'sub'] });\n * ```\n */\n select?: string | string[];\n /**\n * Output format for emitted chunks.\n *\n * - `'dom'` (default) — emit raw `TNode | string` values.\n * - `'lossy'` — convert each item to the compact lossy format (`LossyValue`).\n * - `'lossless'` — convert each item to the order-preserving lossless format\n * (`LosslessEntry`).\n */\n output?: 'dom' | 'lossy' | 'lossless';\n}\n\n// Re-export converter output types so consumers can import them from the\n// same entry point when using `output: 'lossy'` or `output: 'lossless'`.\nexport type { LossyValue, LosslessEntry };\n// @generated:char-codes:begin\nconst EQ = 61; // =\nconst SQUOTE = 39; // '\nconst DQUOTE = 34; // \"\nconst TAB = 9; // \\t\nconst LF = 10; // \\n\nconst CR = 13; // \\r\nconst SPACE = 32; // (space)\n// @generated:char-codes:end\n\n/**\n * Convert a SAX `Attributes` object into the format used by `parse()`:\n * - Returns `null` when there are no attributes.\n * - Returns an `Object.create(null)` prototype-free record otherwise.\n */\nfunction toNodeAttributes(\n attributes: Attributes,\n): Record<string, string | null> | null {\n const keys = Object.keys(attributes);\n if (keys.length === 0) return null;\n const out: Record<string, string | null> = Object.create(null);\n for (let i = 0; i < keys.length; i++) {\n out[keys[i]!] = attributes[keys[i]!]!;\n }\n return out;\n}\n\n/**\n * Parse a processing instruction body string into an attributes record.\n * e.g. `version=\"1.0\" encoding=\"UTF-8\"` -> `{ version: \"1.0\", encoding: \"UTF-8\" }`\n *\n * This replicates the attribute-parsing behavior of `parse()` so that PIs\n * emitted by `XmlParseStream` match the `{ tagName: \"?xml\", attributes: {...} }`\n * format that consumers expect.\n *\n * Returns `null` when the body contains no attributes, matching `parse()`.\n * Uses `Object.create(null)` for prototype-free attribute records.\n */\nfunction parsePIAttributes(body: string): Record<string, string | null> | null {\n let attributes: Record<string, string | null> | null = null;\n const bodyLength = body.length;\n let i = 0;\n\n while (i < bodyLength) {\n // Skip whitespace\n let charCode = body.charCodeAt(i);\n if (\n charCode === SPACE ||\n charCode === TAB ||\n charCode === LF ||\n charCode === CR\n ) {\n i++;\n continue;\n }\n\n // Read attribute name\n const nameStart = i;\n while (i < bodyLength) {\n charCode = body.charCodeAt(i);\n if (\n charCode === EQ ||\n charCode === SPACE ||\n charCode === TAB ||\n charCode === LF ||\n charCode === CR\n )\n break;\n i++;\n }\n if (i === nameStart) {\n i++;\n continue;\n }\n const name = body.substring(nameStart, i);\n\n // Allocate attributes object lazily on first attribute\n if (attributes === null) attributes = Object.create(null);\n\n // Skip whitespace\n while (i < bodyLength) {\n charCode = body.charCodeAt(i);\n if (\n charCode !== SPACE &&\n charCode !== TAB &&\n charCode !== LF &&\n charCode !== CR\n )\n break;\n i++;\n }\n\n // Check for =\n if (i < bodyLength && body.charCodeAt(i) === EQ) {\n i++; // skip =\n // Skip whitespace\n while (i < bodyLength) {\n charCode = body.charCodeAt(i);\n if (\n charCode !== SPACE &&\n charCode !== TAB &&\n charCode !== LF &&\n charCode !== CR\n )\n break;\n i++;\n }\n // Read value\n if (i < bodyLength) {\n const quoteCharCode = body.charCodeAt(i);\n if (quoteCharCode === DQUOTE || quoteCharCode === SQUOTE) {\n const quoteCharacter = body[i]!;\n i++; // skip opening quote\n const valueStartIndex = i;\n const end = body.indexOf(quoteCharacter, i);\n if (end === -1) {\n attributes![name] = body.substring(valueStartIndex);\n i = bodyLength;\n } else {\n attributes![name] = body.substring(valueStartIndex, end);\n i = end + 1;\n }\n } else {\n // Unquoted value — read until whitespace\n const valueStartIndex = i;\n while (i < bodyLength) {\n charCode = body.charCodeAt(i);\n if (\n charCode === SPACE ||\n charCode === TAB ||\n charCode === LF ||\n charCode === CR\n )\n break;\n i++;\n }\n attributes![name] = body.substring(valueStartIndex, i);\n }\n } else {\n attributes![name] = null;\n }\n } else {\n // Boolean attribute (no value)\n attributes![name] = null;\n }\n }\n\n return attributes;\n}\n\n/**\n * A Web Streams `TransformStream` that incrementally parses XML chunks into\n * `TNode` subtrees (or lossy/lossless objects). Works in browsers, Node.js\n * 18+, Deno, and Bun.\n *\n * Follows the platform stream class convention (like `TextDecoderStream`,\n * `DecompressionStream`, etc.) — instantiate with `new` and use with\n * `.pipeThrough()`.\n *\n * Internally powered by the SAX engine with a tree-construction layer\n * that assembles `TNode` subtrees and emits them as they complete.\n *\n * By default, nodes are emitted when a top-level element closes (depth 0).\n * Use the `select` option to emit specific elements as they close at any depth,\n * without waiting for the entire document root to finish.\n *\n * Use the `output` option to choose the emitted format:\n * - `'dom'` (default) — raw `TNode | string`\n * - `'lossy'` — compact lossy objects (`LossyValue`)\n * - `'lossless'` — order-preserving objects (`LosslessEntry`)\n *\n * @example\n * ```ts\n * import { XmlParseStream } from '@eksml/xml/stream';\n *\n * const response = await fetch('/feed.xml');\n * const reader = response.body\n * .pipeThrough(new TextDecoderStream())\n * .pipeThrough(new XmlParseStream())\n * .getReader();\n *\n * while (true) {\n * const { done, value } = await reader.read();\n * if (done) break;\n * console.log(value); // TNode or string\n * }\n * ```\n */\nexport class XmlParseStream<TOutput = TNode | string> extends TransformStream<\n string,\n TOutput\n> {\n /** Default DOM output (`TNode | string`). */\n constructor(options?: XmlParseStreamOptions & { output?: 'dom' });\n /** Lossy output — each item is converted to `LossyValue`. */\n constructor(options: XmlParseStreamOptions & { output: 'lossy' });\n /** Lossless output — each item is converted to `LosslessEntry`. */\n constructor(options: XmlParseStreamOptions & { output: 'lossless' });\n /** Dynamic output — when the `output` option is not a literal, returns the widest type. */\n constructor(options: XmlParseStreamOptions);\n constructor(options?: XmlParseStreamOptions) {\n const resolvedOptions = options ?? {};\n let skipBytes: number =\n typeof resolvedOptions.offset === 'string'\n ? resolvedOptions.offset.length\n : resolvedOptions.offset || 0;\n\n // Resolve HTML-mode defaults (same logic as parse())\n const isHtml = resolvedOptions.html === true;\n const selfClosingTags =\n resolvedOptions.selfClosingTags ?? (isHtml ? HTML_VOID_ELEMENTS : []);\n const rawContentTags =\n resolvedOptions.rawContentTags ?? (isHtml ? HTML_RAW_CONTENT_TAGS : []);\n const keepComments = resolvedOptions.keepComments === true;\n\n // Resolve select into a Set for O(1) lookup (null = emit at depth 0)\n const selectSet: Set<string> | null =\n resolvedOptions.select == null\n ? null\n : typeof resolvedOptions.select === 'string'\n ? new Set([resolvedOptions.select])\n : resolvedOptions.select.length > 0\n ? new Set(resolvedOptions.select)\n : null;\n\n // Resolve the output converter — identity for 'dom', otherwise a mapping fn.\n const outputMode = resolvedOptions.output ?? 'dom';\n const convert: (item: TNode | string) => TOutput =\n outputMode === 'lossy'\n ? (convertItemToLossy as (item: TNode | string) => TOutput)\n : outputMode === 'lossless'\n ? (convertItemToLossless as (item: TNode | string) => TOutput)\n : (((item: TNode | string) => item) as (\n item: TNode | string,\n ) => TOutput);\n\n // --- Tree-construction state ---\n let streamController: TransformStreamDefaultController<TOutput> | null =\n null;\n\n // When `select` is null (default mode): stack holds all open TNodes.\n // Emission happens when stack.length returns to 0.\n //\n // When `select` is set: `depth` tracks total nesting depth (for all\n // elements, including non-selected ancestors). `stack` only holds nodes\n // within a selected subtree. `selectDepths` is a stack of depths at which\n // selected elements were opened — this allows nested selections (e.g.\n // selecting both `item` and `sub` where `sub` is a child of `item`).\n // Each entry records the depth at which a matching element was opened.\n const stack: TNode[] = [];\n let depth = 0;\n const selectDepths: number[] = [];\n\n // -----------------------------------------------------------------------\n // Default mode helpers (no select — original behavior)\n // -----------------------------------------------------------------------\n\n function currentParent(): TNode | null {\n return stack.length > 0 ? stack[stack.length - 1]! : null;\n }\n\n function emitOrAttach(item: TNode | string): void {\n const parent = currentParent();\n if (parent) {\n parent.children.push(item);\n } else if (streamController) {\n streamController.enqueue(convert(item));\n }\n }\n\n // -----------------------------------------------------------------------\n // Select-mode helpers\n // -----------------------------------------------------------------------\n\n /** Whether we are currently inside a selected subtree. */\n function insideSelection(): boolean {\n return selectDepths.length > 0;\n }\n\n /** The TNode at the top of the stack, or null. */\n function selectParent(): TNode | null {\n return stack.length > 0 ? stack[stack.length - 1]! : null;\n }\n\n // -----------------------------------------------------------------------\n // SAX handler: default mode (no select)\n // -----------------------------------------------------------------------\n\n function defaultOnOpenTag(tagName: string, attributes: Attributes): void {\n const node: TNode = {\n tagName,\n attributes: toNodeAttributes(attributes),\n children: [],\n };\n const parent = currentParent();\n if (parent) {\n parent.children.push(node);\n }\n stack.push(node);\n }\n\n function defaultOnCloseTag(_tagName: string): void {\n const node = stack.pop();\n if (!node) return;\n if (stack.length === 0 && streamController) {\n streamController.enqueue(convert(node));\n }\n }\n\n function defaultOnText(text: string): void {\n const parent = currentParent();\n if (parent) {\n parent.children.push(text);\n }\n }\n\n function defaultOnCdata(data: string): void {\n const parent = currentParent();\n if (parent) {\n parent.children.push(data);\n }\n }\n\n function defaultOnComment(comment: string): void {\n if (!keepComments) return;\n const parent = currentParent();\n if (parent) {\n parent.children.push(comment);\n } else if (streamController) {\n streamController.enqueue(convert(comment));\n }\n }\n\n function defaultOnProcessingInstruction(name: string, body: string): void {\n const node: TNode = {\n tagName: '?' + name,\n attributes: parsePIAttributes(body),\n children: [],\n };\n emitOrAttach(node);\n }\n\n function defaultOnDoctype(tagName: string, attributes: Attributes): void {\n const node: TNode = {\n tagName,\n attributes: toNodeAttributes(attributes),\n children: [],\n };\n emitOrAttach(node);\n }\n\n // -----------------------------------------------------------------------\n // SAX handler: select mode\n // -----------------------------------------------------------------------\n\n function selectOnOpenTag(tagName: string, attributes: Attributes): void {\n depth++;\n if (insideSelection()) {\n // Inside a selected subtree — build the TNode and attach to parent.\n const node: TNode = {\n tagName,\n attributes: toNodeAttributes(attributes),\n children: [],\n };\n const parent = selectParent();\n if (parent) {\n parent.children.push(node);\n }\n stack.push(node);\n // If this tag also matches the selector, record it so it will be\n // emitted independently when it closes (in addition to being part\n // of its ancestor's subtree).\n if (selectSet!.has(tagName)) {\n selectDepths.push(depth);\n }\n } else if (selectSet!.has(tagName)) {\n // This element matches the selector — start a new selected subtree.\n selectDepths.push(depth);\n const node: TNode = {\n tagName,\n attributes: toNodeAttributes(attributes),\n children: [],\n };\n stack.push(node);\n }\n // Otherwise: non-selected ancestor — no allocation, just depth tracking.\n }\n\n function selectOnCloseTag(_tagName: string): void {\n if (insideSelection()) {\n const topSelectDepth = selectDepths[selectDepths.length - 1]!;\n if (depth === topSelectDepth) {\n // A selected element is closing — emit it independently.\n const node = stack.pop();\n if (node && streamController) {\n streamController.enqueue(convert(node));\n }\n selectDepths.pop();\n } else {\n // A descendant of the selected element is closing — already\n // attached to its parent via onOpenTag, just pop from stack.\n stack.pop();\n }\n }\n depth--;\n }\n\n function selectOnText(text: string): void {\n if (!insideSelection()) return;\n const parent = selectParent();\n if (parent) {\n parent.children.push(text);\n }\n }\n\n function selectOnCdata(data: string): void {\n if (!insideSelection()) return;\n const parent = selectParent();\n if (parent) {\n parent.children.push(data);\n }\n }\n\n function selectOnComment(comment: string): void {\n if (!keepComments) return;\n if (!insideSelection()) return;\n const parent = selectParent();\n if (parent) {\n parent.children.push(comment);\n }\n }\n\n function selectOnProcessingInstruction(name: string, body: string): void {\n if (!insideSelection()) return;\n const node: TNode = {\n tagName: '?' + name,\n attributes: parsePIAttributes(body),\n children: [],\n };\n const parent = selectParent();\n if (parent) {\n parent.children.push(node);\n }\n }\n\n function selectOnDoctype(tagName: string, attributes: Attributes): void {\n if (!insideSelection()) return;\n const node: TNode = {\n tagName,\n attributes: toNodeAttributes(attributes),\n children: [],\n };\n const parent = selectParent();\n if (parent) {\n parent.children.push(node);\n }\n }\n\n // -----------------------------------------------------------------------\n // Wire up the appropriate handler set\n // -----------------------------------------------------------------------\n\n const parser = saxEngine({\n selfClosingTags,\n rawContentTags,\n onOpenTag: selectSet ? selectOnOpenTag : defaultOnOpenTag,\n onCloseTag: selectSet ? selectOnCloseTag : defaultOnCloseTag,\n onText: selectSet ? selectOnText : defaultOnText,\n onCdata: selectSet ? selectOnCdata : defaultOnCdata,\n onComment: selectSet ? selectOnComment : defaultOnComment,\n onProcessingInstruction: selectSet\n ? selectOnProcessingInstruction\n : defaultOnProcessingInstruction,\n onDoctype: selectSet ? selectOnDoctype : defaultOnDoctype,\n });\n\n super({\n transform(\n chunk: string,\n controller: TransformStreamDefaultController<TOutput>,\n ): void {\n streamController = controller;\n // Handle offset: skip leading bytes from the first chunk(s)\n if (skipBytes > 0) {\n if (chunk.length <= skipBytes) {\n skipBytes -= chunk.length;\n return;\n }\n chunk = chunk.substring(skipBytes);\n skipBytes = 0;\n }\n parser.write(chunk);\n },\n\n flush(controller: TransformStreamDefaultController<TOutput>): void {\n streamController = controller;\n parser.close();\n },\n });\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "name": "@eksml/xml",
3
+ "version": "0.1.0",
4
+ "description": "Fast, lightweight XML/HTML parser, serializer, and streaming toolkit",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "imports": {
8
+ "#src/*": "./src/*"
9
+ },
10
+ "exports": {
11
+ "./parser": {
12
+ "types": "./dist/parser.d.mts",
13
+ "import": "./dist/parser.mjs"
14
+ },
15
+ "./writer": {
16
+ "types": "./dist/writer.d.mts",
17
+ "import": "./dist/writer.mjs"
18
+ },
19
+ "./sax": {
20
+ "types": "./dist/sax.d.mts",
21
+ "import": "./dist/sax.mjs"
22
+ },
23
+ "./stream": {
24
+ "types": "./dist/xmlParseStream.d.mts",
25
+ "import": "./dist/xmlParseStream.mjs"
26
+ },
27
+ "./lossy": {
28
+ "types": "./dist/converters/lossy.d.mts",
29
+ "import": "./dist/converters/lossy.mjs"
30
+ },
31
+ "./lossless": {
32
+ "types": "./dist/converters/lossless.d.mts",
33
+ "import": "./dist/converters/lossless.mjs"
34
+ },
35
+ "./from-lossy": {
36
+ "types": "./dist/converters/fromLossy.d.mts",
37
+ "import": "./dist/converters/fromLossy.mjs"
38
+ },
39
+ "./from-lossless": {
40
+ "types": "./dist/converters/fromLossless.d.mts",
41
+ "import": "./dist/converters/fromLossless.mjs"
42
+ },
43
+ "./utilities": {
44
+ "types": "./dist/utilities/index.d.mts",
45
+ "import": "./dist/utilities/index.mjs"
46
+ }
47
+ },
48
+ "files": [
49
+ "dist",
50
+ "src"
51
+ ],
52
+ "keywords": [
53
+ "xml",
54
+ "html",
55
+ "parser",
56
+ "serializer",
57
+ "sax",
58
+ "streaming",
59
+ "transform-stream",
60
+ "dom",
61
+ "fast",
62
+ "lightweight"
63
+ ],
64
+ "author": "Liam Potter <liam@liampotter.co.uk>",
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "https://github.com/evoactivity/eksml",
68
+ "directory": "eksml"
69
+ },
70
+ "license": "MIT",
71
+ "devDependencies": {
72
+ "@types/node": "^25.5.0",
73
+ "@types/sax": "^1.2.7",
74
+ "@types/xml2js": "^0.4.14",
75
+ "@xmldom/xmldom": "^0.9.9",
76
+ "fast-xml-parser": "^5.5.9",
77
+ "htmlparser2": "^12.0.0",
78
+ "modern-monaco": "^0.4.0",
79
+ "sax": "^1.6.0",
80
+ "saxes": "^6.0.0",
81
+ "tsdown": "^0.21.7",
82
+ "txml": "^5.2.1",
83
+ "typescript": "^6.0.2",
84
+ "vite": "^8.0.3",
85
+ "vitest": "^4.1.2",
86
+ "xml2js": "^0.6.2"
87
+ },
88
+ "dependencies": {
89
+ "entities": "^8.0.0"
90
+ },
91
+ "scripts": {
92
+ "build": "tsdown",
93
+ "test": "vitest run",
94
+ "test:watch": "vitest",
95
+ "bench": "vitest bench",
96
+ "typecheck": "tsc --noEmit",
97
+ "examples": "vite",
98
+ "examples:build": "vite build"
99
+ }
100
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * fromLossless — convert a lossless JSON structure back to a TNode DOM tree.
3
+ *
4
+ * This is the inverse of `lossless()`. Because the lossless format preserves
5
+ * element order, mixed content, attributes, and comments, the round-trip is
6
+ * exact: `fromLossless(lossless(dom))` produces a tree identical to `dom`.
7
+ *
8
+ * Entry types:
9
+ * - `{ tagName: LosslessEntry[] }` → TNode element
10
+ * - `{ $text: "..." }` → string child
11
+ * - `{ $attr: { ... } }` → attributes on the enclosing element (first child)
12
+ * - `{ $comment: "..." }` → comment string child (`"<!-- ... -->"`)
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { fromLossless } from "@eksml/xml/from-lossless";
17
+ * import { lossless } from "@eksml/xml/lossless";
18
+ * import { write } from "@eksml/xml/writer";
19
+ *
20
+ * const entries = lossless('<root attr="1"><item>hello</item></root>');
21
+ * const dom = fromLossless(entries);
22
+ * const xml = write(dom); // '<root attr="1"><item>hello</item></root>'
23
+ * ```
24
+ */
25
+
26
+ import type { TNode } from '#src/parser.ts';
27
+ import type { LosslessEntry } from '#src/converters/lossless.ts';
28
+
29
+ /**
30
+ * Convert a lossless JSON entry array back to a `(TNode | string)[]` DOM tree.
31
+ *
32
+ * @param entries - The lossless entry array (as returned by `lossless()`).
33
+ * @returns A DOM array suitable for `write()` or further processing.
34
+ */
35
+ export function fromLossless(entries: LosslessEntry[]): (TNode | string)[] {
36
+ const result: (TNode | string)[] = [];
37
+ for (let i = 0; i < entries.length; i++) {
38
+ result.push(convertEntry(entries[i]!));
39
+ }
40
+ return result;
41
+ }
42
+
43
+ function convertEntry(entry: LosslessEntry): TNode | string {
44
+ // $text → plain string
45
+ if ('$text' in entry) {
46
+ return (entry as { $text: string }).$text;
47
+ }
48
+
49
+ // $comment → reconstruct comment string as the parser emits it
50
+ if ('$comment' in entry) {
51
+ return '<!--' + (entry as { $comment: string }).$comment + '-->';
52
+ }
53
+
54
+ // $attr should not appear at top level — it's consumed by the element
55
+ // handler below. If we encounter it here, skip it (defensive).
56
+ if ('$attr' in entry) {
57
+ return '';
58
+ }
59
+
60
+ // Element: single key is the tag name, value is children array
61
+ const keys = Object.keys(entry);
62
+ const tagName = keys[0]!;
63
+ const entryChildren = (entry as { [tagName: string]: LosslessEntry[] })[
64
+ tagName
65
+ ]!;
66
+
67
+ let attributes: Record<string, string | null> | null = null;
68
+ const children: (TNode | string)[] = [];
69
+
70
+ for (let i = 0; i < entryChildren.length; i++) {
71
+ const child = entryChildren[i]!;
72
+ if ('$attr' in child) {
73
+ attributes = (child as { $attr: Record<string, string | null> }).$attr;
74
+ } else {
75
+ children.push(convertEntry(child));
76
+ }
77
+ }
78
+
79
+ return { tagName, attributes, children };
80
+ }