scjson 0.2.1 → 0.3.1

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,1372 @@
1
+ "use strict";
2
+ /**
3
+ * Agent Name: js-converters
4
+ *
5
+ * Part of the scjson project.
6
+ * Developed by Softoboros Technology Inc.
7
+ * Licensed under the BSD 1-Clause License.
8
+ */
9
+ /**
10
+ * @file Core conversion utilities shared between Node and browser builds.
11
+ */
12
+ const { XMLParser, XMLBuilder } = require('fast-xml-parser');
13
+ const Ajv = require('ajv');
14
+ const schema = require('../scjson.schema.json');
15
+ /**
16
+ * Keys that should always be represented as arrays.
17
+ *
18
+ * These are derived from `scjson.schema.json` where the corresponding
19
+ * properties have a ``type`` of ``array``. The parser used here will
20
+ * collapse single elements into objects, so we normalise the structure
21
+ * back to arrays in order to maintain canonical output that matches the
22
+ * reference Python implementation.
23
+ */
24
+ const ARRAY_KEYS = new Set([
25
+ 'assign',
26
+ 'cancel',
27
+ 'content',
28
+ 'data',
29
+ 'datamodel',
30
+ 'donedata',
31
+ 'final',
32
+ 'finalize',
33
+ 'foreach',
34
+ 'history',
35
+ 'if_value',
36
+ 'initial',
37
+ 'invoke',
38
+ 'log',
39
+ 'onentry',
40
+ 'onexit',
41
+ 'other_element',
42
+ 'parallel',
43
+ 'param',
44
+ 'raise_value',
45
+ 'script',
46
+ 'send',
47
+ 'state',
48
+ ]);
49
+ /// Known SCXML structural fields that should be pulled out of `content[]`
50
+ const STRUCTURAL_FIELDS = new Set([
51
+ 'state', 'parallel', 'final', 'history',
52
+ 'transition', 'onentry', 'onexit', 'invoke',
53
+ 'datamodel', 'data', 'initial', 'script',
54
+ 'log', 'assign', 'send', 'cancel',
55
+ 'param', 'if', 'elseif', 'else',
56
+ 'foreach', 'raise', 'content'
57
+ ]);
58
+ /**
59
+ * Recursively convert an XML Element to SCJSON-compliant JS object.
60
+ */
61
+ function convert(element) {
62
+ const result = {
63
+ tag: element.tagName,
64
+ ...Object.fromEntries(Array.from(element.attributes).map(attr => [attr.name, attr.value]))
65
+ };
66
+ // Initialize known structural containers if needed
67
+ STRUCTURAL_FIELDS.forEach(field => {
68
+ if (element.querySelector(field)) {
69
+ result[field] = [];
70
+ }
71
+ });
72
+ for (const child of element.children) {
73
+ const converted = convert(child);
74
+ const tag = child.tagName;
75
+ if (STRUCTURAL_FIELDS.has(tag)) {
76
+ // Attach to known field
77
+ result[tag] = result[tag] || [];
78
+ result[tag].push(converted);
79
+ }
80
+ else {
81
+ // Fallback to generic 'content' array
82
+ result.content = result.content || [];
83
+ result.content.push(converted);
84
+ }
85
+ }
86
+ // Handle text content if present
87
+ const rawText = element.textContent;
88
+ if (rawText && element.children.length === 0 && rawText.trim() !== '') {
89
+ result.content = [rawText];
90
+ }
91
+ return result;
92
+ }
93
+ /**
94
+ * Keys that should never be pruned even when empty.
95
+ */
96
+ const ALWAYS_KEEP = new Set(['else_value', 'else', 'final', 'onentry']);
97
+ /**
98
+ * Remove transition elements directly under the <scxml> root.
99
+ *
100
+ * The reference Python implementation ignores these top level
101
+ * transitions entirely. To maintain parity we drop them during
102
+ * conversion.
103
+ *
104
+ * @param {object} obj - Parsed SCXML object.
105
+ */
106
+ function stripRootTransitions(obj) {
107
+ if (obj && typeof obj === 'object' && Array.isArray(obj.transition)) {
108
+ delete obj.transition;
109
+ }
110
+ }
111
+ /**
112
+ * Map of attribute names to their scjson property equivalents.
113
+ */
114
+ const ATTRIBUTE_MAP = {
115
+ datamodel: 'datamodel_attribute',
116
+ initial: 'initial_attribute',
117
+ type: 'type_value',
118
+ raise: 'raise_value',
119
+ };
120
+ /**
121
+ * Attributes whose whitespace should be collapsed.
122
+ */
123
+ const COLLAPSE_ATTRS = new Set([
124
+ 'expr',
125
+ 'cond',
126
+ 'event',
127
+ 'target',
128
+ 'delay',
129
+ 'location',
130
+ 'name',
131
+ 'src',
132
+ 'id',
133
+ ]);
134
+ /**
135
+ * Collapse whitespace in attribute string values recursively.
136
+ *
137
+ * @param {object|Array|string} value - Value to normalise.
138
+ * @returns {object|Array|string} Normalised value.
139
+ */
140
+ function collapseWhitespace(value) {
141
+ if (Array.isArray(value)) {
142
+ return value.map(collapseWhitespace);
143
+ }
144
+ if (value && typeof value === 'object') {
145
+ for (const [k, v] of Object.entries(value)) {
146
+ if ((k.endsWith('_attribute') || COLLAPSE_ATTRS.has(k)) && typeof v === 'string') {
147
+ if (v.startsWith('\n')) {
148
+ value[k] = '\n' + v.slice(1).replace(/[\n\r\t]/g, ' ');
149
+ }
150
+ else {
151
+ value[k] = v.replace(/[\n\r\t]/g, ' ');
152
+ }
153
+ }
154
+ else {
155
+ value[k] = collapseWhitespace(v);
156
+ }
157
+ }
158
+ return value;
159
+ }
160
+ return value;
161
+ }
162
+ /**
163
+ * Split whitespace-separated token attributes.
164
+ *
165
+ * Attributes such as ``initial`` and ``target`` can contain multiple
166
+ * identifiers separated by spaces. This function mirrors the Python
167
+ * implementation by splitting those values into arrays before further
168
+ * normalisation.
169
+ *
170
+ * @param {object|Array} value - Parsed value to adjust in place.
171
+ */
172
+ function splitTokenAttrs(value, parent) {
173
+ if (Array.isArray(value))
174
+ return value.forEach(v => splitTokenAttrs(v, parent));
175
+ if (value && typeof value === 'object') {
176
+ for (const [k, v] of Object.entries(value)) {
177
+ if ((k === 'initial' || k === 'initial_attribute') && typeof v === 'string') {
178
+ value[k] = v.trim().split(/\s+/);
179
+ continue;
180
+ }
181
+ if (k === 'transition') {
182
+ if (parent !== 'history' && parent !== 'initial') {
183
+ const arr = Array.isArray(v) ? v : [v];
184
+ arr.forEach(tr => {
185
+ if (typeof tr.target === 'string')
186
+ tr.target = tr.target.trim().split(/\s+/);
187
+ splitTokenAttrs(tr, k);
188
+ });
189
+ value[k] = arr;
190
+ }
191
+ else {
192
+ if (typeof v.target === 'string')
193
+ v.target = v.target.trim().split(/\s+/);
194
+ splitTokenAttrs(v, k);
195
+ }
196
+ continue;
197
+ }
198
+ splitTokenAttrs(v, k);
199
+ }
200
+ }
201
+ }
202
+ /**
203
+ * Reorder SCXML object keys to match canonical output.
204
+ *
205
+ * ``datamodel`` elements and the attributes ``version`` and
206
+ * ``datamodel_attribute`` are appended to the end of their
207
+ * respective objects so that JSON generated by this converter
208
+ * matches the reference Python implementation.
209
+ *
210
+ * @param {object|Array} value - Parsed object to adjust in place.
211
+ */
212
+ function reorderScxml(value) {
213
+ if (Array.isArray(value)) {
214
+ value.forEach(reorderScxml);
215
+ return;
216
+ }
217
+ if (value && typeof value === 'object') {
218
+ for (const v of Object.values(value)) {
219
+ reorderScxml(v);
220
+ }
221
+ if (Object.prototype.hasOwnProperty.call(value, 'datamodel')) {
222
+ const dm = value.datamodel;
223
+ delete value.datamodel;
224
+ value.datamodel = dm;
225
+ }
226
+ if (Object.prototype.hasOwnProperty.call(value, 'version')) {
227
+ const ver = value.version;
228
+ delete value.version;
229
+ value.version = ver;
230
+ }
231
+ if (Object.prototype.hasOwnProperty.call(value, 'datamodel_attribute')) {
232
+ const attr = value.datamodel_attribute;
233
+ delete value.datamodel_attribute;
234
+ value.datamodel_attribute = attr;
235
+ }
236
+ if (Object.prototype.hasOwnProperty.call(value, 'item') &&
237
+ Object.prototype.hasOwnProperty.call(value, 'index')) {
238
+ const item = value.item;
239
+ const index = value.index;
240
+ delete value.item;
241
+ delete value.index;
242
+ value.item = item;
243
+ value.index = index;
244
+ }
245
+ }
246
+ }
247
+ /**
248
+ * Recursively rename XML parser keys to match the scjson schema.
249
+ *
250
+ * - Attribute names prefixed with ``@_`` are stripped of the prefix.
251
+ * - Text content keys ``#text`` are converted to a ``content`` array.
252
+ *
253
+ * @param {object|Array} value - Parsed value to normalise.
254
+ * @returns {object|Array} Normalised value.
255
+ */
256
+ function normaliseKeys(value) {
257
+ if (Array.isArray(value)) {
258
+ return value.map(normaliseKeys);
259
+ }
260
+ if (value && typeof value === 'object') {
261
+ const out = {};
262
+ for (const [k, v] of Object.entries(value)) {
263
+ if (k === '#text') {
264
+ const text = normaliseKeys(v);
265
+ if (text !== undefined) {
266
+ if (Array.isArray(out.content)) {
267
+ if (Array.isArray(text)) {
268
+ out.content.push(...text);
269
+ }
270
+ else {
271
+ out.content.push(text);
272
+ }
273
+ }
274
+ else if (out.content !== undefined) {
275
+ out.content = Array.isArray(text)
276
+ ? [out.content, ...text]
277
+ : [out.content, text];
278
+ }
279
+ else {
280
+ out.content = Array.isArray(text) ? text : [text];
281
+ }
282
+ }
283
+ continue;
284
+ }
285
+ let nk = k;
286
+ if (k.startsWith('@_')) {
287
+ const attr = k.slice(2);
288
+ nk = ATTRIBUTE_MAP[attr] || attr;
289
+ }
290
+ else if (k === 'if') {
291
+ nk = 'if_value';
292
+ }
293
+ else if (k === 'raise') {
294
+ nk = 'raise_value';
295
+ }
296
+ out[nk] = normaliseKeys(v);
297
+ }
298
+ return out;
299
+ }
300
+ return value;
301
+ }
302
+ /**
303
+ * Recursively normalise values that should be arrays.
304
+ *
305
+ * @param {object} obj - Parsed object to adjust in place.
306
+ */
307
+ function ensureArrays(obj, parent) {
308
+ if (!obj || typeof obj !== 'object')
309
+ return;
310
+ for (const [k, v] of Object.entries(obj)) {
311
+ if (ARRAY_KEYS.has(k) && v !== undefined) {
312
+ Array.isArray(v)
313
+ ? v.forEach(o => ensureArrays(o, k))
314
+ : (obj[k] = [v], ensureArrays(obj[k][0], k));
315
+ continue;
316
+ }
317
+ if (k === 'transition' && v && typeof v === 'object') {
318
+ if (parent !== 'history' && parent !== 'initial') {
319
+ const arr = Array.isArray(v) ? v : [v];
320
+ arr.forEach(tr => {
321
+ if (tr.target !== undefined && !Array.isArray(tr.target))
322
+ tr.target = [tr.target];
323
+ ensureArrays(tr, k);
324
+ });
325
+ obj[k] = arr;
326
+ }
327
+ else {
328
+ if (v.target !== undefined && !Array.isArray(v.target))
329
+ v.target = [v.target];
330
+ ensureArrays(v, k);
331
+ }
332
+ continue;
333
+ }
334
+ Array.isArray(v) ? v.forEach(o => ensureArrays(o, k)) : typeof v === 'object' && ensureArrays(v, k);
335
+ }
336
+ }
337
+ /**
338
+ * Convert ``else`` elements to the ``else_value`` schema key.
339
+ *
340
+ * Empty ``<else/>`` tags become an object literal so they survive
341
+ * subsequent calls to :func:`removeEmpty`.
342
+ *
343
+ * @param {object|Array} value - Parsed object to adjust in place.
344
+ */
345
+ function fixEmptyElse(value) {
346
+ if (Array.isArray(value)) {
347
+ value.forEach(v => fixEmptyElse(v));
348
+ return;
349
+ }
350
+ if (value && typeof value === 'object') {
351
+ for (const [k, v] of Object.entries(value)) {
352
+ if (k === 'else') {
353
+ value.else_value = v === '' ? {} : v;
354
+ delete value.else;
355
+ fixEmptyElse(value.else_value);
356
+ continue;
357
+ }
358
+ fixEmptyElse(v);
359
+ }
360
+ }
361
+ }
362
+ /**
363
+ * Normalise empty ``onentry`` and ``onexit`` elements.
364
+ *
365
+ * The XML parser represents empty tags as an empty string. The Python
366
+ * reference output preserves these elements as empty objects so they
367
+ * survive subsequent cleaning steps. This helper mirrors that behaviour.
368
+ *
369
+ * @param {object|Array} value - Parsed object to adjust in place.
370
+ */
371
+ function fixEmptyOnentry(value) {
372
+ if (Array.isArray(value)) {
373
+ value.forEach(fixEmptyOnentry);
374
+ return;
375
+ }
376
+ if (value && typeof value === 'object') {
377
+ for (const [k, v] of Object.entries(value)) {
378
+ if ((k === 'onentry' || k === 'onexit') &&
379
+ Array.isArray(v) &&
380
+ v.length === 1 &&
381
+ typeof v[0] === 'string' &&
382
+ v[0].trim() === '') {
383
+ value[k] = [{}];
384
+ continue;
385
+ }
386
+ fixEmptyOnentry(v);
387
+ }
388
+ }
389
+ }
390
+ /**
391
+ * Decode HTML entities in string values.
392
+ *
393
+ * Fast XML parser leaves character references intact. This helper matches the
394
+ * Python implementation by converting entities like ``&#xA;`` to their literal
395
+ * characters.
396
+ *
397
+ * @param {object|Array|string} value - Parsed value to normalise.
398
+ * @returns {object|Array|string} Normalised value.
399
+ */
400
+ function decodeEntities(value) {
401
+ if (Array.isArray(value)) {
402
+ return value.map(decodeEntities);
403
+ }
404
+ if (value && typeof value === 'object') {
405
+ for (const [k, v] of Object.entries(value)) {
406
+ value[k] = decodeEntities(v);
407
+ }
408
+ return value;
409
+ }
410
+ if (typeof value === 'string') {
411
+ return value
412
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
413
+ .replace(/&#([0-9]+);/g, (_, d) => String.fromCharCode(parseInt(d, 10)))
414
+ .replace(/&quot;/g, '"')
415
+ .replace(/&apos;/g, "'")
416
+ .replace(/&amp;/g, '&')
417
+ .replace(/&lt;/g, '<')
418
+ .replace(/&gt;/g, '>');
419
+ }
420
+ return value;
421
+ }
422
+ /**
423
+ * Normalise script elements after parsing.
424
+ *
425
+ * Ensures that each ``script`` entry is an object with a ``content`` array
426
+ * as required by the schema.
427
+ *
428
+ * @param {object|Array} value - Parsed object to adjust in place.
429
+ */
430
+ function fixScripts(value) {
431
+ if (Array.isArray(value)) {
432
+ value.forEach(fixScripts);
433
+ return;
434
+ }
435
+ if (value && typeof value === 'object') {
436
+ for (const [k, v] of Object.entries(value)) {
437
+ if (k === 'script') {
438
+ if (Array.isArray(v)) {
439
+ value[k] = v.map(s => typeof s === 'string' ? { content: [s] } : (fixScripts(s), s));
440
+ }
441
+ else if (typeof v === 'string') {
442
+ value[k] = { content: [v] };
443
+ }
444
+ else {
445
+ fixScripts(v);
446
+ }
447
+ continue;
448
+ }
449
+ fixScripts(v);
450
+ }
451
+ }
452
+ }
453
+ /**
454
+ * Normalise nested SCXML documents within invoke content.
455
+ *
456
+ * XML parser output represents nested ``<scxml>`` elements as a key
457
+ * named ``scxml`` inside the ``content`` element. The reference
458
+ * Python implementation instead stores the nested machine under a
459
+ * ``content`` array. This helper replicates that behaviour.
460
+ *
461
+ * @param {object|Array} value - Parsed object to adjust in place.
462
+ */
463
+ function fixNestedScxml(value) {
464
+ if (Array.isArray(value)) {
465
+ value.forEach(fixNestedScxml);
466
+ return;
467
+ }
468
+ if (value && typeof value === 'object') {
469
+ if (Object.prototype.hasOwnProperty.call(value, 'scxml')) {
470
+ const sub = value.scxml;
471
+ delete value.scxml;
472
+ const arr = Array.isArray(sub) ? sub : [sub];
473
+ arr.forEach(v => {
474
+ if (Object.prototype.hasOwnProperty.call(v, 'final') && v.final === '') {
475
+ v.final = [{}];
476
+ }
477
+ if (v.initial_attribute !== undefined && v.initial === undefined) {
478
+ v.initial = v.initial_attribute;
479
+ delete v.initial_attribute;
480
+ }
481
+ if (typeof v.version === 'string') {
482
+ const n = parseFloat(v.version);
483
+ if (!Number.isNaN(n))
484
+ v.version = n;
485
+ }
486
+ if (v.version === undefined) {
487
+ v.version = 1.0;
488
+ }
489
+ for (const k of Object.keys(v)) {
490
+ if (k === '@_xmlns' || k.startsWith('xmlns')) {
491
+ delete v[k];
492
+ }
493
+ }
494
+ if (v.datamodel_attribute === undefined) {
495
+ v.datamodel_attribute = 'null';
496
+ }
497
+ fixNestedScxml(v);
498
+ });
499
+ value.content = [{ content: arr }];
500
+ }
501
+ for (const v of Object.values(value)) {
502
+ fixNestedScxml(v);
503
+ }
504
+ }
505
+ }
506
+ /**
507
+ * Apply default values for assign elements.
508
+ *
509
+ * The scjson schema expects ``assign`` elements to include a
510
+ * ``type_value`` attribute with a default of ``replacechildren``.
511
+ * This helper ensures the attribute is present when not specified in
512
+ * the original XML.
513
+ *
514
+ * @param {object|Array} value - Parsed object to adjust in place.
515
+ */
516
+ function fixAssignDefaults(value) {
517
+ if (Array.isArray(value)) {
518
+ value.forEach(fixAssignDefaults);
519
+ return;
520
+ }
521
+ if (value && typeof value === 'object') {
522
+ if (Object.prototype.hasOwnProperty.call(value, 'assign')) {
523
+ const arr = Array.isArray(value.assign) ? value.assign : [value.assign];
524
+ arr.forEach(a => {
525
+ if (a.type_value === undefined) {
526
+ a.type_value = 'replacechildren';
527
+ }
528
+ fixAssignDefaults(a);
529
+ });
530
+ value.assign = arr;
531
+ }
532
+ for (const v of Object.values(value)) {
533
+ fixAssignDefaults(v);
534
+ }
535
+ }
536
+ }
537
+ /**
538
+ * Hoist unexpected attributes into ``other_attributes``.
539
+ *
540
+ * Handles the ``id`` attribute on ``assign`` elements and the
541
+ * misspelled ``intial`` attribute on ``state`` elements so that
542
+ * generated scjson matches the reference Python output.
543
+ *
544
+ * @param {object|Array} value - Parsed object to adjust in place.
545
+ */
546
+ // Avoid infinite recursion on cyclic structures
547
+ const VISITED_FLAG = Symbol('fixOtherAttributesVisited');
548
+ function fixOtherAttributes(value) {
549
+ if (Array.isArray(value)) {
550
+ value.forEach(fixOtherAttributes);
551
+ return;
552
+ }
553
+ if (value && typeof value === 'object') {
554
+ if (value[VISITED_FLAG]) {
555
+ return;
556
+ }
557
+ value[VISITED_FLAG] = true;
558
+ if (Object.prototype.hasOwnProperty.call(value, 'assign')) {
559
+ const arr = Array.isArray(value.assign) ? value.assign : [value.assign];
560
+ arr.forEach(a => {
561
+ if (a.id !== undefined) {
562
+ a.other_attributes = a.other_attributes || {};
563
+ a.other_attributes.id = a.id;
564
+ delete a.id;
565
+ }
566
+ fixOtherAttributes(a);
567
+ });
568
+ value.assign = arr;
569
+ }
570
+ if (value.intial !== undefined) {
571
+ value.other_attributes = value.other_attributes || {};
572
+ value.other_attributes.intial = value.intial;
573
+ delete value.intial;
574
+ }
575
+ for (const [k, v] of Object.entries(value)) {
576
+ if (v === value || k === 'other_attributes')
577
+ continue;
578
+ fixOtherAttributes(v);
579
+ }
580
+ delete value[VISITED_FLAG];
581
+ }
582
+ }
583
+ /**
584
+ * Apply default values for send elements.
585
+ *
586
+ * The SCXML specification defines ``type="scxml"`` and ``delay="0s"``
587
+ * as defaults. This mirrors the behaviour of the Python converter so
588
+ * round-trip conversions remain consistent.
589
+ *
590
+ * @param {object|Array} value - Parsed object to adjust in place.
591
+ */
592
+ function fixSendDefaults(value) {
593
+ if (Array.isArray(value)) {
594
+ value.forEach(fixSendDefaults);
595
+ return;
596
+ }
597
+ if (value && typeof value === 'object') {
598
+ if (Object.prototype.hasOwnProperty.call(value, 'send')) {
599
+ const arr = Array.isArray(value.send) ? value.send : [value.send];
600
+ arr.forEach(s => {
601
+ if (s.type_value === undefined) {
602
+ s.type_value = 'scxml';
603
+ }
604
+ if (s.delay === undefined) {
605
+ s.delay = '0s';
606
+ }
607
+ fixSendContent(s);
608
+ fixSendDefaults(s);
609
+ });
610
+ value.send = arr;
611
+ }
612
+ for (const v of Object.values(value)) {
613
+ fixSendDefaults(v);
614
+ }
615
+ }
616
+ }
617
+ /**
618
+ * Normalise inline content elements under ``send``.
619
+ *
620
+ * ``<content>`` children inside ``<send>`` should always be objects with a
621
+ * ``content`` array according to the scjson schema. The fast-xml-parser library
622
+ * collapses simple text nodes to strings which leads to mismatches when
623
+ * compared with the Python implementation. This helper wraps such strings in an
624
+ * object structure.
625
+ *
626
+ * @param {object|Array} value - Parsed object to adjust in place.
627
+ */
628
+ function fixSendContent(value) {
629
+ if (Array.isArray(value)) {
630
+ value.forEach(fixSendContent);
631
+ return;
632
+ }
633
+ if (value && typeof value === 'object') {
634
+ if (Object.prototype.hasOwnProperty.call(value, 'qname')) {
635
+ if (Object.prototype.hasOwnProperty.call(value, 'version')) {
636
+ delete value.version;
637
+ }
638
+ if (Object.prototype.hasOwnProperty.call(value, 'datamodel_attribute')) {
639
+ delete value.datamodel_attribute;
640
+ }
641
+ }
642
+ if (Object.prototype.hasOwnProperty.call(value, 'send')) {
643
+ const arr = Array.isArray(value.send) ? value.send : [value.send];
644
+ arr.forEach(s => {
645
+ if (Object.prototype.hasOwnProperty.call(s, 'content')) {
646
+ const cArr = Array.isArray(s.content) ? s.content : [s.content];
647
+ const mapped = cArr.map(c => {
648
+ if (typeof c !== 'object') {
649
+ const raw = String(c);
650
+ if (raw.trim() === '')
651
+ return null;
652
+ return { content: [{ content: [raw] }] };
653
+ }
654
+ if (c && typeof c === 'object') {
655
+ if (typeof c.content === 'string' || typeof c.content === 'number' || typeof c.content === 'boolean') {
656
+ c.content = [String(c.content)];
657
+ }
658
+ if (Array.isArray(c.content)) {
659
+ c.content = c.content
660
+ .map(i => (typeof i === 'string' ? String(i) : i))
661
+ .filter(i => !(typeof i === 'string' && i.trim() === '') && i !== null && i !== undefined);
662
+ if (c.content.length === 0)
663
+ delete c.content;
664
+ }
665
+ // Convert raw XML objects into canonical content structures
666
+ if (!c.qname && !c.expr && !c.content && Object.keys(c).length === 1) {
667
+ const [k, v] = Object.entries(c)[0];
668
+ c = { content: [convertDataNode(k, v)] };
669
+ }
670
+ else {
671
+ fixSendContent(c);
672
+ }
673
+ if (c.qname && c.version !== undefined)
674
+ delete c.version;
675
+ if (c.qname && c.datamodel_attribute !== undefined)
676
+ delete c.datamodel_attribute;
677
+ return c;
678
+ }
679
+ return null;
680
+ }).filter(x => x !== null);
681
+ s.content = mapped;
682
+ }
683
+ fixSendContent(s);
684
+ });
685
+ value.send = arr;
686
+ }
687
+ for (const v of Object.values(value)) {
688
+ fixSendContent(v);
689
+ }
690
+ }
691
+ }
692
+ /**
693
+ * Normalise inline content elements under ``donedata``.
694
+ *
695
+ * ``<content>`` children inside ``<donedata>`` should always be objects with a
696
+ * ``content`` array so that round-trips match the reference Python
697
+ * implementation. Strings are wrapped in an object accordingly.
698
+ *
699
+ * @param {object|Array} value - Parsed object to adjust in place.
700
+ */
701
+ function fixDonedataContent(value) {
702
+ if (Array.isArray(value)) {
703
+ value.forEach(fixDonedataContent);
704
+ return;
705
+ }
706
+ if (value && typeof value === 'object') {
707
+ if (Object.prototype.hasOwnProperty.call(value, 'donedata')) {
708
+ const arr = Array.isArray(value.donedata) ? value.donedata : [value.donedata];
709
+ arr.forEach(d => {
710
+ if (Object.prototype.hasOwnProperty.call(d, 'content')) {
711
+ const cArr = Array.isArray(d.content) ? d.content : [d.content];
712
+ const mapped = cArr.map(c => {
713
+ if (typeof c !== 'object') {
714
+ const raw = String(c);
715
+ if (raw.trim() === '')
716
+ return null;
717
+ return { content: [raw] };
718
+ }
719
+ if (c && typeof c === 'object') {
720
+ if (typeof c.content === 'string' ||
721
+ typeof c.content === 'number' ||
722
+ typeof c.content === 'boolean') {
723
+ c.content = [String(c.content)];
724
+ }
725
+ // Convert raw XML objects into canonical content structures
726
+ if (!c.qname && !c.expr && !c.content && Object.keys(c).length === 1) {
727
+ const [k, v] = Object.entries(c)[0];
728
+ c = { content: [convertDataNode(k, v)] };
729
+ }
730
+ else {
731
+ fixDonedataContent(c);
732
+ }
733
+ if (c.qname && c.version !== undefined)
734
+ delete c.version;
735
+ if (c.qname && c.datamodel_attribute !== undefined)
736
+ delete c.datamodel_attribute;
737
+ return c;
738
+ }
739
+ return null;
740
+ });
741
+ const clean = mapped.filter(x => x !== null);
742
+ d.content = clean.length === 1 ? clean[0] : clean;
743
+ }
744
+ fixDonedataContent(d);
745
+ });
746
+ value.donedata = arr;
747
+ }
748
+ for (const v of Object.values(value)) {
749
+ fixDonedataContent(v);
750
+ }
751
+ }
752
+ }
753
+ /**
754
+ * Convert arbitrary objects parsed under ``<data>`` elements into
755
+ * canonical content structures.
756
+ *
757
+ * The fast-xml-parser library represents child elements of ``<data>`` as
758
+ * direct properties on the data object. This helper converts those
759
+ * properties to the schema's ``content`` array with ``qname``,
760
+ * ``attributes`` and ``children`` fields so that round-trips match the
761
+ * Python implementation.
762
+ *
763
+ * @param {string} name - Element name.
764
+ * @param {*} node - Parsed element value.
765
+ * @returns {object} Canonical content object.
766
+ */
767
+ function convertDataNode(name, node) {
768
+ if (Array.isArray(node)) {
769
+ return node.map(n => convertDataNode(name, n));
770
+ }
771
+ if (node && typeof node === 'object') {
772
+ const attrs = {};
773
+ const children = [];
774
+ let text = '';
775
+ for (const [k, v] of Object.entries(node)) {
776
+ if (k === 'content') {
777
+ if (Array.isArray(v)) {
778
+ if (v.every(x => typeof x !== 'object')) {
779
+ text += v.join('');
780
+ }
781
+ else {
782
+ v.forEach(sub => {
783
+ if (typeof sub === 'object' && Object.keys(sub).length === 1) {
784
+ const [ck, cv] = Object.entries(sub)[0];
785
+ const c = convertDataNode(ck, cv);
786
+ Array.isArray(c) ? children.push(...c) : children.push(c);
787
+ }
788
+ });
789
+ }
790
+ }
791
+ else if (typeof v === 'string') {
792
+ text += v;
793
+ }
794
+ continue;
795
+ }
796
+ if (v && typeof v === 'object') {
797
+ const c = convertDataNode(k, v);
798
+ Array.isArray(c) ? children.push(...c) : children.push(c);
799
+ }
800
+ else {
801
+ attrs[k] = String(v);
802
+ }
803
+ }
804
+ const out = { qname: name, text };
805
+ if (children.length)
806
+ out.children = children;
807
+ if (Object.keys(attrs).length)
808
+ out.attributes = attrs;
809
+ if (out.text === undefined)
810
+ out.text = '';
811
+ return out;
812
+ }
813
+ return { qname: name, text: String(node) };
814
+ }
815
+ /**
816
+ * Recursively normalise ``<data>`` elements that contain inline XML.
817
+ *
818
+ * @param {object|Array} value - Parsed object to adjust in place.
819
+ */
820
+ function fixDataContent(value) {
821
+ if (Array.isArray(value)) {
822
+ value.forEach(fixDataContent);
823
+ return;
824
+ }
825
+ if (value && typeof value === 'object') {
826
+ if (Object.prototype.hasOwnProperty.call(value, 'data')) {
827
+ const arr = Array.isArray(value.data) ? value.data : [value.data];
828
+ arr.forEach(d => {
829
+ const content = [];
830
+ for (const [k, v] of Object.entries(d)) {
831
+ if (!['id', 'src', 'expr', 'otherAttributes', 'content'].includes(k)) {
832
+ const c = convertDataNode(k, v);
833
+ Array.isArray(c) ? content.push(...c) : content.push(c);
834
+ delete d[k];
835
+ }
836
+ }
837
+ if (content.length)
838
+ d.content = content;
839
+ });
840
+ value.data = arr;
841
+ }
842
+ for (const v of Object.values(value)) {
843
+ fixDataContent(v);
844
+ }
845
+ }
846
+ }
847
+ /**
848
+ * Convert a canonical content object back into XML element format.
849
+ *
850
+ * ``jsonToXml`` relies on this helper to rebuild inline XML stored under
851
+ * ``<data>`` elements. Objects with ``qname``, ``attributes``, and ``children``
852
+ * fields are translated to the structure expected by ``fast-xml-parser``.
853
+ *
854
+ * @param {object} node - Canonical content object.
855
+ * @returns {object} XML builder structure keyed by element name.
856
+ */
857
+ function restoreDataNode(node) {
858
+ const out = {};
859
+ if (node.attributes) {
860
+ for (const [k, v] of Object.entries(node.attributes)) {
861
+ out[`@_${k}`] = v;
862
+ }
863
+ }
864
+ if (node.text !== undefined && node.text !== '') {
865
+ out['#text'] = node.text;
866
+ }
867
+ if (Array.isArray(node.children)) {
868
+ node.children.forEach(c => {
869
+ const r = restoreDataNode(c);
870
+ const [ck, cv] = Object.entries(r)[0];
871
+ if (out[ck]) {
872
+ if (Array.isArray(out[ck])) {
873
+ out[ck].push(cv);
874
+ }
875
+ else {
876
+ out[ck] = [out[ck], cv];
877
+ }
878
+ }
879
+ else {
880
+ out[ck] = cv;
881
+ }
882
+ });
883
+ }
884
+ if (!node.qname.includes(':') && !node.qname.startsWith('{') && node.qname !== 'scxml') {
885
+ out['@_xmlns'] = '';
886
+ }
887
+ return { [node.qname]: out };
888
+ }
889
+ /**
890
+ * Remove namespace URIs from ``qname`` fields.
891
+ *
892
+ * @param {object|Array} value - Parsed object to adjust in place.
893
+ */
894
+ function stripQnameNs(value) {
895
+ if (Array.isArray(value)) {
896
+ value.forEach(stripQnameNs);
897
+ return;
898
+ }
899
+ if (value && typeof value === 'object') {
900
+ for (const [k, v] of Object.entries(value)) {
901
+ if (k === 'qname' && typeof v === 'string') {
902
+ value[k] = v.replace(/^\{[^}]+\}/, '');
903
+ continue;
904
+ }
905
+ stripQnameNs(v);
906
+ }
907
+ }
908
+ }
909
+ /**
910
+ * Recursively remove ``xmlns`` attributes from nested objects.
911
+ *
912
+ * @param {object|Array} value - Parsed object to adjust in place.
913
+ */
914
+ function stripXmlns(value) {
915
+ if (Array.isArray(value)) {
916
+ value.forEach(stripXmlns);
917
+ return;
918
+ }
919
+ if (value && typeof value === 'object') {
920
+ for (const k of Object.keys(value)) {
921
+ if (k === '@_xmlns' || k.startsWith('xmlns')) {
922
+ delete value[k];
923
+ }
924
+ else {
925
+ stripXmlns(value[k]);
926
+ }
927
+ }
928
+ }
929
+ }
930
+ /**
931
+ * Collapse nested ``content`` wrappers created during parsing.
932
+ *
933
+ * A ``content`` array may contain a single object with its own
934
+ * ``content`` array when the original XML element only held text.
935
+ * This helper flattens that structure so that round-tripping through
936
+ * XML does not introduce spurious elements.
937
+ *
938
+ * @param {object|Array} value - Parsed object to adjust in place.
939
+ */
940
+ function flattenContent(value) {
941
+ if (Array.isArray(value)) {
942
+ value.forEach(flattenContent);
943
+ return;
944
+ }
945
+ if (value && typeof value === 'object') {
946
+ if (Array.isArray(value.content) &&
947
+ value.content.length === 1 &&
948
+ value.content[0] &&
949
+ typeof value.content[0] === 'object' &&
950
+ Object.keys(value.content[0]).length === 1 &&
951
+ Array.isArray(value.content[0].content) &&
952
+ value.content[0].content.length === 1 &&
953
+ value.content[0].content[0] &&
954
+ typeof value.content[0].content[0] === 'object' &&
955
+ !Object.prototype.hasOwnProperty.call(value.content[0].content[0], 'qname')) {
956
+ value.content = [value.content[0].content[0]];
957
+ }
958
+ for (const v of Object.values(value)) {
959
+ flattenContent(v);
960
+ }
961
+ }
962
+ }
963
+ /**
964
+ * Remove nulls and empty containers from values recursively.
965
+ *
966
+ * Certain keys like ``final`` must always be preserved even when they
967
+ * would otherwise be considered empty. The caller provides the key so we
968
+ * can decide whether to keep an empty object.
969
+ *
970
+ * @param {*} value - Candidate value.
971
+ * @param {string} [key] - Key name associated with ``value`` in the parent.
972
+ * @returns {*} Sanitised value.
973
+ */
974
+ function removeEmpty(value, key) {
975
+ if (Array.isArray(value)) {
976
+ const arr = value.map(v => removeEmpty(v, key)).filter(v => v !== undefined);
977
+ if (arr.length > 0 || ALWAYS_KEEP.has(key)) {
978
+ return arr;
979
+ }
980
+ return undefined;
981
+ }
982
+ if (value && typeof value === 'object') {
983
+ const obj = {};
984
+ for (const [k, v] of Object.entries(value)) {
985
+ const r = removeEmpty(v, k);
986
+ if (r !== undefined)
987
+ obj[k] = r;
988
+ }
989
+ if (Object.keys(obj).length > 0 || ALWAYS_KEEP.has(key)) {
990
+ return obj;
991
+ }
992
+ return undefined;
993
+ }
994
+ if (value === null) {
995
+ return undefined;
996
+ }
997
+ if (typeof value === 'string' && value.trim() === '') {
998
+ if (key) {
999
+ const base = key.startsWith('@_') ? key.slice(2) : key;
1000
+ if (base.endsWith('_attribute') ||
1001
+ base.endsWith('_value') ||
1002
+ ['expr', 'cond', 'event', 'target', 'id', 'name', 'label', 'text'].includes(base) ||
1003
+ key === '@_xmlns') {
1004
+ return '';
1005
+ }
1006
+ }
1007
+ return undefined;
1008
+ }
1009
+ return value;
1010
+ }
1011
+ const ajv = new Ajv({ useDefaults: true, strict: false });
1012
+ const validate = ajv.compile(schema);
1013
+ /**
1014
+ * Convert an SCXML string to scjson.
1015
+ *
1016
+ * @param {string} xmlStr - XML input.
1017
+ * @param {boolean} [omitEmpty=true] - Remove empty values when true.
1018
+ * @returns {{result: string, valid: boolean, errors: object[]|null}} Conversion outcome.
1019
+ *
1020
+ * Removes the XML namespace attribute and injects default values
1021
+ * expected by the schema.
1022
+ */
1023
+ /**
1024
+ * Recursively strip default attributes from nested data nodes.
1025
+ *
1026
+ * Any object with a ``qname`` property other than ``scxml`` may have
1027
+ * ``version`` or ``datamodel_attribute`` inserted during validation.
1028
+ * This helper removes those keys so that nested structures match the
1029
+ * canonical Python output.
1030
+ *
1031
+ * @param {object|Array} value - Parsed object to adjust in place.
1032
+ */
1033
+ function stripNestedDataAttrs(value) {
1034
+ if (Array.isArray(value)) {
1035
+ value.forEach(stripNestedDataAttrs);
1036
+ return;
1037
+ }
1038
+ if (value && typeof value === 'object') {
1039
+ if (Object.prototype.hasOwnProperty.call(value, 'qname') &&
1040
+ value.qname !== 'scxml') {
1041
+ delete value.version;
1042
+ delete value.datamodel_attribute;
1043
+ }
1044
+ for (const v of Object.values(value)) {
1045
+ stripNestedDataAttrs(v);
1046
+ }
1047
+ }
1048
+ }
1049
+ function xmlToJson(xmlStr, omitEmpty = true) {
1050
+ const parser = new XMLParser({
1051
+ ignoreAttributes: false,
1052
+ trimValues: false,
1053
+ parseTagValue: false,
1054
+ });
1055
+ let obj = parser.parse(xmlStr);
1056
+ if (obj.scxml) {
1057
+ obj = obj.scxml;
1058
+ }
1059
+ obj = normaliseKeys(obj);
1060
+ obj = decodeEntities(obj);
1061
+ fixNestedScxml(obj);
1062
+ fixEmptyElse(obj);
1063
+ obj = collapseWhitespace(obj);
1064
+ splitTokenAttrs(obj);
1065
+ ensureArrays(obj);
1066
+ fixOtherAttributes(obj);
1067
+ fixScripts(obj);
1068
+ fixAssignDefaults(obj);
1069
+ fixSendDefaults(obj);
1070
+ fixSendContent(obj);
1071
+ fixDonedataContent(obj);
1072
+ fixDataContent(obj);
1073
+ fixEmptyOnentry(obj);
1074
+ fixSendContent(obj);
1075
+ flattenContent(obj);
1076
+ stripRootTransitions(obj);
1077
+ obj = collapseWhitespace(obj);
1078
+ if (omitEmpty) {
1079
+ obj = removeEmpty(obj) || {};
1080
+ }
1081
+ if (obj.initial_attribute !== undefined && obj.initial === undefined) {
1082
+ obj.initial = obj.initial_attribute;
1083
+ delete obj.initial_attribute;
1084
+ }
1085
+ for (const k of Object.keys(obj)) {
1086
+ if (k === '@_xmlns' || k.startsWith('xmlns')) {
1087
+ delete obj[k];
1088
+ }
1089
+ }
1090
+ if (obj.datamodel !== undefined) {
1091
+ if (typeof obj.datamodel === 'string') {
1092
+ obj.datamodel_attribute = obj.datamodel;
1093
+ delete obj.datamodel;
1094
+ }
1095
+ else if (Array.isArray(obj.datamodel) &&
1096
+ obj.datamodel.length === 1 &&
1097
+ typeof obj.datamodel[0] === 'string') {
1098
+ obj.datamodel_attribute = obj.datamodel[0];
1099
+ delete obj.datamodel;
1100
+ }
1101
+ }
1102
+ if (typeof obj.version === 'string') {
1103
+ const n = parseFloat(obj.version);
1104
+ if (!Number.isNaN(n))
1105
+ obj.version = n;
1106
+ }
1107
+ if (obj.version === undefined) {
1108
+ obj.version = 1.0;
1109
+ }
1110
+ if (obj.datamodel_attribute === undefined) {
1111
+ obj.datamodel_attribute = 'null';
1112
+ }
1113
+ stripQnameNs(obj);
1114
+ reorderScxml(obj);
1115
+ stripNestedDataAttrs(obj);
1116
+ stripXmlns(obj);
1117
+ const valid = validate(obj);
1118
+ const errors = valid ? null : validate.errors;
1119
+ if (omitEmpty) {
1120
+ obj = removeEmpty(obj) || {};
1121
+ fixDataContent(obj);
1122
+ stripQnameNs(obj);
1123
+ stripNestedDataAttrs(obj);
1124
+ stripXmlns(obj);
1125
+ }
1126
+ let out = JSON.stringify(obj, null, 2);
1127
+ out = out.replace(/"version": 1(?=[,\n])/g, '"version": 1.0');
1128
+ return { result: out, valid, errors };
1129
+ }
1130
+ /**
1131
+ * Convert a scjson string to SCXML.
1132
+ *
1133
+ * Removes empty objects so that the generated XML does not include spurious
1134
+ * `<content/>` elements. Nested SCXML documents are also normalised after
1135
+ * restoring attribute names to ensure no stray wrapper nodes remain. The
1136
+ * function validates the input against the SCJSON schema before conversion.
1137
+ *
1138
+ * @param {string} jsonStr - JSON input.
1139
+ * @returns {{result: string, valid: boolean, errors: object[]|null}} Conversion outcome.
1140
+ */
1141
+ function jsonToXml(jsonStr) {
1142
+ const builder = new XMLBuilder({
1143
+ ignoreAttributes: false,
1144
+ format: true,
1145
+ suppressEmptyNode: true,
1146
+ suppressBooleanAttributes: false,
1147
+ });
1148
+ let obj = JSON.parse(jsonStr);
1149
+ flattenContent(obj);
1150
+ obj = removeEmpty(obj) || {};
1151
+ const valid = validate(obj);
1152
+ const errors = valid ? null : validate.errors;
1153
+ // Remove defaults injected by validation that would misidentify
1154
+ // arbitrary XML content blocks as nested SCXML documents. Ajv
1155
+ // populates ``version`` and ``datamodel_attribute`` for objects
1156
+ // matching the ``Scxml`` schema. When the original JSON only
1157
+ // contains a ``qname`` field these defaults lead to erroneous
1158
+ // ``<scxml>`` wrappers being generated on output. Stripping the
1159
+ // fields prior to conversion preserves parity with the Python
1160
+ // implementation.
1161
+ stripNestedDataAttrs(obj);
1162
+ function restoreKeys(value) {
1163
+ if (Array.isArray(value)) {
1164
+ return value.map(restoreKeys);
1165
+ }
1166
+ if (value && typeof value === 'object') {
1167
+ if (Object.prototype.hasOwnProperty.call(value, 'qname')) {
1168
+ return restoreDataNode(value);
1169
+ }
1170
+ if (Object.keys(value).every(k => k === 'content' || k.endsWith('_value') || k === 'location' || k === 'expr' || k === 'src') &&
1171
+ Array.isArray(value.content) &&
1172
+ value.content.length === 1 &&
1173
+ value.content[0] &&
1174
+ typeof value.content[0] === 'object' &&
1175
+ (value.content[0].state ||
1176
+ value.content[0].parallel ||
1177
+ value.content[0].final ||
1178
+ value.content[0].datamodel ||
1179
+ value.content[0].datamodel_attribute !== undefined)) {
1180
+ const outObj = {};
1181
+ for (const [k, v] of Object.entries(value)) {
1182
+ if (k !== 'content') {
1183
+ outObj[k.startsWith('@_') ? k : `@_${k}`] = v;
1184
+ }
1185
+ }
1186
+ outObj.scxml = restoreKeys(value.content[0]);
1187
+ return outObj;
1188
+ }
1189
+ const out = {};
1190
+ for (const [k, v] of Object.entries(value)) {
1191
+ let nk = k;
1192
+ if (k === 'if_value') {
1193
+ nk = 'if';
1194
+ }
1195
+ else if (k === 'raise_value') {
1196
+ nk = 'raise';
1197
+ }
1198
+ else if (k === 'else_value') {
1199
+ nk = 'else';
1200
+ }
1201
+ if (nk === 'other_attributes') {
1202
+ if (v && typeof v === 'object') {
1203
+ for (const [ak, av] of Object.entries(v)) {
1204
+ out[`@_${ak}`] = av;
1205
+ }
1206
+ }
1207
+ continue;
1208
+ }
1209
+ for (const [attr, prop] of Object.entries(ATTRIBUTE_MAP)) {
1210
+ if (prop === nk) {
1211
+ nk = `@_${attr}`;
1212
+ break;
1213
+ }
1214
+ }
1215
+ if (nk === 'script') {
1216
+ if (Array.isArray(v)) {
1217
+ out[nk] = v.map(item => {
1218
+ if (item &&
1219
+ typeof item === 'object' &&
1220
+ Array.isArray(item.content) &&
1221
+ item.content.every(x => typeof x === 'string')) {
1222
+ return item.content.join('');
1223
+ }
1224
+ return restoreKeys(item);
1225
+ });
1226
+ }
1227
+ else {
1228
+ out[nk] = v;
1229
+ }
1230
+ }
1231
+ else if (nk === 'content') {
1232
+ if (Array.isArray(v)) {
1233
+ if (v.every(item => item && typeof item === 'object' && Object.prototype.hasOwnProperty.call(item, 'qname'))) {
1234
+ v.forEach(item => {
1235
+ const r = restoreDataNode(item);
1236
+ const [ck, cv] = Object.entries(r)[0];
1237
+ if (out[ck]) {
1238
+ if (Array.isArray(out[ck])) {
1239
+ out[ck].push(cv);
1240
+ }
1241
+ else {
1242
+ out[ck] = [out[ck], cv];
1243
+ }
1244
+ }
1245
+ else {
1246
+ out[ck] = cv;
1247
+ }
1248
+ });
1249
+ continue;
1250
+ }
1251
+ if (value.location !== undefined &&
1252
+ v.length === 1 &&
1253
+ v[0] &&
1254
+ typeof v[0] === 'object' &&
1255
+ (v[0].state || v[0].parallel || v[0].final || v[0].datamodel ||
1256
+ v[0].datamodel_attribute !== undefined)) {
1257
+ const cv = restoreKeys(v[0]);
1258
+ out.scxml = cv;
1259
+ continue;
1260
+ }
1261
+ out[nk] = v.map(item => {
1262
+ if (item &&
1263
+ typeof item === 'object' &&
1264
+ (item.state || item.parallel || item.final || item.datamodel ||
1265
+ item.datamodel_attribute !== undefined)) {
1266
+ return { scxml: restoreKeys(item) };
1267
+ }
1268
+ if (item &&
1269
+ typeof item === 'object' &&
1270
+ Object.keys(item).length === 1 &&
1271
+ Array.isArray(item.content) &&
1272
+ item.content.every(x => typeof x !== 'object')) {
1273
+ return item.content.join('');
1274
+ }
1275
+ return restoreKeys(item);
1276
+ });
1277
+ }
1278
+ else if (v &&
1279
+ typeof v === 'object' &&
1280
+ (v.state || v.parallel || v.final || v.datamodel ||
1281
+ v.datamodel_attribute !== undefined)) {
1282
+ out[nk] = { scxml: restoreKeys(v) };
1283
+ }
1284
+ else if (v &&
1285
+ typeof v === 'object' &&
1286
+ Object.keys(v).length === 1 &&
1287
+ Array.isArray(v.content) &&
1288
+ v.content.every(x => typeof x !== 'object')) {
1289
+ out[nk] = v.content.join('');
1290
+ }
1291
+ else {
1292
+ out[nk] = restoreKeys(v);
1293
+ }
1294
+ }
1295
+ else if (Array.isArray(v) && v.every(x => typeof x !== 'object')) {
1296
+ const val = v.join(' ');
1297
+ if (nk.startsWith('@_')) {
1298
+ out[nk] = val;
1299
+ }
1300
+ else {
1301
+ out[`@_${nk}`] = val;
1302
+ }
1303
+ }
1304
+ else if (v === null || typeof v !== 'object') {
1305
+ if (nk.startsWith('@_')) {
1306
+ out[nk] = v;
1307
+ }
1308
+ else {
1309
+ out[`@_${nk}`] = v;
1310
+ }
1311
+ }
1312
+ else {
1313
+ out[nk] = restoreKeys(v);
1314
+ }
1315
+ }
1316
+ if (Array.isArray(out.content) &&
1317
+ out.content.every(x => typeof x !== 'object')) {
1318
+ const others = Object.keys(out).filter(k => k !== 'content' && !k.startsWith('@_'));
1319
+ const attrs = Object.keys(out).filter(k => k.startsWith('@_'));
1320
+ const sendAttrs = [
1321
+ '@_event',
1322
+ '@_eventexpr',
1323
+ '@_target',
1324
+ '@_targetexpr',
1325
+ '@_type',
1326
+ '@_type_value',
1327
+ '@_delay',
1328
+ '@_delayexpr',
1329
+ '@_namelist',
1330
+ ];
1331
+ const isSend = attrs.some(a => sendAttrs.includes(a));
1332
+ if (others.length === 0 && !isSend) {
1333
+ out['#text'] = out.content.join('');
1334
+ delete out.content;
1335
+ }
1336
+ }
1337
+ return out;
1338
+ }
1339
+ return value;
1340
+ }
1341
+ const restored = restoreKeys(obj);
1342
+ const cleaned = removeEmpty(restored) || {};
1343
+ if (cleaned['@_xmlns'] === undefined) {
1344
+ cleaned['@_xmlns'] = 'http://www.w3.org/2005/07/scxml';
1345
+ }
1346
+ return { result: builder.build({ scxml: cleaned }), valid, errors };
1347
+ }
1348
+ module.exports = {
1349
+ xmlToJson,
1350
+ jsonToXml,
1351
+ removeEmpty,
1352
+ normaliseKeys,
1353
+ ensureArrays,
1354
+ fixScripts,
1355
+ fixNestedScxml,
1356
+ fixAssignDefaults,
1357
+ fixSendDefaults,
1358
+ fixSendContent,
1359
+ fixDonedataContent,
1360
+ fixOtherAttributes,
1361
+ decodeEntities,
1362
+ restoreDataNode,
1363
+ flattenContent,
1364
+ splitTokenAttrs,
1365
+ fixEmptyElse,
1366
+ fixEmptyOnentry,
1367
+ stripRootTransitions,
1368
+ stripQnameNs,
1369
+ reorderScxml,
1370
+ stripNestedDataAttrs,
1371
+ stripXmlns,
1372
+ };