@bpmnkit/core 0.1.2 → 0.2.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 (42) hide show
  1. package/README.md +30 -1
  2. package/dist/bpmn/bpmn-builder.d.ts +209 -3
  3. package/dist/bpmn/bpmn-builder.js +456 -16
  4. package/dist/bpmn/bpmn-model.d.ts +110 -0
  5. package/dist/bpmn/bpmn-parser.js +1413 -528
  6. package/dist/bpmn/bpmn-serializer.js +101 -19
  7. package/dist/bpmn/compact.d.ts +17 -2
  8. package/dist/bpmn/compact.js +3 -3
  9. package/dist/bpmn/full-operations.d.ts +89 -0
  10. package/dist/bpmn/full-operations.js +478 -0
  11. package/dist/bpmn/index.d.ts +19 -0
  12. package/dist/bpmn/index.js +21 -0
  13. package/dist/bpmn/optimize/feel.js +2 -2
  14. package/dist/bpmn/optimize/patterns.js +23 -16
  15. package/dist/bpmn/optimize/tasks.js +30 -7
  16. package/dist/bpmn/optimize/utils.js +2 -4
  17. package/dist/bpmn/optimize/variable-flow.js +58 -67
  18. package/dist/bpmn/semantic-hash.d.ts +93 -0
  19. package/dist/bpmn/semantic-hash.js +155 -0
  20. package/dist/bpmn/sha256.d.ts +17 -0
  21. package/dist/bpmn/sha256.js +95 -0
  22. package/dist/bpmn/zeebe-extensions.d.ts +56 -0
  23. package/dist/bpmn/zeebe-extensions.js +79 -0
  24. package/dist/bpmn/zeebe-placement.d.ts +12 -0
  25. package/dist/bpmn/zeebe-placement.js +140 -0
  26. package/dist/errors.d.ts +40 -1
  27. package/dist/errors.js +41 -0
  28. package/dist/index.d.ts +10 -4
  29. package/dist/index.js +7 -3
  30. package/dist/layout/semantic/graph.d.ts +9 -1
  31. package/dist/layout/semantic/graph.js +42 -17
  32. package/dist/layout/semantic/route.js +102 -42
  33. package/dist/node/index.d.ts +10 -0
  34. package/dist/node/index.js +9 -0
  35. package/dist/node/write.d.ts +81 -0
  36. package/dist/node/write.js +167 -0
  37. package/dist/types/id-generator.js +11 -3
  38. package/dist/xml/index.d.ts +3 -1
  39. package/dist/xml/index.js +2 -1
  40. package/dist/xml/xml-parser.d.ts +32 -0
  41. package/dist/xml/xml-parser.js +394 -143
  42. package/package.json +8 -1
@@ -1,153 +1,375 @@
1
1
  // ---------------------------------------------------------------------------
2
- // Parser
2
+ // Event scanner
3
3
  // ---------------------------------------------------------------------------
4
+ /** What a sink wants the scanner to do with an element after its start tag. */
5
+ export const Visit = {
6
+ /** Report child elements and character data. */
7
+ All: 0,
8
+ /**
9
+ * Skip the element's content: the scanner fast-forwards past the matching
10
+ * end tag without building attributes or decoding text for anything inside,
11
+ * and `end` is not called for it.
12
+ */
13
+ Skip: 1,
14
+ /** Report child elements but drop character data without decoding it. */
15
+ ElementsOnly: 2,
16
+ };
17
+ /**
18
+ * Scan an XML document, reporting the root element and everything inside it to
19
+ * `sink`. Returns false when the document has no root element. Content after
20
+ * the root element is ignored.
21
+ */
22
+ export function scanXml(xml, sink) {
23
+ return new XmlScanner(xml).scan(sink);
24
+ }
4
25
  /**
5
26
  * Parse an XML string into an XmlElement tree.
6
27
  * Returns the root element with all namespace prefixes preserved.
7
28
  * @throws Error if the XML has no root element.
8
29
  */
