scjson 0.3.3 → 0.4.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,920 @@
1
+ /**
2
+ * Agent Name: js-comment-promotion
3
+ *
4
+ * Part of the scjson project.
5
+ * Developed by Softoboros Technology Inc.
6
+ * Licensed under the BSD 1-Clause License.
7
+ *
8
+ * CONV-F (SCXML Comment Promotion) — JavaScript parity.
9
+ *
10
+ * See ``docs/concepts/SCJSON-CONV-00-CONCEPTS.md`` (CONV-F, ~lines 270-388)
11
+ * for the normative contract this module implements and
12
+ * ``py/scjson/comment_promotion.py`` for the canonical Python implementation
13
+ * this module mirrors.
14
+ *
15
+ * Public surface:
16
+ *
17
+ * - ``extractHelpTextFromXml(xmlStr)`` — XML pre-pass. Parses ``xmlStr`` with
18
+ * ``fast-xml-parser`` in ``preserveOrder`` + ``commentPropName`` mode,
19
+ * assigns deterministic addresses to every element, partitions XML comment
20
+ * nodes onto their owning element per CONV-F rules 1-8, and returns the
21
+ * comment-free XML plus an ``address -> [comment_text, ...]`` map.
22
+ * - ``attachHelpTextToModel(model, addressMap)`` — after the existing
23
+ * ``xmlToJson`` pipeline produces a JS object from the comment-free XML,
24
+ * walks the model in the same deterministic order and appends each comment
25
+ * string to the matching element's ``help_text`` array.
26
+ * - ``injectHelpTextCommentsIntoXml(xmlStr, model)`` — post-pass for the
27
+ * SCJSON-to-SCXML direction. Takes the model and the comment-free XML
28
+ * produced by ``jsonToXml`` and inserts ``<!-- ... -->`` nodes immediately
29
+ * before the owning element (or before the ``<scxml>`` root for root-level
30
+ * entries).
31
+ *
32
+ * Addressing
33
+ * ----------
34
+ *
35
+ * Each element gets an address of shape
36
+ * ``[["scxml", 0], ["state", 1], ["transition", 0], ...]`` where each
37
+ * segment is ``[local_tag_name, index_among_same-tag_element_siblings]``.
38
+ * This MUST match the Python tuple shape byte-for-byte (after canonical
39
+ * JSON serialization) so cross-language fixtures align.
40
+ *
41
+ * XML-local element names are used (the parser drops the ``xmlns`` prefix
42
+ * because SCXML elements are not namespace-qualified in serialized form;
43
+ * any future namespaced extensions would be opaque and not promoted).
44
+ *
45
+ * Model-attribute mapping
46
+ * -----------------------
47
+ *
48
+ * The JS model surface renames a few XML tags to avoid JavaScript reserved
49
+ * words:
50
+ *
51
+ * - ``<if>`` -> ``if_value``
52
+ * - ``<raise>`` -> ``raise_value``
53
+ * - ``<else>`` -> ``else_value``
54
+ *
55
+ * Address segments always use the XML local name; resolution into the model
56
+ * goes through ``TAG_TO_ATTR`` below.
57
+ *
58
+ * Singleton vs. array fields
59
+ * --------------------------
60
+ *
61
+ * Most child fields are arrays in the JS model (mirrors xsdata's
62
+ * ``list[Element]`` shape). Two exceptions match the Python model:
63
+ *
64
+ * - ``History.transition`` is a singleton object.
65
+ * - ``Initial.transition`` is a singleton object.
66
+ *
67
+ * Address-shape parity with Python only requires that the *first* sibling at
68
+ * index ``0`` resolves to that object; index > 0 returns ``null``.
69
+ *
70
+ * Script / Data exclusion (rule 5)
71
+ * --------------------------------
72
+ *
73
+ * Comments inside ``<script>`` and ``<data>`` are body content of opaque
74
+ * source. They are not promoted.
75
+ *
76
+ * Extension subtree exclusion (rule 6)
77
+ * ------------------------------------
78
+ *
79
+ * Any element whose local name is not in ``APPLIES_TO_TAGS`` is treated as an
80
+ * opaque extension. Comments inside such subtrees are not promoted.
81
+ *
82
+ * Emission text safety (§340-344)
83
+ * -------------------------------
84
+ *
85
+ * Comment text containing ``--`` is replaced with ``- -``; a body ending in
86
+ * ``-`` gets one trailing space appended; the builder handles XML
87
+ * metacharacters because comment bodies are emitted literally.
88
+ */
89
+ 'use strict';
90
+ const { XMLParser, XMLBuilder } = require('fast-xml-parser');
91
+ /**
92
+ * Local tag names that own a ``help_text`` field on the model side.
93
+ * Mirrors ``APPLIES_TO_TAGS`` in ``py/scjson/comment_promotion.py``.
94
+ */
95
+ const APPLIES_TO_TAGS = new Set([
96
+ 'scxml',
97
+ 'state',
98
+ 'parallel',
99
+ 'final',
100
+ 'history',
101
+ 'initial',
102
+ 'transition',
103
+ 'onentry',
104
+ 'onexit',
105
+ 'invoke',
106
+ 'finalize',
107
+ 'datamodel',
108
+ 'data',
109
+ 'donedata',
110
+ 'content',
111
+ 'param',
112
+ 'assign',
113
+ 'log',
114
+ 'raise',
115
+ 'if',
116
+ 'elseif',
117
+ 'else',
118
+ 'foreach',
119
+ 'send',
120
+ 'cancel',
121
+ 'script',
122
+ ]);
123
+ /**
124
+ * Tags whose textual content is opaque target-language source. Comments
125
+ * inside these elements remain content and MUST NOT be promoted (CONV-F
126
+ * rule 5).
127
+ */
128
+ const SOURCE_BODY_TAGS = new Set(['script', 'data']);
129
+ /**
130
+ * XML local-tag -> JS model attribute name. Mirrors xsdata's renames.
131
+ */
132
+ const TAG_TO_ATTR = {
133
+ if: 'if_value',
134
+ raise: 'raise_value',
135
+ else: 'else_value',
136
+ };
137
+ /**
138
+ * Fields in the JS model that hold a *singleton* object rather than an
139
+ * array of objects under XML elements named ``transition``.
140
+ *
141
+ * Used by ``resolveAddress`` so a singleton field can still satisfy an
142
+ * address segment ``["transition", 0]`` even though it is not wrapped in
143
+ * a JS array.
144
+ */
145
+ const SINGLETON_TRANSITION_PARENTS = new Set(['history', 'initial']);
146
+ /**
147
+ * Return the JS model attribute name for an XML local tag.
148
+ *
149
+ * @param {string} tag - XML local tag name.
150
+ * @returns {string} Attribute name on the JS model object.
151
+ */
152
+ function modelAttrForTag(tag) {
153
+ return Object.prototype.hasOwnProperty.call(TAG_TO_ATTR, tag)
154
+ ? TAG_TO_ATTR[tag]
155
+ : tag;
156
+ }
157
+ /**
158
+ * Canonical key for a tuple-address. Used as a Map key so two addresses
159
+ * with the same shape collide.
160
+ *
161
+ * @param {Array<[string, number]>} address - Tuple address.
162
+ * @returns {string} JSON serialization of the address.
163
+ */
164
+ function addressKey(address) {
165
+ return JSON.stringify(address);
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Comment text repair (parser side + emitter side share this).
169
+ // ---------------------------------------------------------------------------
170
+ /**
171
+ * Repair raw XML comment body text per CONV-F §294-299.
172
+ *
173
+ * Strips a common leading indentation margin from multi-line block comments
174
+ * and trims leading/trailing blank lines. Does NOT paragraph-wrap or
175
+ * coalesce. Single-line comments are stripped only of edge whitespace.
176
+ *
177
+ * @param {string} raw - Comment body text without the ``<!--`` / ``-->``
178
+ * delimiters.
179
+ * @returns {string} Repaired text suitable for storage in ``help_text``.
180
+ */
181
+ function repairCommentText(raw) {
182
+ if (raw === null || raw === undefined) {
183
+ return '';
184
+ }
185
+ const str = String(raw);
186
+ if (!str.includes('\n')) {
187
+ return str.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '');
188
+ }
189
+ const lines = str.split('\n');
190
+ while (lines.length && lines[0].trim() === '') {
191
+ lines.shift();
192
+ }
193
+ while (lines.length && lines[lines.length - 1].trim() === '') {
194
+ lines.pop();
195
+ }
196
+ if (lines.length === 0) {
197
+ return '';
198
+ }
199
+ const indents = [];
200
+ for (const line of lines) {
201
+ if (line.trim() === '')
202
+ continue;
203
+ const m = line.match(/^[ \t]*/);
204
+ indents.push(m ? m[0].length : 0);
205
+ }
206
+ const common = indents.length ? Math.min(...indents) : 0;
207
+ if (common) {
208
+ for (let i = 0; i < lines.length; i++) {
209
+ const line = lines[i];
210
+ lines[i] = line.length >= common ? line.slice(common) : line;
211
+ }
212
+ }
213
+ return lines.join('\n');
214
+ }
215
+ /**
216
+ * Return a body string safe to wrap in ``<!-- ... -->``.
217
+ *
218
+ * Per CONV-F §340-344: the forbidden sequence ``--`` is replaced with
219
+ * ``- -`` and a body ending in ``-`` gets one trailing space appended.
220
+ *
221
+ * @param {string} text - Repaired help_text entry.
222
+ * @returns {string} Comment-safe body.
223
+ */
224
+ function emitSafeCommentText(text) {
225
+ let safe = String(text == null ? '' : text).replace(/--/g, '- -');
226
+ if (safe.endsWith('-')) {
227
+ safe = safe + ' ';
228
+ }
229
+ return safe;
230
+ }
231
+ // ---------------------------------------------------------------------------
232
+ // Pre-pass node classifiers + helpers.
233
+ // ---------------------------------------------------------------------------
234
+ /**
235
+ * The preserveOrder shape of fast-xml-parser v5 represents every node as an
236
+ * object with exactly one own-data key plus an optional ``:@`` attributes
237
+ * group. This helper returns the data key, or ``null`` for the attributes
238
+ * group key itself.
239
+ *
240
+ * @param {object} node - preserveOrder node.
241
+ * @returns {string|null} The data key (tag name or ``#text`` / ``#comment``).
242
+ */
243
+ function nodeDataKey(node) {
244
+ if (node === null || typeof node !== 'object')
245
+ return null;
246
+ for (const k of Object.keys(node)) {
247
+ if (k !== ':@')
248
+ return k;
249
+ }
250
+ return null;
251
+ }
252
+ /**
253
+ * @param {object} node - preserveOrder node.
254
+ * @returns {boolean}
255
+ */
256
+ function isCommentNode(node) {
257
+ return nodeDataKey(node) === '#comment';
258
+ }
259
+ /**
260
+ * @param {object} node - preserveOrder node.
261
+ * @returns {boolean}
262
+ */
263
+ function isTextNode(node) {
264
+ return nodeDataKey(node) === '#text';
265
+ }
266
+ /**
267
+ * @param {object} node - preserveOrder node.
268
+ * @returns {boolean} True if this is a processing instruction node.
269
+ */
270
+ function isProcessingInstructionNode(node) {
271
+ const k = nodeDataKey(node);
272
+ return typeof k === 'string' && k.startsWith('?');
273
+ }
274
+ /**
275
+ * @param {object} node - preserveOrder node.
276
+ * @returns {boolean} True for element nodes (tag-named, not PI/comment/text).
277
+ */
278
+ function isElementNode(node) {
279
+ const k = nodeDataKey(node);
280
+ if (k === null)
281
+ return false;
282
+ if (k === '#text' || k === '#comment')
283
+ return false;
284
+ if (k.startsWith('?'))
285
+ return false;
286
+ if (k.startsWith('!'))
287
+ return false;
288
+ return true;
289
+ }
290
+ /**
291
+ * Extract the body text from a ``#comment`` node.
292
+ *
293
+ * @param {object} node - preserveOrder comment node.
294
+ * @returns {string} Raw body text.
295
+ */
296
+ function commentBody(node) {
297
+ const arr = node['#comment'];
298
+ if (!Array.isArray(arr) || arr.length === 0)
299
+ return '';
300
+ const first = arr[0];
301
+ if (first && typeof first === 'object' && '#text' in first) {
302
+ return String(first['#text'] == null ? '' : first['#text']);
303
+ }
304
+ return '';
305
+ }
306
+ /**
307
+ * Extract the body text from a ``#text`` node.
308
+ */
309
+ function textBody(node) {
310
+ if (!node || typeof node !== 'object')
311
+ return '';
312
+ const v = node['#text'];
313
+ return v == null ? '' : String(v);
314
+ }
315
+ /**
316
+ * Strip a local prefix from a tag returned by fast-xml-parser. The parser
317
+ * already drops the ``xmlns`` URI; this helper survives any future
318
+ * namespace-aware caller using ``{namespace}local`` form.
319
+ *
320
+ * @param {string} tag - Tag name.
321
+ * @returns {string} Local tag name.
322
+ */
323
+ function localName(tag) {
324
+ if (typeof tag !== 'string')
325
+ return tag;
326
+ if (tag.startsWith('{')) {
327
+ const end = tag.indexOf('}');
328
+ if (end !== -1)
329
+ return tag.slice(end + 1);
330
+ }
331
+ return tag;
332
+ }
333
+ // ---------------------------------------------------------------------------
334
+ // Pre-pass: tree walker that builds the address map.
335
+ // ---------------------------------------------------------------------------
336
+ /**
337
+ * Walk an element's preserveOrder children, partition comments onto
338
+ * deterministic addresses per CONV-F rules, and recurse.
339
+ *
340
+ * Mutates ``addressMap`` in place. Does NOT strip comments — that happens
341
+ * separately in ``buildCleanedTree``.
342
+ *
343
+ * @param {object} elem - preserveOrder element node.
344
+ * @param {Array<[string, number]>} address - Address of ``elem`` itself.
345
+ * @param {Map<string, string[]>} addressMap - canonical address-key ->
346
+ * comment list.
347
+ * @param {boolean} insideSourceBody - True if any ancestor is
348
+ * ``<script>`` / ``<data>``.
349
+ * @param {boolean} insideExtension - True if any ancestor is an opaque
350
+ * extension element.
351
+ */
352
+ function walkPromotion(elem, address, addressMap, insideSourceBody, insideExtension) {
353
+ const elemTag = nodeDataKey(elem);
354
+ if (elemTag === null)
355
+ return;
356
+ const elemLocal = localName(elemTag);
357
+ const elemInSource = insideSourceBody || SOURCE_BODY_TAGS.has(elemLocal);
358
+ // The root element is always treated as inside-known: APPLIES_TO_TAGS
359
+ // contains "scxml" so the root passes; for any other root we still walk
360
+ // its subtree but no promotions will land because elemInExtension stays
361
+ // true at every step.
362
+ const elemInExtension = insideExtension || (!APPLIES_TO_TAGS.has(elemLocal) && address.length > 0);
363
+ const children = elem[elemTag];
364
+ if (!Array.isArray(children) || children.length === 0)
365
+ return;
366
+ // Pending: comments awaiting a next element sibling.
367
+ let pending = [];
368
+ // Per-local-tag sibling counters for child elements.
369
+ const perTagCounter = Object.create(null);
370
+ for (const child of children) {
371
+ if (isTextNode(child)) {
372
+ const t = textBody(child);
373
+ if (t && t.trim() !== '') {
374
+ // Non-whitespace text severs the "pending -> next element"
375
+ // relationship; flush pending to *this* element (its parent).
376
+ if (pending.length) {
377
+ if (!elemInSource &&
378
+ !elemInExtension &&
379
+ APPLIES_TO_TAGS.has(elemLocal) &&
380
+ address.length > 0) {
381
+ const key = addressKey(address);
382
+ const existing = addressMap.get(key) || [];
383
+ addressMap.set(key, existing.concat(pending));
384
+ }
385
+ pending = [];
386
+ }
387
+ }
388
+ // whitespace-only text does not flush
389
+ continue;
390
+ }
391
+ if (isProcessingInstructionNode(child)) {
392
+ // PIs never promote, never sever.
393
+ continue;
394
+ }
395
+ if (isCommentNode(child)) {
396
+ const raw = commentBody(child);
397
+ pending.push(repairCommentText(raw));
398
+ continue;
399
+ }
400
+ if (isElementNode(child)) {
401
+ const childTag = nodeDataKey(child);
402
+ const childLocal = localName(childTag);
403
+ const idx = perTagCounter[childLocal] || 0;
404
+ perTagCounter[childLocal] = idx + 1;
405
+ const childAddr = address.concat([[childLocal, idx]]);
406
+ const targetEligible = APPLIES_TO_TAGS.has(childLocal) &&
407
+ !elemInSource &&
408
+ !elemInExtension;
409
+ if (pending.length && targetEligible) {
410
+ const key = addressKey(childAddr);
411
+ const existing = addressMap.get(key) || [];
412
+ addressMap.set(key, existing.concat(pending));
413
+ }
414
+ else if (pending.length) {
415
+ // Try flushing to parent if parent is promotion-eligible.
416
+ if (address.length > 0 &&
417
+ !elemInSource &&
418
+ !elemInExtension &&
419
+ APPLIES_TO_TAGS.has(elemLocal)) {
420
+ const key = addressKey(address);
421
+ const existing = addressMap.get(key) || [];
422
+ addressMap.set(key, existing.concat(pending));
423
+ }
424
+ // Else silently drop (extension subtree).
425
+ }
426
+ pending = [];
427
+ walkPromotion(child, childAddr, addressMap, elemInSource, elemInExtension);
428
+ continue;
429
+ }
430
+ // Anything else (DOCTYPE, etc.) — ignore.
431
+ }
432
+ // After all children, leftover pending comments attach to this element
433
+ // (rule 3). Suppressed under script/data and extension subtrees.
434
+ if (pending.length) {
435
+ if (!elemInSource &&
436
+ !elemInExtension &&
437
+ APPLIES_TO_TAGS.has(elemLocal) &&
438
+ address.length > 0) {
439
+ const key = addressKey(address);
440
+ const existing = addressMap.get(key) || [];
441
+ addressMap.set(key, existing.concat(pending));
442
+ }
443
+ }
444
+ }
445
+ /**
446
+ * Build a comment-free copy of the preserveOrder tree, preserving
447
+ * processing instructions but dropping every ``#comment``.
448
+ *
449
+ * @param {Array<object>} tree - Top-level preserveOrder array.
450
+ * @returns {Array<object>} Cleaned tree (new array, deep-cloned bodies).
451
+ */
452
+ function buildCleanedTree(tree) {
453
+ function clean(node) {
454
+ if (node === null || typeof node !== 'object')
455
+ return node;
456
+ const dataKey = nodeDataKey(node);
457
+ if (dataKey === '#comment')
458
+ return null;
459
+ const out = {};
460
+ for (const k of Object.keys(node)) {
461
+ const v = node[k];
462
+ if (k === ':@') {
463
+ out[k] = Array.isArray(v) ? v.slice() : Object.assign({}, v);
464
+ }
465
+ else if (Array.isArray(v)) {
466
+ const cleaned = [];
467
+ for (const c of v) {
468
+ const r = clean(c);
469
+ if (r !== null)
470
+ cleaned.push(r);
471
+ }
472
+ out[k] = cleaned;
473
+ }
474
+ else {
475
+ out[k] = v;
476
+ }
477
+ }
478
+ return out;
479
+ }
480
+ const result = [];
481
+ for (const n of tree) {
482
+ const r = clean(n);
483
+ if (r !== null)
484
+ result.push(r);
485
+ }
486
+ return result;
487
+ }
488
+ /**
489
+ * Locate the root element in a preserveOrder top-level tree.
490
+ *
491
+ * @param {Array<object>} tree - Top-level array.
492
+ * @returns {object|null} Root element node or null.
493
+ */
494
+ function findRootElement(tree) {
495
+ for (const node of tree) {
496
+ if (isElementNode(node))
497
+ return node;
498
+ }
499
+ return null;
500
+ }
501
+ /**
502
+ * Extract help_text promotion metadata from an SCXML string.
503
+ *
504
+ * @param {string} xmlStr - Raw SCXML string.
505
+ * @returns {{cleanedXml: string, addressMap: Map<string, string[]>, addressMapJson: Array<{address: Array<[string,number]>, comments: string[]}>}}
506
+ * The comment-free XML serialization and the address map.
507
+ * ``addressMapJson`` provides a stable JSON-friendly projection for
508
+ * downstream callers or cross-language fixture comparison.
509
+ * On parse failure returns the original string and an empty map.
510
+ */
511
+ function extractHelpTextFromXml(xmlStr) {
512
+ if (typeof xmlStr !== 'string' || xmlStr.length === 0) {
513
+ return { cleanedXml: xmlStr, addressMap: new Map(), addressMapJson: [] };
514
+ }
515
+ const parser = new XMLParser({
516
+ ignoreAttributes: false,
517
+ trimValues: false,
518
+ preserveOrder: true,
519
+ commentPropName: '#comment',
520
+ ignoreDeclaration: false,
521
+ ignorePiTags: false,
522
+ parseTagValue: false,
523
+ });
524
+ let tree;
525
+ try {
526
+ tree = parser.parse(xmlStr);
527
+ }
528
+ catch (e) {
529
+ return { cleanedXml: xmlStr, addressMap: new Map(), addressMapJson: [] };
530
+ }
531
+ if (!Array.isArray(tree)) {
532
+ return { cleanedXml: xmlStr, addressMap: new Map(), addressMapJson: [] };
533
+ }
534
+ const root = findRootElement(tree);
535
+ const addressMap = new Map();
536
+ if (root === null) {
537
+ return { cleanedXml: xmlStr, addressMap, addressMapJson: [] };
538
+ }
539
+ const rootLocal = localName(nodeDataKey(root));
540
+ const rootAddr = [[rootLocal, 0]];
541
+ // CONV-F rule 4: pre-root and post-root comments attach to root.
542
+ if (APPLIES_TO_TAGS.has(rootLocal)) {
543
+ const preRoot = [];
544
+ const postRoot = [];
545
+ let sawRoot = false;
546
+ for (const node of tree) {
547
+ if (node === root) {
548
+ sawRoot = true;
549
+ continue;
550
+ }
551
+ if (isCommentNode(node)) {
552
+ const repaired = repairCommentText(commentBody(node));
553
+ if (sawRoot) {
554
+ postRoot.push(repaired);
555
+ }
556
+ else {
557
+ preRoot.push(repaired);
558
+ }
559
+ }
560
+ }
561
+ if (preRoot.length || postRoot.length) {
562
+ const key = addressKey(rootAddr);
563
+ const existing = addressMap.get(key) || [];
564
+ addressMap.set(key, existing.concat(preRoot).concat(postRoot));
565
+ }
566
+ }
567
+ walkPromotion(root, rootAddr, addressMap, false, false);
568
+ // Strip comments and re-serialize.
569
+ const cleaned = buildCleanedTree(tree);
570
+ const builder = new XMLBuilder({
571
+ ignoreAttributes: false,
572
+ preserveOrder: true,
573
+ commentPropName: '#comment',
574
+ format: false,
575
+ suppressEmptyNode: true,
576
+ });
577
+ let cleanedXml;
578
+ try {
579
+ cleanedXml = builder.build(cleaned);
580
+ }
581
+ catch (e) {
582
+ cleanedXml = xmlStr;
583
+ }
584
+ const addressMapJson = [];
585
+ for (const [k, v] of addressMap.entries()) {
586
+ addressMapJson.push({ address: JSON.parse(k), comments: v.slice() });
587
+ }
588
+ return { cleanedXml, addressMap, addressMapJson };
589
+ }
590
+ // ---------------------------------------------------------------------------
591
+ // Attach helper: walk the JS model in lockstep with the address map.
592
+ // ---------------------------------------------------------------------------
593
+ /**
594
+ * Locate a child by ``(local_tag, index)`` segment on a JS model object.
595
+ *
596
+ * Handles both array-shaped collections and singleton transition fields
597
+ * (``History.transition`` / ``Initial.transition``).
598
+ *
599
+ * @param {object} parent - Parent model node.
600
+ * @param {string} parentTag - Local XML tag of the parent.
601
+ * @param {string} tag - Child local XML tag.
602
+ * @param {number} index - Sibling index.
603
+ * @returns {object|null}
604
+ */
605
+ function resolveChild(parent, parentTag, tag, index) {
606
+ if (parent === null || typeof parent !== 'object')
607
+ return null;
608
+ const attr = modelAttrForTag(tag);
609
+ const coll = parent[attr];
610
+ if (coll === undefined || coll === null)
611
+ return null;
612
+ if (Array.isArray(coll)) {
613
+ if (index >= 0 && index < coll.length)
614
+ return coll[index];
615
+ return null;
616
+ }
617
+ // Non-array: a singleton object. Only index 0 maps to it.
618
+ if (typeof coll === 'object' && index === 0)
619
+ return coll;
620
+ return null;
621
+ }
622
+ /**
623
+ * Navigate ``modelRoot`` to the element at ``address``.
624
+ *
625
+ * Address ``[["scxml", 0]]`` resolves to ``modelRoot`` itself.
626
+ *
627
+ * @param {object} modelRoot - Root SCJSON object.
628
+ * @param {Array<[string, number]>} address - Address tuple.
629
+ * @returns {object|null}
630
+ */
631
+ function resolveAddress(modelRoot, address) {
632
+ if (!Array.isArray(address) || address.length === 0)
633
+ return null;
634
+ let node = modelRoot;
635
+ let parentTag = address[0][0];
636
+ for (let i = 1; i < address.length; i++) {
637
+ const [tag, idx] = address[i];
638
+ node = resolveChild(node, parentTag, tag, idx);
639
+ if (node === null)
640
+ return null;
641
+ parentTag = tag;
642
+ }
643
+ return node;
644
+ }
645
+ /**
646
+ * Append each address's comment list to the corresponding model element's
647
+ * ``help_text`` array.
648
+ *
649
+ * Comments are appended to any existing ``help_text`` entries per CONV-F
650
+ * rule 7.
651
+ *
652
+ * @param {object} model - Root SCJSON object.
653
+ * @param {Map<string, string[]>} addressMap - Map produced by
654
+ * ``extractHelpTextFromXml``.
655
+ */
656
+ function attachHelpTextToModel(model, addressMap) {
657
+ if (!model || typeof model !== 'object')
658
+ return;
659
+ if (!addressMap || addressMap.size === 0)
660
+ return;
661
+ for (const [key, comments] of addressMap.entries()) {
662
+ if (!comments || comments.length === 0)
663
+ continue;
664
+ const address = JSON.parse(key);
665
+ const target = resolveAddress(model, address);
666
+ if (target === null || typeof target !== 'object')
667
+ continue;
668
+ if (Array.isArray(target.help_text)) {
669
+ target.help_text = target.help_text.concat(comments);
670
+ }
671
+ else if (target.help_text === undefined) {
672
+ target.help_text = comments.slice();
673
+ }
674
+ else {
675
+ // Scalar / non-array — coerce to array preserving existing.
676
+ target.help_text = [String(target.help_text)].concat(comments);
677
+ }
678
+ }
679
+ }
680
+ // ---------------------------------------------------------------------------
681
+ // Post-pass: inject comments into already-serialized SCXML.
682
+ // ---------------------------------------------------------------------------
683
+ /**
684
+ * Walk a model and record every non-empty ``help_text`` keyed by address.
685
+ *
686
+ * @param {object} node - Model node.
687
+ * @param {Array<[string, number]>} address - Address of ``node``.
688
+ * @param {Map<string, string[]>} out - Output map.
689
+ */
690
+ function collectHelpTextAddresses(node, address, out) {
691
+ if (node === null || typeof node !== 'object')
692
+ return;
693
+ if (Array.isArray(node.help_text) && node.help_text.length > 0) {
694
+ out.set(addressKey(address), node.help_text.slice());
695
+ }
696
+ // Recurse into typed child fields.
697
+ // We discover children by enumerating the model's own keys and treating
698
+ // any value that is an array of objects or a singleton object as a
699
+ // potential XML child collection — but we map JS attr name back to XML
700
+ // local tag name.
701
+ const reverseTagMap = Object.create(null);
702
+ for (const [tag, attr] of Object.entries(TAG_TO_ATTR)) {
703
+ reverseTagMap[attr] = tag;
704
+ }
705
+ for (const [attr, value] of Object.entries(node)) {
706
+ if (attr === 'help_text')
707
+ continue;
708
+ if (attr === 'other_attributes')
709
+ continue;
710
+ if (attr.startsWith('@_'))
711
+ continue;
712
+ if (attr.endsWith('_attribute'))
713
+ continue;
714
+ // Skip scalar attributes; only objects/arrays-of-objects are XML
715
+ // children we care about for addressing.
716
+ const tag = Object.prototype.hasOwnProperty.call(reverseTagMap, attr)
717
+ ? reverseTagMap[attr]
718
+ : attr;
719
+ // Only descend into collections whose attr name is plausibly an SCXML
720
+ // child element tag (or its renamed JS form).
721
+ if (!APPLIES_TO_TAGS.has(tag))
722
+ continue;
723
+ if (Array.isArray(value)) {
724
+ for (let i = 0; i < value.length; i++) {
725
+ const child = value[i];
726
+ if (child && typeof child === 'object') {
727
+ collectHelpTextAddresses(child, address.concat([[tag, i]]), out);
728
+ }
729
+ }
730
+ }
731
+ else if (value && typeof value === 'object') {
732
+ collectHelpTextAddresses(value, address.concat([[tag, 0]]), out);
733
+ }
734
+ }
735
+ }
736
+ /**
737
+ * Find a preserveOrder element at an address under ``rootNode``.
738
+ *
739
+ * @param {object} rootNode - Root preserveOrder element node.
740
+ * @param {Array<[string, number]>} address - Address tuple.
741
+ * @returns {{node: object, parent: Array<object>|null, indexInParent: number}|null}
742
+ */
743
+ function navigateXml(rootNode, address) {
744
+ if (!Array.isArray(address) || address.length === 0)
745
+ return null;
746
+ let node = rootNode;
747
+ let parentList = null;
748
+ let indexInParent = -1;
749
+ for (let i = 1; i < address.length; i++) {
750
+ const [tag, idx] = address[i];
751
+ const dk = nodeDataKey(node);
752
+ if (dk === null)
753
+ return null;
754
+ const children = node[dk];
755
+ if (!Array.isArray(children))
756
+ return null;
757
+ let count = 0;
758
+ let found = -1;
759
+ for (let j = 0; j < children.length; j++) {
760
+ const child = children[j];
761
+ if (!isElementNode(child))
762
+ continue;
763
+ if (localName(nodeDataKey(child)) === tag) {
764
+ if (count === idx) {
765
+ found = j;
766
+ break;
767
+ }
768
+ count++;
769
+ }
770
+ }
771
+ if (found < 0)
772
+ return null;
773
+ parentList = children;
774
+ indexInParent = found;
775
+ node = children[found];
776
+ }
777
+ return { node, parent: parentList, indexInParent };
778
+ }
779
+ /**
780
+ * Build a ``#comment`` preserveOrder node with safe body text.
781
+ *
782
+ * @param {string} text - Repaired help_text entry.
783
+ * @returns {object}
784
+ */
785
+ function makeCommentNode(text) {
786
+ return { '#comment': [{ '#text': emitSafeCommentText(text) }] };
787
+ }
788
+ /**
789
+ * Re-emit ``xmlStr`` with each model element's ``help_text`` entries as
790
+ * leading XML comments.
791
+ *
792
+ * @param {string} xmlStr - Comment-free SCXML produced by the existing
793
+ * ``jsonToXml`` pipeline.
794
+ * @param {object} model - The SCJSON model whose ``help_text`` lists drive
795
+ * injection.
796
+ * @returns {string} SCXML with leading comments inserted.
797
+ */
798
+ function injectHelpTextCommentsIntoXml(xmlStr, model) {
799
+ if (!model || typeof model !== 'object')
800
+ return xmlStr;
801
+ const collected = new Map();
802
+ // Determine the root local tag by parsing once. xsdata-equivalent JS
803
+ // output always starts with ``<scxml`` (or `<?xml ... ?><scxml`); for
804
+ // safety we read the actual root from the parsed tree.
805
+ const parser = new XMLParser({
806
+ ignoreAttributes: false,
807
+ trimValues: false,
808
+ preserveOrder: true,
809
+ commentPropName: '#comment',
810
+ ignoreDeclaration: false,
811
+ ignorePiTags: false,
812
+ parseTagValue: false,
813
+ });
814
+ let tree;
815
+ try {
816
+ tree = parser.parse(xmlStr);
817
+ }
818
+ catch (e) {
819
+ return xmlStr;
820
+ }
821
+ if (!Array.isArray(tree))
822
+ return xmlStr;
823
+ const root = findRootElement(tree);
824
+ if (root === null)
825
+ return xmlStr;
826
+ const rootLocal = localName(nodeDataKey(root));
827
+ const rootAddr = [[rootLocal, 0]];
828
+ collectHelpTextAddresses(model, rootAddr, collected);
829
+ if (collected.size === 0)
830
+ return xmlStr;
831
+ // Pop the root entries (special handling: insert as document-level
832
+ // siblings before the root element).
833
+ const rootKey = addressKey(rootAddr);
834
+ const rootEntries = collected.get(rootKey);
835
+ collected.delete(rootKey);
836
+ // For deterministic order, sort the remaining addresses lexically so
837
+ // re-emit order is stable regardless of Map iteration order.
838
+ const sortedKeys = Array.from(collected.keys()).sort();
839
+ // To preserve correct sibling indexes while inserting, process each
840
+ // address fresh by re-navigating after each insertion at that address.
841
+ for (const key of sortedKeys) {
842
+ const address = JSON.parse(key);
843
+ const found = navigateXml(root, address);
844
+ if (found === null)
845
+ continue;
846
+ const { parent, indexInParent } = found;
847
+ if (parent === null)
848
+ continue;
849
+ const entries = collected.get(key);
850
+ // Determine the leading whitespace that precedes ``target`` in the
851
+ // already-formatted XML. The XML builder is run with format:false
852
+ // (below) so we explicitly reproduce input indentation between
853
+ // injected comments and the target element.
854
+ let leadingWhitespace = '';
855
+ if (indexInParent > 0) {
856
+ const prev = parent[indexInParent - 1];
857
+ if (isTextNode(prev)) {
858
+ const t = textBody(prev);
859
+ if (/\n/.test(t)) {
860
+ leadingWhitespace = '\n' + t.split('\n').pop();
861
+ }
862
+ else {
863
+ leadingWhitespace = t;
864
+ }
865
+ }
866
+ }
867
+ const insertion = [];
868
+ for (let i = 0; i < entries.length; i++) {
869
+ insertion.push(makeCommentNode(entries[i]));
870
+ if (leadingWhitespace) {
871
+ insertion.push({ '#text': leadingWhitespace });
872
+ }
873
+ }
874
+ // Insert all comments + spacing at the same position; the target
875
+ // shifts right.
876
+ parent.splice(indexInParent, 0, ...insertion);
877
+ }
878
+ // Insert root-level comments as document-level siblings BEFORE the root.
879
+ if (rootEntries && rootEntries.length) {
880
+ const rootIdx = tree.indexOf(root);
881
+ if (rootIdx >= 0) {
882
+ const insertion = [];
883
+ for (let i = 0; i < rootEntries.length; i++) {
884
+ insertion.push(makeCommentNode(rootEntries[i]));
885
+ // Insert a newline between adjacent root-level siblings so the
886
+ // result is human-readable.
887
+ insertion.push({ '#text': '\n' });
888
+ }
889
+ tree.splice(rootIdx, 0, ...insertion);
890
+ }
891
+ }
892
+ const builder = new XMLBuilder({
893
+ ignoreAttributes: false,
894
+ preserveOrder: true,
895
+ commentPropName: '#comment',
896
+ format: false,
897
+ suppressEmptyNode: true,
898
+ });
899
+ let out;
900
+ try {
901
+ out = builder.build(tree);
902
+ }
903
+ catch (e) {
904
+ return xmlStr;
905
+ }
906
+ return out;
907
+ }
908
+ module.exports = {
909
+ APPLIES_TO_TAGS,
910
+ SOURCE_BODY_TAGS,
911
+ TAG_TO_ATTR,
912
+ SINGLETON_TRANSITION_PARENTS,
913
+ modelAttrForTag,
914
+ addressKey,
915
+ repairCommentText,
916
+ emitSafeCommentText,
917
+ extractHelpTextFromXml,
918
+ attachHelpTextToModel,
919
+ injectHelpTextCommentsIntoXml,
920
+ };