@nodable/sequential-stream-builder 1.0.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.
package/README.md ADDED
@@ -0,0 +1,219 @@
1
+ # Sequential Stream Output Builder
2
+
3
+ Produces a sequential array where every element is represented as an object with the **tag name directly as a key** pointing to its children array. There is no separate `elementname` property — the structure is array-first throughout.
4
+
5
+ ## Output structure
6
+
7
+ ```
8
+ [ ← getOutput() always returns an array
9
+ {
10
+ [tagName]: Array, ← tag name → children array (always present, empty for leaf/empty nodes)
11
+ [groupBy]?: object, ← attributes as a sibling property (only when non-empty)
12
+ text?: any ← only present on leaf nodes (no child element entries)
13
+ }
14
+ ]
15
+ ```
16
+
17
+ ### Leaf node (text only, no child elements)
18
+
19
+ ```js
20
+ { span: [], text: "Hello" }
21
+ ```
22
+
23
+ ### Empty tag (no text, no children)
24
+
25
+ ```js
26
+ { br: [] }
27
+ ```
28
+
29
+ ### Tag with child elements
30
+
31
+ ```js
32
+ { div: [ /* child entries */ ] }
33
+ ```
34
+
35
+ ### Tag with attributes and text
36
+
37
+ ```js
38
+ { item: [], attributes: { "@_id": 1 }, text: "hello" }
39
+ ```
40
+
41
+ Attributes are a **sibling property** alongside the tag key — they are not nested inside the children array.
42
+
43
+ ### Mixed content (text interleaved with child elements)
44
+
45
+ Inline text runs appear as `{ "#text": value }` entries inside the children array. The entry itself has no `text` property in this case.
46
+
47
+ Input:
48
+ ```xml
49
+ <p>Hello <b>world</b>!</p>
50
+ ```
51
+
52
+ Output:
53
+ ```js
54
+ [
55
+ {
56
+ p: [
57
+ { "#text": "Hello " },
58
+ { b: [], text: "world" },
59
+ { "#text": "!" }
60
+ ]
61
+ }
62
+ ]
63
+ ```
64
+
65
+ ## Basic example
66
+
67
+ Input:
68
+ ```xml
69
+ <root>
70
+ <child>hello</child>
71
+ <child>world</child>
72
+ </root>
73
+ ```
74
+
75
+ Output:
76
+ ```js
77
+ [
78
+ {
79
+ root: [
80
+ { child: [], text: "hello" },
81
+ { child: [], text: "world" }
82
+ ]
83
+ }
84
+ ]
85
+ ```
86
+
87
+ ## Install
88
+
89
+ ```bash
90
+ npm install @nodable/sequential-stream-builder
91
+ ```
92
+
93
+ ## Usage
94
+
95
+ ```js
96
+ import XMLParser from "@nodable/flexible-xml-parser";
97
+ import {SequentialStreamBuilderFactory} from "@nodable/sequential-stream-builder";
98
+
99
+ const parser = new XMLParser({
100
+ OutputBuilder: new SequentialStreamBuilderFactory({
101
+ stream: fs.createWriteStream('output.json'),
102
+ // onChunk: (chunk) => {
103
+ // console.log('CHUNK EMITTED:', chunk);
104
+ // }
105
+ }),
106
+ ...parserOptions,
107
+ });
108
+
109
+ parser.parse(xmlString);
110
+ // result is always an array
111
+ ```
112
+
113
+ ## Options
114
+
115
+ ### `attributes.groupBy` (default: `"attributes"`)
116
+
117
+ The property name under which all attributes are collected as a sibling alongside the tag key. The property is **only present** when attributes exist and `skip.attributes` is false.
118
+
119
+ ```js
120
+ new SequentialStreamBuilderFactory({
121
+ attributes: { groupBy: "attributes" } // default
122
+ })
123
+ ```
124
+
125
+ To use a custom key:
126
+
127
+ ```js
128
+ new SequentialStreamBuilderFactory({
129
+ attributes: { groupBy: ":@" }
130
+ })
131
+ ```
132
+
133
+ ### `nameFor.text` (default: `"#text"`)
134
+
135
+ The key used for inline text entries inside the children array when a node has mixed content (text interleaved with child elements).
136
+
137
+ ```js
138
+ new SequentialStreamBuilderFactory({
139
+ nameFor: { text: ":text" }
140
+ })
141
+ ```
142
+
143
+ ### `nameFor.comment`
144
+
145
+ When `skip.comment` is false, this property name is used for comment entries in the children array.
146
+
147
+ ```js
148
+ new SequentialStreamBuilderFactory({
149
+ nameFor: { comment: "#comment" }
150
+ })
151
+ ```
152
+
153
+ ### `nameFor.cdata`
154
+
155
+ When set, CDATA sections appear as `{ [cdata]: value }` entries in the children array. When unset (default), CDATA content is merged into the node's `text` value (same as regular text).
156
+
157
+ ```js
158
+ // builder config
159
+ const builderConfig = { nameFor: { cdata: "##cdata" } };
160
+ // parser config
161
+ const parserConfig = { skip: { cdata: false } };
162
+
163
+ const parser = new XMLParser({
164
+ OutputBuilder: new SequentialStreamBuilderFactory(builderConfig),
165
+ ...parserConfig,
166
+ });
167
+ ```
168
+
169
+ Output for `<root><code><![CDATA[data]]></code></root>`:
170
+ ```js
171
+ [
172
+ {
173
+ root: [
174
+ {
175
+ code: [
176
+ { "##cdata": "data" }
177
+ ]
178
+ }
179
+ ]
180
+ }
181
+ ]
182
+ ```
183
+
184
+ ### `textInChild` (default: `false`)
185
+
186
+ When `true`, text is always stored as a `{ [nameFor.text]: value }` entry in the children array — even on pure leaf nodes that have no element children. The `text` sibling property is never set in this mode.
187
+
188
+ ```js
189
+ new SequentialStreamBuilderFactory({ textInChild: true })
190
+ ```
191
+
192
+ Input:
193
+ ```xml
194
+ <root><a>hello</a></root>
195
+ ```
196
+
197
+ Default output (`textInChild: false`):
198
+ ```js
199
+ [ { root: [ { a: [], text: "hello" } ] } ]
200
+ ```
201
+
202
+ Output with `textInChild: true`:
203
+ ```js
204
+ [ { root: [ { a: [ { "#text": "hello" } ] } ] } ]
205
+ ```
206
+
207
+ ### `skip.attributes` (default: `true`)
208
+
209
+ When `true` (default), all attributes are ignored and no `attributes` property appears on entries. Set to `false` to populate attributes.
210
+
211
+ ### Value parsers
212
+
213
+ By default the parser chain `["entity", "boolean", "number"]` is applied to text content, converting `"42"` → `42` and `"true"` → `true`. Override with `tags.valueParsers`.
214
+
215
+ ```js
216
+ new SequentialStreamBuilderFactory({
217
+ tags: { valueParsers: [] } // keep all values as raw strings
218
+ })
219
+ ```
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@nodable/sequential-stream-builder",
3
+ "version": "1.0.0",
4
+ "description": "Sequential JS Array stream builder for flexible-xml-parser",
5
+ "main": "src/index.js",
6
+ "types": "./src/index.d.ts",
7
+ "type": "module",
8
+ "scripts": {
9
+ "test": "echo \"Error: no test specified\" && exit 1"
10
+ },
11
+ "keywords": [
12
+ "flexible-xml-parser",
13
+ "nodable",
14
+ "xml"
15
+ ],
16
+ "author": "Amit Gupta (https://solothought.com)",
17
+ "license": "MIT",
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "dependencies": {
25
+ "@nodable/base-output-builder": "^1.0.2",
26
+ "path-expression-matcher": "^1.4.0"
27
+ },
28
+ "funding": [
29
+ {
30
+ "type": "github",
31
+ "url": "https://github.com/sponsors/nodable"
32
+ }
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/nodable/flexible-output-builder.git"
37
+ }
38
+ }
@@ -0,0 +1,78 @@
1
+ const defaultOptions = {
2
+ nameFor: {
3
+ text: "#text",
4
+ // comment: "",
5
+ // cdata: "",
6
+ },
7
+ skip: {
8
+ // declaration: false,
9
+ // pi: false,
10
+ // attributes: true,
11
+ // cdata: false,
12
+ // comment: false,
13
+ // nsPrefix: false,
14
+ // tags: false,
15
+ },
16
+ tags: {
17
+ valueParsers: [],
18
+ // stopNodes: [],
19
+ },
20
+ attributes: {
21
+ prefix: "@_",
22
+ suffix: "",
23
+ groupBy: "attributes",
24
+ valueParsers: [],
25
+ },
26
+ textInChild: false,
27
+ };
28
+
29
+ // Default chains: replaceEntities first (expand references), then type coercion.
30
+ const defaultTagParsers = ["entity", "boolean", "number"];
31
+ const defaultAttrParsers = ["entity", "number", "boolean"];
32
+
33
+ export function buildOptions(options) {
34
+ const finalOptions = deepClone(defaultOptions);
35
+
36
+ if (!options || options.tags?.valueParsers === undefined) {
37
+ finalOptions.tags.valueParsers = [...defaultTagParsers];
38
+ }
39
+ if (!options || options.attributes?.valueParsers === undefined) {
40
+ finalOptions.attributes.valueParsers = [...defaultAttrParsers];
41
+ }
42
+
43
+ if (options) {
44
+ copyProperties(finalOptions, options);
45
+ }
46
+
47
+ return finalOptions;
48
+ }
49
+
50
+ function deepClone(obj) {
51
+ if (obj === null || typeof obj !== 'object') return obj;
52
+ if (Array.isArray(obj)) return obj.map(deepClone);
53
+ const clone = {};
54
+ for (const key of Object.keys(obj)) {
55
+ clone[key] = deepClone(obj[key]);
56
+ }
57
+ return clone;
58
+ }
59
+
60
+ function copyProperties(target, source) {
61
+ for (const key of Object.keys(source)) {
62
+ // Guard against prototype pollution via option keys
63
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
64
+
65
+ if (typeof source[key] === 'function') {
66
+ target[key] = source[key];
67
+ } else if (Array.isArray(source[key])) {
68
+ target[key] = source[key];
69
+ } else if (typeof source[key] === 'object' && source[key] !== null) {
70
+ if (typeof target[key] !== 'object' || target[key] === null) {
71
+ target[key] = {};
72
+ }
73
+ copyProperties(target[key], source[key]);
74
+ } else {
75
+ target[key] = source[key];
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,370 @@
1
+ import { buildOptions } from './ParserOptionsBuilder.js';
2
+ import { BaseOutputBuilder, BaseOutputBuilderFactory, ElementType } from '@nodable/base-output-builder';
3
+
4
+ /**
5
+ * SequentialStreamBuilderFactory
6
+ *
7
+ * Stream variant of SequentialBuilderFactory.
8
+ *
9
+ * Instead of accumulating all parsed entries in memory and returning them via
10
+ * getOutput(), this builder emits each **top-level entry** to a Writable stream
11
+ * (or a plain callback) as soon as its closing tag is processed. Nested nodes
12
+ * are still kept in memory during parsing — you cannot emit partial JSON — but
13
+ * they are released the moment the root-level element closes.
14
+ *
15
+ * The wire format is a **JSON array written incrementally**:
16
+ *
17
+ * [ ← written on first entry
18
+ * { "root": [...] }, ← written when the first top-level tag closes
19
+ * { "other": [...] } ← written when the second top-level tag closes
20
+ * ] ← written when getOutput() is called (end of parse)
21
+ *
22
+ * This matches the shape of SequentialBuilder.getOutput() so the two builders
23
+ * are drop-in replacements for each other.
24
+ *
25
+ * Options (in addition to all SequentialBuilderFactory options):
26
+ *
27
+ * stream {Writable} – Node.js Writable stream. Mutually exclusive with onChunk.
28
+ * onChunk {Function} – Callback invoked with each string chunk.
29
+ * Mutually exclusive with stream.
30
+ * space {number|string} – JSON.stringify spacing (default: undefined → compact).
31
+ *
32
+ * Exactly one of `stream` or `onChunk` must be provided.
33
+ *
34
+ * Usage:
35
+ *
36
+ * import fs from 'fs';
37
+ * import XMLParser from '@nodable/flexible-xml-parser';
38
+ * import SequentialStreamBuilderFactory from './SequentialStreamBuilder.js';
39
+ *
40
+ * const out = fs.createWriteStream('output.json');
41
+ *
42
+ * out.on('open', () => {
43
+ * const parser = new XMLParser({
44
+ * OutputBuilder: new SequentialStreamBuilderFactory({ stream: out }),
45
+ * });
46
+ *
47
+ * parser.parse(xmlString); // synchronous — all onChunk calls fire here
48
+ *
49
+ * out.end(); // flush + close after parse() returns
50
+ * });
51
+ *
52
+ * IMPORTANT — parser.parse() is synchronous.
53
+ * All chunk emissions happen inline during parse(). The stream must be open
54
+ * (writable) before parse() is called, and end() / finish() is the caller's
55
+ * responsibility after parse() returns.
56
+ */
57
+ export default class SequentialStreamBuilderFactory extends BaseOutputBuilderFactory {
58
+ constructor(options = {}) {
59
+ super();
60
+
61
+ if (!options.stream && typeof options.onChunk !== 'function') {
62
+ throw new TypeError(
63
+ 'SequentialStreamBuilderFactory: provide either `stream` (Writable) or `onChunk` (Function).'
64
+ );
65
+ }
66
+ if (options.stream && typeof options.onChunk === 'function') {
67
+ throw new TypeError(
68
+ 'SequentialStreamBuilderFactory: `stream` and `onChunk` are mutually exclusive.'
69
+ );
70
+ }
71
+
72
+ // Separate stream/formatting options from builder options
73
+ const { stream, onChunk, space, ...builderOptions } = options;
74
+
75
+ this._stream = stream ?? null;
76
+ this._onChunk = onChunk ?? null;
77
+ this._space = space ?? undefined;
78
+
79
+ this.options = buildOptions(builderOptions);
80
+ }
81
+
82
+ getInstance(parserOptions, readonlyMatcher) {
83
+ const valParsers = { ...this.commonValParsers };
84
+
85
+ // Each parse run gets its own builder instance, with a fresh emit function
86
+ // that writes to the configured destination.
87
+ const emit = this._stream
88
+ ? (chunk) => this._stream.write(chunk)
89
+ : this._onChunk;
90
+
91
+ return new SequentialStreamBuilder(
92
+ parserOptions,
93
+ this.options,
94
+ valParsers,
95
+ readonlyMatcher,
96
+ emit,
97
+ this._space
98
+ );
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export class SequentialStreamBuilder extends BaseOutputBuilder {
105
+ /**
106
+ * @param {object} parserOptions
107
+ * @param {object} builderOptions – merged + defaulted options from buildOptions()
108
+ * @param {object} registeredValParsers
109
+ * @param {object} readonlyMatcher
110
+ * @param {Function} emit – (chunk: string) => void
111
+ * @param {number|string|undefined} space – JSON.stringify space argument
112
+ */
113
+ constructor(parserOptions, builderOptions, registeredValParsers, readonlyMatcher, emit, space) {
114
+ super(readonlyMatcher);
115
+
116
+ this.tagsStack = [];
117
+ this.parserOptions = parserOptions;
118
+
119
+ this.options = {
120
+ ...parserOptions,
121
+ ...builderOptions,
122
+ skip: { ...parserOptions.skip, ...builderOptions.skip },
123
+ nameFor: { ...parserOptions.nameFor, ...builderOptions.nameFor },
124
+ tags: { ...parserOptions.tags, ...builderOptions.tags },
125
+ attributes: { ...parserOptions.attributes, ...builderOptions.attributes },
126
+ };
127
+
128
+ this.registeredValParsers = registeredValParsers;
129
+
130
+ this._emit = emit;
131
+ this._space = space;
132
+ this._entryCount = 0; // how many top-level entries have been emitted so far
133
+ this._streamClosed = false;
134
+
135
+ // currentNode starts as null — the first addElement() creates the root entry.
136
+ // We don't need a synthetic root node because we emit each top-level entry
137
+ // immediately on closeElement() when tagsStack is empty.
138
+ this.currentNode = null;
139
+ this.attributes = {};
140
+ this._pendingStopNode = false;
141
+ }
142
+
143
+ // -------------------------------------------------------------------------
144
+ // Internal helpers
145
+ // -------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Emit a fully-closed top-level entry to the stream.
149
+ * Handles the JSON array framing: "[" before the first entry,
150
+ * "," as a separator between subsequent entries.
151
+ * The closing "]" is written in getOutput().
152
+ */
153
+ _emitEntry(entry) {
154
+ if (this._streamClosed) return;
155
+
156
+ const json = JSON.stringify(entry, null, this._space);
157
+
158
+ if (this._entryCount === 0) {
159
+ // Open the JSON array with the first element
160
+ this._emit('[\n' + json);
161
+ } else {
162
+ // Subsequent elements are comma-separated
163
+ this._emit(',\n' + json);
164
+ }
165
+
166
+ this._entryCount++;
167
+ }
168
+
169
+ // -------------------------------------------------------------------------
170
+ // BaseOutputBuilder interface
171
+ // -------------------------------------------------------------------------
172
+
173
+ addElement(tag) {
174
+ if (this.currentNode !== null) {
175
+ // We are already inside a top-level element — this is a nested open tag.
176
+ // Migrate any pending text into the children array (mixed-content case).
177
+ if (this.currentNode.text !== undefined) {
178
+ this.currentNode.children.unshift({
179
+ [this.options.nameFor.text]: this.currentNode.text,
180
+ });
181
+ delete this.currentNode.text;
182
+ }
183
+
184
+ this.tagsStack.push(this.currentNode);
185
+ }
186
+ // else: tagsStack is empty and currentNode is null → this is a new top-level element.
187
+
188
+ const node = new Node(tag.name, this.options);
189
+
190
+ if (this.attributes && Object.keys(this.attributes).length > 0) {
191
+ node[this.options.attributes.groupBy] = { ...this.attributes };
192
+ }
193
+ this.attributes = {};
194
+ this.currentNode = node;
195
+ }
196
+
197
+ /**
198
+ * Called when a stop node is fully collected, before addValue().
199
+ */
200
+ onStopNode(tagDetail, rawContent) {
201
+ this._pendingStopNode = true;
202
+ if (typeof this.options.onStopNode === 'function') {
203
+ this.options.onStopNode(tagDetail, rawContent, this.matcher);
204
+ }
205
+ }
206
+
207
+ closeElement() {
208
+ const node = this.currentNode;
209
+
210
+ this._pendingStopNode = false;
211
+
212
+ if (this.options.onClose !== undefined) {
213
+ const resultTag = this.options.onClose(node, this.matcher);
214
+ if (resultTag) {
215
+ // Caller suppressed this entry; restore parent or null
216
+ this.currentNode = this.tagsStack.pop() ?? null;
217
+ return;
218
+ }
219
+ }
220
+
221
+ // Build the entry object — same shape as SequentialBuilder
222
+ const entry = { [node.tagname]: node.children };
223
+
224
+ const groupBy = this.options.attributes.groupBy;
225
+ if (node[groupBy] && Object.keys(node[groupBy]).length > 0) {
226
+ entry[groupBy] = node[groupBy];
227
+ }
228
+
229
+ if (node.text !== undefined) {
230
+ entry.text = node.text;
231
+ }
232
+
233
+ if (this.tagsStack.length === 0) {
234
+ // ── Top-level element just closed ────────────────────────────────────
235
+ // Emit immediately; do NOT keep a reference (memory freed here).
236
+ this._emitEntry(entry);
237
+ this.currentNode = null;
238
+ } else {
239
+ // ── Nested element closed ─────────────────────────────────────────────
240
+ // Attach to the parent's children array and pop the stack.
241
+ this.currentNode = this.tagsStack.pop();
242
+ this.currentNode.children.push(entry);
243
+ }
244
+ }
245
+
246
+ addValue(text) {
247
+ if (this.currentNode === null) return; // text outside any tag — ignore
248
+
249
+ const hasElementChildren = this.currentNode.children?.some(
250
+ (c) => !Object.prototype.hasOwnProperty.call(c, this.options.nameFor.text)
251
+ );
252
+
253
+ const context = {
254
+ elementName: this.currentNode.tagname,
255
+ elementValue: text,
256
+ elementType: ElementType.ELEMENT,
257
+ matcher: this.matcher,
258
+ isLeafNode: !hasElementChildren,
259
+ };
260
+
261
+ const parsedValue = this.parseValue(text, this.options.tags.valueParsers, context);
262
+
263
+ if (hasElementChildren || this.options.textInChild) {
264
+ this.currentNode.children.push({
265
+ [this.options.nameFor.text]: parsedValue,
266
+ });
267
+ } else {
268
+ this.currentNode.text = parsedValue;
269
+ }
270
+ }
271
+
272
+ addInstruction(name) {
273
+ if (this.currentNode === null) {
274
+ // PI at document level — emit immediately as a standalone entry
275
+ const node = new Node(name, this.options);
276
+ const groupBy = this.options.attributes.groupBy;
277
+ if (this.attributes && Object.keys(this.attributes).length > 0) {
278
+ node[groupBy] = { ...this.attributes };
279
+ }
280
+ const entry = { [node.tagname]: node.children };
281
+ if (node[groupBy] && Object.keys(node[groupBy]).length > 0) {
282
+ entry[groupBy] = node[groupBy];
283
+ }
284
+ this.attributes = {};
285
+ this._emitEntry(entry);
286
+ return;
287
+ }
288
+
289
+ // PI inside an element — same as SequentialBuilder
290
+ const node = new Node(name, this.options);
291
+ const groupBy = this.options.attributes.groupBy;
292
+ if (this.attributes && Object.keys(this.attributes).length > 0) {
293
+ node[groupBy] = { ...this.attributes };
294
+ }
295
+ const entry = { [node.tagname]: node.children };
296
+ if (node[groupBy] && Object.keys(node[groupBy]).length > 0) {
297
+ entry[groupBy] = node[groupBy];
298
+ }
299
+ this.currentNode.children.push(entry);
300
+ this.attributes = {};
301
+ }
302
+
303
+ addComment(text) {
304
+ if (this.options.skip.comment) return;
305
+ if (!this.options.nameFor.comment) return;
306
+ if (this.currentNode === null) return; // comment outside any tag
307
+
308
+ this.currentNode.children.push({
309
+ [this.options.nameFor.comment]: text,
310
+ });
311
+ }
312
+
313
+ addLiteral(text) {
314
+ if (this.options.skip.cdata) return;
315
+
316
+ if (this.options.nameFor.cdata) {
317
+ if (this.currentNode === null) return;
318
+ this.currentNode.children.push({
319
+ [this.options.nameFor.cdata]: text,
320
+ });
321
+ } else {
322
+ this.addValue(text || '');
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Called after the full parse is complete.
328
+ *
329
+ * Closes the JSON array that was opened by the first _emitEntry() call.
330
+ * If no entries were emitted at all (empty document), emits an empty array.
331
+ *
332
+ * Returns null — the output lives in the stream, not in memory.
333
+ */
334
+ getOutput() {
335
+ if (this._streamClosed) return null;
336
+ this._streamClosed = true;
337
+
338
+ if (this._entryCount === 0) {
339
+ this._emit('[]');
340
+ } else {
341
+ this._emit('\n]');
342
+ }
343
+
344
+ return null;
345
+ }
346
+
347
+ /**
348
+ * onExit — called by the parser when exitIf returns true.
349
+ *
350
+ * The parser has already closed all open tags before calling this, so
351
+ * all pending entries have already been emitted via closeElement().
352
+ * We just need to close the JSON array, same as getOutput().
353
+ *
354
+ * @param {object} exitInfo
355
+ */
356
+ onExit(exitInfo) { // eslint-disable-line no-unused-vars
357
+ this.getOutput();
358
+ }
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+
363
+ class Node {
364
+ constructor(tagname, options) {
365
+ this.tagname = tagname;
366
+ this.children = [];
367
+ const groupBy = options?.attributes?.groupBy ?? 'attributes';
368
+ this[groupBy] = {};
369
+ }
370
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,123 @@
1
+ import type { Writable } from 'node:stream';
2
+ import type {
3
+ FactoryOptions,
4
+ SequentialEntry,
5
+ ValueParser,
6
+ } from './index.js';
7
+
8
+ /**
9
+ * Options accepted by SequentialStreamBuilderFactory.
10
+ *
11
+ * Extends FactoryOptions with the stream-specific properties.
12
+ * Exactly one of `stream` or `onChunk` must be provided.
13
+ */
14
+ export interface StreamBuilderOptions extends FactoryOptions {
15
+ /**
16
+ * A Node.js Writable stream. Chunks of the JSON array are written to it
17
+ * via stream.write(chunk: string).
18
+ *
19
+ * Mutually exclusive with `onChunk`.
20
+ */
21
+ stream?: Writable;
22
+
23
+ /**
24
+ * A callback invoked with each string chunk as the parse progresses.
25
+ * Use this when you don't have a Writable but want to handle output
26
+ * yourself (e.g. accumulate into a buffer, send over a WebSocket, …).
27
+ *
28
+ * Mutually exclusive with `stream`.
29
+ */
30
+ onChunk?: (chunk: string) => void;
31
+
32
+ /**
33
+ * Spacing argument forwarded to JSON.stringify().
34
+ * Omit (or pass undefined) for compact output.
35
+ * Pass 2 for human-readable indented output.
36
+ */
37
+ space?: number | string;
38
+ }
39
+
40
+ /**
41
+ * SequentialStreamBuilderFactory
42
+ *
43
+ * Stream variant of SequentialBuilderFactory. Instead of accumulating the
44
+ * full parse tree in memory, each top-level XML element is serialised to JSON
45
+ * and emitted to the configured `stream` or `onChunk` callback as soon as its
46
+ * closing tag is seen.
47
+ *
48
+ * Wire format — a JSON array written incrementally:
49
+ *
50
+ * [ ← emitted together with the first top-level entry
51
+ * { "root": [...] }
52
+ * ] ← emitted by getOutput() (called by the parser after parse())
53
+ *
54
+ * The shape of each entry is identical to SequentialBuilder so the two
55
+ * builders are drop-in replacements for each other.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * import fs from 'node:fs';
60
+ * import XMLParser from '@nodable/flexible-xml-parser';
61
+ * import SequentialStreamBuilderFactory from './SequentialStreamBuilder.js';
62
+ *
63
+ * const out = fs.createWriteStream('output.json');
64
+ *
65
+ * out.on('open', () => {
66
+ * const parser = new XMLParser({
67
+ * OutputBuilder: new SequentialStreamBuilderFactory({ stream: out }),
68
+ * });
69
+ *
70
+ * parser.parse(xmlString); // synchronous — all writes happen here
71
+ *
72
+ * out.end(); // caller closes the stream
73
+ * });
74
+ * ```
75
+ */
76
+ export default class SequentialStreamBuilderFactory {
77
+ constructor(options: StreamBuilderOptions);
78
+
79
+ getInstance(
80
+ parserOptions: FactoryOptions,
81
+ readonlyMatcher: unknown,
82
+ ): SequentialStreamBuilderInstance;
83
+
84
+ registerValueParser(name: string, parser: ValueParser): void;
85
+ }
86
+
87
+ export interface SequentialStreamBuilderInstance {
88
+ addElement(tag: { name: string }): void;
89
+ closeElement(): void;
90
+ addValue(text: string): void;
91
+ addAttribute(name: string, value: unknown): void;
92
+ addComment(text: string): void;
93
+ addLiteral(text: string): void;
94
+ addDeclaration(): void;
95
+ addInstruction(name: string): void;
96
+ addInputEntities(entities: object): void;
97
+
98
+ onStopNode?(
99
+ tagDetail: { name: string; line: number; col: number; index: number },
100
+ rawContent: string,
101
+ matcher: unknown,
102
+ ): void;
103
+
104
+ /**
105
+ * Closes the JSON array and returns null.
106
+ * All output has already been streamed; there is nothing to return in memory.
107
+ */
108
+ getOutput(): null;
109
+
110
+ /**
111
+ * Called by the parser when exitIf returns true.
112
+ * Flushes and closes the JSON array, identical to getOutput().
113
+ */
114
+ onExit(exitInfo: {
115
+ tagDetail: { name: string; line: number; col: number; index: number };
116
+ matcher: unknown;
117
+ depth: number;
118
+ }): void;
119
+
120
+ registeredValParsers: Record<string, ValueParser>;
121
+ }
122
+
123
+ export { SequentialEntry, FactoryOptions };
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default as SequentialStreamBuilderFactory, SequentialStreamBuilder } from './SequentialStreamBuilder.js';