9
30
  export function parseXml(xml) {
10
- const p = new XmlReader(xml);
11
- const root = p.parseDocument();
12
- if (!root)
31
+ const builder = new TreeBuilder();
32
+ if (!scanXml(xml, builder) || builder.root === undefined) {
13
33
  throw new Error("Failed to parse XML: no root element found");
14
- return root;
34
+ }
35
+ return builder.root;
36
+ }
37
+ /** Sink that materialises the document as an XmlElement tree. */
38
+ class TreeBuilder {
39
+ root;
40
+ stack = [];
41
+ start(name, _local, attributes) {
42
+ const el = { name, attributes, children: [] };
43
+ const parent = this.stack[this.stack.length - 1];
44
+ if (parent)
45
+ parent.children.push(el);
46
+ else
47
+ this.root = el;
48
+ this.stack.push(el);
49
+ return Visit.All;
50
+ }
51
+ text(text) {
52
+ const el = this.stack[this.stack.length - 1];
53
+ el.text = el.text === undefined ? text : el.text + text;
54
+ }
55
+ end() {
56
+ this.stack.pop();
57
+ }
15
58
  }
16
- class XmlReader {
59
+ // Character codes the scanner switches on. Comparing codes avoids allocating a
60
+ // one-character string for every position visited.
61
+ const LT = 0x3c; // <
62
+ const GT = 0x3e; // >
63
+ const SLASH = 0x2f; // /
64
+ const EQ = 0x3d; // =
65
+ const QUESTION = 0x3f; // ?
66
+ const BANG = 0x21; // !
67
+ const DQUOTE = 0x22; // "
68
+ const SQUOTE = 0x27; // '
69
+ const COLON = 0x3a; // :
70
+ const SPACE = 0x20;
71
+ const TAB = 0x09;
72
+ const LF = 0x0a;
73
+ const CR = 0x0d;
74
+ class XmlScanner {
17
75
  s;
76
+ n;
18
77
  i = 0;
78
+ /** Index of the first ":" in the name most recently read, or -1. */
79
+ colon = -1;
80
+ /** Position of the next "&" at or after the last place we looked, or -1 for none. */
81
+ nextAmp = 0;
19
82
  constructor(source) {
20
83
  this.s = source;
84
+ this.n = source.length;
21
85
  }
22
- parseDocument() {
23
- let root;
24
- while (this.i < this.s.length) {
86
+ scan(sink) {
87
+ while (this.i < this.n) {
25
88
  this.skipWhitespace();
26
- if (this.i >= this.s.length)
89
+ if (this.i >= this.n)
27
90
  break;
28
- if (this.s[this.i] !== "<") {
91
+ if (this.s.charCodeAt(this.i) !== LT) {
29
92
  // text outside root — skip
30
93
  this.i++;
31
94
  continue;
32
95
  }
33
- if (this.startsWith("<?")) {
96
+ const next = this.s.charCodeAt(this.i + 1);
97
+ if (next === QUESTION) {
34
98
  this.skipPi();
35
99
  }
36
- else if (this.startsWith("<!--")) {
37
- this.skipComment();
100
+ else if (next === BANG) {
101
+ if (this.s.startsWith("<!--", this.i))
102
+ this.skipComment();
103
+ else
104
+ this.skipBang();
38
105
  }
39
- else if (this.startsWith("<!")) {
40
- this.skipBang();
106
+ else {
107
+ this.element(sink);
108
+ return true;
109
+ }
110
+ }
111
+ return false;
112
+ }
113
+ /** Scan the element starting at `i` (which is "<") and everything inside it. */
114
+ element(sink) {
115
+ const s = this.s;
116
+ // Open elements, innermost last, with whether each one wants character data.
117
+ const open = [];
118
+ const wantText = [];
119
+ if (this.openTag(sink, open, wantText) === 0)
120
+ return;
121
+ while (open.length > 0) {
122
+ if (this.i >= this.n)
123
+ throw new Error(`Expected "</" at position ${this.i}`);
124
+ const c = s.charCodeAt(this.i);
125
+ if (c !== LT) {
126
+ if (wantText[wantText.length - 1])
127
+ sink.text(this.readText());
128
+ else
129
+ this.skipText();
130
+ continue;
131
+ }
132
+ const next = s.charCodeAt(this.i + 1);
133
+ if (next === SLASH) {
134
+ this.i += 2;
135
+ const closing = this.readName();
136
+ const name = open[open.length - 1];
137
+ if (closing !== name) {
138
+ throw new Error(`Mismatched closing tag: expected </${name}>, got </${closing}>`);
139
+ }
140
+ this.skipWhitespace();
141
+ this.expectChar(GT, ">");
142
+ open.pop();
143
+ wantText.pop();
144
+ sink.end(name);
145
+ }
146
+ else if (next === BANG) {
147
+ if (s.startsWith("<!--", this.i)) {
148
+ this.skipComment();
149
+ }
150
+ else if (s.startsWith("<![CDATA[", this.i)) {
151
+ if (wantText[wantText.length - 1])
152
+ sink.text(this.readCData());
153
+ else
154
+ this.skipCData();
155
+ }
156
+ else {
157
+ // Unknown declaration inside content — treat it as an element and
158
+ // let the name reader fail on it, as before.
159
+ this.openTag(sink, open, wantText);
160
+ }
161
+ }
162
+ else if (next === QUESTION) {
163
+ this.skipPi();
41
164
  }
42
165
  else {
43
- root = this.parseElement();
44
- break;
166
+ this.openTag(sink, open, wantText);
45
167
  }
46
168
  }
47
- return root;
48
169
  }
49
- parseElement() {
50
- this.expect("<");
170
+ /**
171
+ * Read a start tag at `i`, report it, and push it onto `open` unless it was
172
+ * self-closing or the sink skipped it. Returns the new depth of `open`.
173
+ */
174
+ openTag(sink, open, wantText) {
175
+ const s = this.s;
176
+ this.i++; // <
51
177
  const name = this.readName();
178
+ const local = this.colon >= 0 ? s.substring(this.colon + 1, this.i) : name;
52
179
  const attributes = {};
53
180
  this.readAttributes(attributes);
54
181
  this.skipWhitespace();
55
- if (this.s[this.i] === "/" && this.s[this.i + 1] === ">") {
56
- // self-closing
182
+ if (s.charCodeAt(this.i) === SLASH && s.charCodeAt(this.i + 1) === GT) {
57
183
  this.i += 2;
58
- return { name, attributes, children: [] };
184
+ if (sink.start(name, local, attributes, true) !== Visit.Skip)
185
+ sink.end(name);
186
+ return open.length;
59
187
  }
60
- this.expect(">");
61
- const children = [];
62
- let text;
63
- while (this.i < this.s.length) {
64
- if (this.startsWith("</"))
65
- break;
66
- if (this.s[this.i] === "<") {
67
- if (this.startsWith("<!--")) {
68
- this.skipComment();
69
- }
70
- else if (this.startsWith("<![CDATA[")) {
71
- const cd = this.readCData();
72
- text = text === undefined ? cd : text + cd;
73
- }
74
- else if (this.startsWith("<?")) {
75
- this.skipPi();
188
+ this.expectChar(GT, ">");
189
+ const visit = sink.start(name, local, attributes, false);
190
+ if (visit === Visit.Skip) {
191
+ this.skipContent(name);
192
+ return open.length;
193
+ }
194
+ open.push(name);
195
+ wantText.push(visit === Visit.All);
196
+ return open.length;
197
+ }
198
+ /**
199
+ * Fast-forward past the content and end tag of the element `name` whose
200
+ * start tag was just consumed, validating nesting but allocating nothing.
201
+ */
202
+ skipContent(name) {
203
+ const s = this.s;
204
+ // Open elements below `name`, as (start, length) spans into the source.
205
+ const spans = [];
206
+ let depth = 1;
207
+ while (depth > 0) {
208
+ const lt = s.indexOf("<", this.i);
209
+ if (lt === -1)
210
+ throw new Error(`Expected "</" at position ${this.n}`);
211
+ this.i = lt;
212
+ const next = s.charCodeAt(lt + 1);
213
+ if (next === SLASH) {
214
+ this.i += 2;
215
+ const start = this.i;
216
+ this.skipName();
217
+ const length = this.i - start;
218
+ if (spans.length === 0) {
219
+ if (length !== name.length || !s.startsWith(name, start)) {
220
+ throw new Error(`Mismatched closing tag: expected </${name}>, got </${s.substring(start, this.i)}>`);
221
+ }
76
222
  }
77
223
  else {
78
- children.push(this.parseElement());
224
+ const openLength = spans.pop();
225
+ const openStart = spans.pop();
226
+ if (!this.sameSpan(openStart, openLength, start, length)) {
227
+ throw new Error(`Mismatched closing tag: expected </${s.substring(openStart, openStart + openLength)}>, got </${s.substring(start, this.i)}>`);
228
+ }
79
229
  }
230
+ this.skipWhitespace();
231
+ this.expectChar(GT, ">");
232
+ depth--;
233
+ }
234
+ else if (next === BANG && s.startsWith("<!--", lt)) {
235
+ this.skipComment();
236
+ }
237
+ else if (next === BANG && s.startsWith("<![CDATA[", lt)) {
238
+ const end = s.indexOf("]]>", lt + 9);
239
+ if (end === -1)
240
+ throw new Error("Unterminated CDATA section");
241
+ this.i = end + 3;
242
+ }
243
+ else if (next === QUESTION) {
244
+ this.skipPi();
80
245
  }
81
246
  else {
82
- const t = this.readText();
83
- if (t.length > 0) {
84
- text = text === undefined ? t : text + t;
247
+ this.i++;
248
+ const start = this.i;
249
+ this.skipName();
250
+ const length = this.i - start;
251
+ this.skipAttributes();
252
+ this.skipWhitespace();
253
+ if (s.charCodeAt(this.i) === SLASH && s.charCodeAt(this.i + 1) === GT) {
254
+ this.i += 2;
255
+ continue;
85
256
  }
257
+ this.expectChar(GT, ">");
258
+ spans.push(start, length);
259
+ depth++;
86
260
  }
87
261
  }
88
- // closing tag </name>
89
- this.expect("</");
90
- const closing = this.readName();
91
- if (closing !== name) {
92
- throw new Error(`Mismatched closing tag: expected </${name}>, got </${closing}>`);
262
+ }
263
+ sameSpan(aStart, aLength, bStart, bLength) {
264
+ if (aLength !== bLength)
265
+ return false;
266
+ const s = this.s;
267
+ for (let k = 0; k < aLength; k++) {
268
+ if (s.charCodeAt(aStart + k) !== s.charCodeAt(bStart + k))
269
+ return false;
93
270
  }
94
- this.skipWhitespace();
95
- this.expect(">");
96
- const el = { name, attributes, children };
97
- if (text !== undefined)
98
- el.text = text;
99
- return el;
271
+ return true;
100
272
  }
101
273
  readAttributes(attrs) {
102
- while (this.i < this.s.length) {
274
+ const s = this.s;
275
+ while (this.i < this.n) {
103
276
  this.skipWhitespace();
104
- const ch = this.s[this.i];
105
- if (ch === ">" || ch === "/")
277
+ const ch = s.charCodeAt(this.i);
278
+ if (ch === GT || ch === SLASH)
106
279
  return;
107
280
  const key = this.readName();
108
281
  this.skipWhitespace();
109
- this.expect("=");
282
+ this.expectChar(EQ, "=");
283
+ this.skipWhitespace();
284
+ attrs[key] = this.readAttrValue();
285
+ }
286
+ }
287
+ /** Like readAttributes, but validates only; nothing is built. */
288
+ skipAttributes() {
289
+ const s = this.s;
290
+ while (this.i < this.n) {
291
+ this.skipWhitespace();
292
+ const ch = s.charCodeAt(this.i);
293
+ if (ch === GT || ch === SLASH)
294
+ return;
295
+ this.skipName();
296
+ this.skipWhitespace();
297
+ this.expectChar(EQ, "=");
110
298
  this.skipWhitespace();
111
- const value = this.readAttrValue();
112
- attrs[key] = value;
299
+ const quote = s.charCodeAt(this.i);
300
+ if (quote !== DQUOTE && quote !== SQUOTE) {
301
+ throw new Error(`Expected quote at position ${this.i}`);
302
+ }
303
+ this.i++;
304
+ const end = s.indexOf(quote === DQUOTE ? '"' : "'", this.i);
305
+ this.i = end === -1 ? this.n + 1 : end + 1;
113
306
  }
114
307
  }
115
308
  readAttrValue() {
116
- const quote = this.s[this.i];
117
- if (quote !== '"' && quote !== "'") {
309
+ const quote = this.s.charCodeAt(this.i);
310
+ if (quote !== DQUOTE && quote !== SQUOTE) {
118
311
  throw new Error(`Expected quote at position ${this.i}`);
119
312
  }
120
313
  this.i++;
121
314
  const start = this.i;
122
- while (this.i < this.s.length && this.s[this.i] !== quote) {
123
- this.i++;
315
+ let end = this.s.indexOf(quote === DQUOTE ? '"' : "'", start);
316
+ if (end === -1)
317
+ end = this.n;
318
+ this.i = end + 1; // skip closing quote
319
+ return this.slice(start, end);
320
+ }
321
+ /**
322
+ * Substring with entities decoded. Values rarely contain "&", so instead of
323
+ * scanning each one, the position of the next "&" is remembered and only
324
+ * refreshed once the scan has moved past it.
325
+ */
326
+ slice(start, end) {
327
+ let amp = this.nextAmp;
328
+ if (amp !== -1 && amp < start) {
329
+ amp = this.s.indexOf("&", start);
330
+ this.nextAmp = amp;
124
331
  }
125
- const value = this.s.substring(start, this.i);
126
- this.i++; // skip closing quote
127
- return decodeXmlEntities(value);
332
+ const value = this.s.substring(start, end);
333
+ return amp !== -1 && amp < end ? decodeXmlEntities(value) : value;
128
334
  }
129
335
  readText() {
130
336
  const start = this.i;
131
- while (this.i < this.s.length && this.s[this.i] !== "<") {
132
- this.i++;
133
- }
134
- return decodeXmlEntities(this.s.substring(start, this.i));
337
+ let end = this.s.indexOf("<", start);
338
+ if (end === -1)
339
+ end = this.n;
340
+ this.i = end;
341
+ return this.slice(start, end);
342
+ }
343
+ skipText() {
344
+ const end = this.s.indexOf("<", this.i);
345
+ this.i = end === -1 ? this.n : end;
346
+ }
347
+ skipCData() {
348
+ const end = this.s.indexOf("]]>", this.i + 9);
349
+ if (end === -1)
350
+ throw new Error("Unterminated CDATA section");
351
+ this.i = end + 3;
135
352
  }
136
353
  readName() {
137
354
  const start = this.i;
138
- while (this.i < this.s.length) {
139
- const c = this.s[this.i];
140
- if (c === " " ||
141
- c === "\t" ||
142
- c === "\n" ||
143
- c === "\r" ||
144
- c === ">" ||
145
- c === "/" ||
146
- c === "=")
355
+ this.skipName();
356
+ return this.s.substring(start, this.i);
357
+ }
358
+ /** Advance past a name, recording the position of its first ":" in `colon`. */
359
+ skipName() {
360
+ const s = this.s;
361
+ let i = this.i;
362
+ let colon = -1;
363
+ while (i < this.n) {
364
+ const c = s.charCodeAt(i);
365
+ if (c === SPACE || c === TAB || c === LF || c === CR || c === GT || c === SLASH || c === EQ)
147
366
  break;
148
- this.i++;
367
+ if (c === COLON && colon === -1)
368
+ colon = i;
369
+ i++;
149
370
  }
150
- return this.s.substring(start, this.i);
371
+ this.colon = colon;
372
+ this.i = i;
151
373
  }
152
374
  readCData() {
153
375
  this.i += 9; // skip <![CDATA[
@@ -161,65 +383,68 @@ class XmlReader {
161
383
  skipPi() {
162
384
  this.i += 2; // skip <?
163
385
  const end = this.s.indexOf("?>", this.i);
164
- this.i = end === -1 ? this.s.length : end + 2;
386
+ this.i = end === -1 ? this.n : end + 2;
165
387
  }
166
388
  skipComment() {
167
389
  this.i += 4; // skip <!--
168
390
  const end = this.s.indexOf("-->", this.i);
169
- this.i = end === -1 ? this.s.length : end + 3;
391
+ this.i = end === -1 ? this.n : end + 3;
170
392
  }
171
393
  skipBang() {
172
394
  // Skip <!DOCTYPE ...> and similar
173
395
  this.i += 2;
174
396
  let depth = 1;
175
- while (this.i < this.s.length && depth > 0) {
176
- if (this.s[this.i] === "<")
397
+ while (this.i < this.n && depth > 0) {
398
+ const c = this.s.charCodeAt(this.i);
399
+ if (c === LT)
177
400
  depth++;
178
- else if (this.s[this.i] === ">")
401
+ else if (c === GT)
179
402
  depth--;
180
403
  this.i++;
181
404
  }
182
405
  }
183
406
  skipWhitespace() {
184
- while (this.i < this.s.length) {
185
- const c = this.s[this.i];
186
- if (c !== " " && c !== "\t" && c !== "\n" && c !== "\r")
407
+ const s = this.s;
408
+ let i = this.i;
409
+ while (i < this.n) {
410
+ const c = s.charCodeAt(i);
411
+ if (c !== SPACE && c !== TAB && c !== LF && c !== CR)
187
412
  break;
188
- this.i++;
413
+ i++;
189
414
  }
415
+ this.i = i;
190
416
  }
191
- expect(str) {
192
- if (!this.s.startsWith(str, this.i)) {
193
- throw new Error(`Expected "${str}" at position ${this.i}`);
417
+ expectChar(code, shown) {
418
+ if (this.s.charCodeAt(this.i) !== code) {
419
+ throw new Error(`Expected "${shown}" at position ${this.i}`);
194
420
  }
195
- this.i += str.length;
196
- }
197
- startsWith(prefix) {
198
- return this.s.startsWith(prefix, this.i);
421
+ this.i++;
199
422
  }
200
423
  }
201
424
  // ---------------------------------------------------------------------------
202
425
  // Entity helpers
203
426
  // ---------------------------------------------------------------------------
427
+ const ENTITY_RE = /&(?:amp|lt|gt|quot|apos|#x[0-9a-fA-F]+|#[0-9]+);/g;
428
+ function decodeEntity(m) {
429
+ if (m === "&amp;")
430
+ return "&";
431
+ if (m === "&lt;")
432
+ return "<";
433
+ if (m === "&gt;")
434
+ return ">";
435
+ if (m === "&quot;")
436
+ return '"';
437
+ if (m === "&apos;")
438
+ return "'";
439
+ if (m.startsWith("&#x"))
440
+ return String.fromCodePoint(Number.parseInt(m.slice(3, -1), 16));
441
+ return String.fromCodePoint(Number.parseInt(m.slice(2, -1), 10));
442
+ }
204
443
  /** Decode XML predefined and numeric character entities in a string. */
205
444
  function decodeXmlEntities(s) {
206
445
  if (!s.includes("&"))
207
446
  return s;
208
- return s.replace(/&(?:amp|lt|gt|quot|apos|#x[0-9a-fA-F]+|#[0-9]+);/g, (m) => {
209
- if (m === "&amp;")
210
- return "&";
211
- if (m === "&lt;")
212
- return "<";
213
- if (m === "&gt;")
214
- return ">";
215
- if (m === "&quot;")
216
- return '"';
217
- if (m === "&apos;")
218
- return "'";
219
- if (m.startsWith("&#x"))
220
- return String.fromCodePoint(Number.parseInt(m.slice(3, -1), 16));
221
- return String.fromCodePoint(Number.parseInt(m.slice(2, -1), 10));
222
- });
447
+ return s.replace(ENTITY_RE, decodeEntity);
223
448
  }
224
449
  // ---------------------------------------------------------------------------
225
450
  // Serializer
@@ -229,48 +454,74 @@ function decodeXmlEntities(s) {
229
454
  * Produces a well-formed XML document with declaration.
230
455
  */
231
456
  export function serializeXml(element) {
232
- const parts = ['<?xml version="1.0" encoding="UTF-8"?>\n'];
233
- writeElement(parts, element, 0);
234
- parts.push("\n");
235
- return parts.join("");
457
+ return `<?xml version="1.0" encoding="UTF-8"?>\n${writeElement(element, 0)}\n`;
236
458
  }
237
- function writeElement(parts, el, depth) {
238
- const indent = " ".repeat(depth);
239
- parts.push(indent, "<", el.name);
240
- for (const [key, value] of Object.entries(el.attributes)) {
459
+ /** Indentation strings by depth, built once and reused across documents. */
460
+ const INDENTS = [];
461
+ function indentFor(depth) {
462
+ let s = INDENTS[depth];
463
+ if (s === undefined) {
464
+ s = " ".repeat(depth);
465
+ INDENTS[depth] = s;
466
+ }
467
+ return s;
468
+ }
469
+ function writeElement(el, depth) {
470
+ const indent = indentFor(depth);
471
+ let out = `${indent}<${el.name}`;
472
+ const attributes = el.attributes;
473
+ for (const key in attributes) {
474
+ const value = attributes[key];
241
475
  // el.attributes is typed Record<string, string>, but content built from
242
476
  // generated/untyped code can leave a value undefined at runtime — skip
243
477
  // rather than crash the whole serialization on one bad attribute.
244
478
  if (typeof value !== "string")
245
479
  continue;
246
- parts.push(" ", key, '="', escapeAttr(value), '"');
480
+ out += ` ${key}="${escapeAttr(value)}"`;
247
481
  }
248
- const hasChildren = el.children.length > 0;
482
+ const children = el.children;
483
+ const hasChildren = children.length > 0;
249
484
  const hasText = el.text !== undefined;
250
- if (!hasChildren && !hasText) {
251
- parts.push("/>\n");
252
- return;
253
- }
254
- parts.push(">");
255
- if (hasText) {
256
- parts.push(escapeText(el.text));
257
- }
485
+ if (!hasChildren && !hasText)
486
+ return `${out}/>\n`;
487
+ out += ">";
488
+ if (hasText)
489
+ out += escapeText(el.text);
258
490
  if (hasChildren) {
259
- parts.push("\n");
260
- for (const child of el.children) {
261
- writeElement(parts, child, depth + 1);
262
- }
263
- parts.push(indent);
491
+ out += "\n";
492
+ for (const child of children)
493
+ out += writeElement(child, depth + 1);
494
+ out += indent;
264
495
  }
265
- parts.push("</", el.name, ">\n");
496
+ return `${out}</${el.name}>\n`;
497
+ }
498
+ // One regex test decides whether a value needs escaping at all; most do not,
499
+ // and the ones that do are rewritten in a single pass instead of six.
500
+ const ATTR_ESCAPE_RE = /[&<"\n\r\t]/;
501
+ const ATTR_ESCAPE_ALL_RE = /[&<"\n\r\t]/g;
502
+ const TEXT_ESCAPE_RE = /[&<>]/;
503
+ const TEXT_ESCAPE_ALL_RE = /[&<>]/g;
504
+ const ESCAPES = {
505
+ "&": "&amp;",
506
+ "<": "&lt;",
507
+ ">": "&gt;",
508
+ '"': "&quot;",
509
+ // Whitespace that XML parsers would normalize in attribute values.
510
+ "\n": "&#10;",
511
+ "\r": "&#13;",
512
+ "\t": "&#9;",
513
+ };
514
+ function escapeChar(ch) {
515
+ return ESCAPES[ch] ?? ch;
266
516
  }
267
517
  function escapeAttr(value) {
268
- let s = value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;");
269
- // Re-encode whitespace that XML parsers would normalize in attribute values
270
- s = s.replaceAll("\n", "&#10;").replaceAll("\r", "&#13;").replaceAll("\t", "&#9;");
271
- return s;
518
+ if (!ATTR_ESCAPE_RE.test(value))
519
+ return value;
520
+ return value.replace(ATTR_ESCAPE_ALL_RE, escapeChar);
272
521
  }
273
522
  function escapeText(value) {
274
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
523
+ if (!TEXT_ESCAPE_RE.test(value))
524
+ return value;
525
+ return value.replace(TEXT_ESCAPE_ALL_RE, escapeChar);
275
526
  }
276
527
  //# sourceMappingURL=xml-parser.js.map