scjson 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +189 -3
- package/bin/scjson.js +1 -1
- package/dist/browser.cjs +12 -0
- package/dist/browser.d.cts +2 -0
- package/dist/browser.d.mts +6 -0
- package/dist/browser.mjs +12 -0
- package/dist/converters.d.ts +191 -0
- package/dist/converters.js +1133 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +236 -0
- package/{types → dist}/scjsonProps.d.ts +92 -226
- package/dist/scjsonProps.js +309 -0
- package/package.json +31 -56
- package/scjson.schema.json +160 -13
- package/browser.cjs +0 -243
- package/browser.mjs +0 -92
- package/index.js +0 -243
- package/scjsonProps.ts +0 -721
- package/tests/cli.test.js +0 -172
- package/types/scjson-browser.d.ts +0 -25
|
@@ -0,0 +1,1133 @@
|
|
|
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
|
+
var _a;
|
|
63
|
+
const result = {
|
|
64
|
+
tag: element.tagName,
|
|
65
|
+
...Object.fromEntries(Array.from(element.attributes).map(attr => [attr.name, attr.value]))
|
|
66
|
+
};
|
|
67
|
+
// Initialize known structural containers if needed
|
|
68
|
+
STRUCTURAL_FIELDS.forEach(field => {
|
|
69
|
+
if (element.querySelector(field)) {
|
|
70
|
+
result[field] = [];
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
for (const child of element.children) {
|
|
74
|
+
const converted = convert(child);
|
|
75
|
+
const tag = child.tagName;
|
|
76
|
+
if (STRUCTURAL_FIELDS.has(tag)) {
|
|
77
|
+
// Attach to known field
|
|
78
|
+
result[tag] = result[tag] || [];
|
|
79
|
+
result[tag].push(converted);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Fallback to generic 'content' array
|
|
83
|
+
result.content = result.content || [];
|
|
84
|
+
result.content.push(converted);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Handle text content if present
|
|
88
|
+
const text = (_a = element.textContent) === null || _a === void 0 ? void 0 : _a.trim();
|
|
89
|
+
if (text && element.children.length === 0) {
|
|
90
|
+
result.content = [text];
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Keys that should never be pruned even when empty.
|
|
96
|
+
*/
|
|
97
|
+
const ALWAYS_KEEP = new Set(['else_value', 'else', 'final']);
|
|
98
|
+
/**
|
|
99
|
+
* Remove transition elements directly under the <scxml> root.
|
|
100
|
+
*
|
|
101
|
+
* The reference Python implementation ignores these top level
|
|
102
|
+
* transitions entirely. To maintain parity we drop them during
|
|
103
|
+
* conversion.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} obj - Parsed SCXML object.
|
|
106
|
+
*/
|
|
107
|
+
function stripRootTransitions(obj) {
|
|
108
|
+
if (obj && typeof obj === 'object' && Array.isArray(obj.transition)) {
|
|
109
|
+
delete obj.transition;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Map of attribute names to their scjson property equivalents.
|
|
114
|
+
*/
|
|
115
|
+
const ATTRIBUTE_MAP = {
|
|
116
|
+
datamodel: 'datamodel_attribute',
|
|
117
|
+
initial: 'initial_attribute',
|
|
118
|
+
type: 'type_value',
|
|
119
|
+
raise: 'raise_value',
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Attributes whose whitespace should be collapsed.
|
|
123
|
+
*/
|
|
124
|
+
const COLLAPSE_ATTRS = new Set([
|
|
125
|
+
'expr',
|
|
126
|
+
'cond',
|
|
127
|
+
'event',
|
|
128
|
+
'target',
|
|
129
|
+
'delay',
|
|
130
|
+
'location',
|
|
131
|
+
'name',
|
|
132
|
+
'src',
|
|
133
|
+
'id',
|
|
134
|
+
]);
|
|
135
|
+
/**
|
|
136
|
+
* Collapse whitespace in attribute string values recursively.
|
|
137
|
+
*
|
|
138
|
+
* @param {object|Array|string} value - Value to normalise.
|
|
139
|
+
* @returns {object|Array|string} Normalised value.
|
|
140
|
+
*/
|
|
141
|
+
function collapseWhitespace(value) {
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
return value.map(collapseWhitespace);
|
|
144
|
+
}
|
|
145
|
+
if (value && typeof value === 'object') {
|
|
146
|
+
for (const [k, v] of Object.entries(value)) {
|
|
147
|
+
if ((k.endsWith('_attribute') || COLLAPSE_ATTRS.has(k)) && typeof v === 'string') {
|
|
148
|
+
value[k] = v.replace(/[\n\r\t]/g, ' ');
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
value[k] = collapseWhitespace(v);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Split whitespace-separated token attributes.
|
|
160
|
+
*
|
|
161
|
+
* Attributes such as ``initial`` and ``target`` can contain multiple
|
|
162
|
+
* identifiers separated by spaces. This function mirrors the Python
|
|
163
|
+
* implementation by splitting those values into arrays before further
|
|
164
|
+
* normalisation.
|
|
165
|
+
*
|
|
166
|
+
* @param {object|Array} value - Parsed value to adjust in place.
|
|
167
|
+
*/
|
|
168
|
+
function splitTokenAttrs(value, parent) {
|
|
169
|
+
if (Array.isArray(value))
|
|
170
|
+
return value.forEach(v => splitTokenAttrs(v, parent));
|
|
171
|
+
if (value && typeof value === 'object') {
|
|
172
|
+
for (const [k, v] of Object.entries(value)) {
|
|
173
|
+
if ((k === 'initial' || k === 'initial_attribute') && typeof v === 'string') {
|
|
174
|
+
value[k] = v.trim().split(/\s+/);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (k === 'transition') {
|
|
178
|
+
if (parent !== 'history') {
|
|
179
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
180
|
+
arr.forEach(tr => {
|
|
181
|
+
if (typeof tr.target === 'string')
|
|
182
|
+
tr.target = tr.target.trim().split(/\s+/);
|
|
183
|
+
splitTokenAttrs(tr, k);
|
|
184
|
+
});
|
|
185
|
+
value[k] = arr;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
if (typeof v.target === 'string')
|
|
189
|
+
v.target = v.target.trim().split(/\s+/);
|
|
190
|
+
splitTokenAttrs(v, k);
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
splitTokenAttrs(v, k);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Reorder SCXML object keys to match canonical output.
|
|
200
|
+
*
|
|
201
|
+
* ``datamodel`` elements and the attributes ``version`` and
|
|
202
|
+
* ``datamodel_attribute`` are appended to the end of their
|
|
203
|
+
* respective objects so that JSON generated by this converter
|
|
204
|
+
* matches the reference Python implementation.
|
|
205
|
+
*
|
|
206
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
207
|
+
*/
|
|
208
|
+
function reorderScxml(value) {
|
|
209
|
+
if (Array.isArray(value)) {
|
|
210
|
+
value.forEach(reorderScxml);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (value && typeof value === 'object') {
|
|
214
|
+
for (const v of Object.values(value)) {
|
|
215
|
+
reorderScxml(v);
|
|
216
|
+
}
|
|
217
|
+
if (Object.prototype.hasOwnProperty.call(value, 'datamodel')) {
|
|
218
|
+
const dm = value.datamodel;
|
|
219
|
+
delete value.datamodel;
|
|
220
|
+
value.datamodel = dm;
|
|
221
|
+
}
|
|
222
|
+
if (Object.prototype.hasOwnProperty.call(value, 'version')) {
|
|
223
|
+
const ver = value.version;
|
|
224
|
+
delete value.version;
|
|
225
|
+
value.version = ver;
|
|
226
|
+
}
|
|
227
|
+
if (Object.prototype.hasOwnProperty.call(value, 'datamodel_attribute')) {
|
|
228
|
+
const attr = value.datamodel_attribute;
|
|
229
|
+
delete value.datamodel_attribute;
|
|
230
|
+
value.datamodel_attribute = attr;
|
|
231
|
+
}
|
|
232
|
+
if (Object.prototype.hasOwnProperty.call(value, 'item') &&
|
|
233
|
+
Object.prototype.hasOwnProperty.call(value, 'index')) {
|
|
234
|
+
const item = value.item;
|
|
235
|
+
const index = value.index;
|
|
236
|
+
delete value.item;
|
|
237
|
+
delete value.index;
|
|
238
|
+
value.item = item;
|
|
239
|
+
value.index = index;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Recursively rename XML parser keys to match the scjson schema.
|
|
245
|
+
*
|
|
246
|
+
* - Attribute names prefixed with ``@_`` are stripped of the prefix.
|
|
247
|
+
* - Text content keys ``#text`` are converted to a ``content`` array.
|
|
248
|
+
*
|
|
249
|
+
* @param {object|Array} value - Parsed value to normalise.
|
|
250
|
+
* @returns {object|Array} Normalised value.
|
|
251
|
+
*/
|
|
252
|
+
function normaliseKeys(value) {
|
|
253
|
+
if (Array.isArray(value)) {
|
|
254
|
+
return value.map(normaliseKeys);
|
|
255
|
+
}
|
|
256
|
+
if (value && typeof value === 'object') {
|
|
257
|
+
const out = {};
|
|
258
|
+
for (const [k, v] of Object.entries(value)) {
|
|
259
|
+
if (k === '#text') {
|
|
260
|
+
const text = normaliseKeys(v);
|
|
261
|
+
if (text !== undefined) {
|
|
262
|
+
if (Array.isArray(out.content)) {
|
|
263
|
+
if (Array.isArray(text)) {
|
|
264
|
+
out.content.push(...text);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
out.content.push(text);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if (out.content !== undefined) {
|
|
271
|
+
out.content = Array.isArray(text)
|
|
272
|
+
? [out.content, ...text]
|
|
273
|
+
: [out.content, text];
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
out.content = Array.isArray(text) ? text : [text];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
let nk = k;
|
|
282
|
+
if (k.startsWith('@_')) {
|
|
283
|
+
const attr = k.slice(2);
|
|
284
|
+
nk = ATTRIBUTE_MAP[attr] || attr;
|
|
285
|
+
}
|
|
286
|
+
else if (k === 'if') {
|
|
287
|
+
nk = 'if_value';
|
|
288
|
+
}
|
|
289
|
+
else if (k === 'raise') {
|
|
290
|
+
nk = 'raise_value';
|
|
291
|
+
}
|
|
292
|
+
out[nk] = normaliseKeys(v);
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Recursively normalise values that should be arrays.
|
|
300
|
+
*
|
|
301
|
+
* @param {object} obj - Parsed object to adjust in place.
|
|
302
|
+
*/
|
|
303
|
+
function ensureArrays(obj, parent) {
|
|
304
|
+
if (!obj || typeof obj !== 'object')
|
|
305
|
+
return;
|
|
306
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
307
|
+
if (ARRAY_KEYS.has(k) && v !== undefined) {
|
|
308
|
+
Array.isArray(v)
|
|
309
|
+
? v.forEach(o => ensureArrays(o, k))
|
|
310
|
+
: (obj[k] = [v], ensureArrays(obj[k][0], k));
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (k === 'transition' && v && typeof v === 'object') {
|
|
314
|
+
if (parent !== 'history') {
|
|
315
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
316
|
+
arr.forEach(tr => {
|
|
317
|
+
if (tr.target !== undefined && !Array.isArray(tr.target))
|
|
318
|
+
tr.target = [tr.target];
|
|
319
|
+
ensureArrays(tr, k);
|
|
320
|
+
});
|
|
321
|
+
obj[k] = arr;
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
if (v.target !== undefined && !Array.isArray(v.target))
|
|
325
|
+
v.target = [v.target];
|
|
326
|
+
ensureArrays(v, k);
|
|
327
|
+
}
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
Array.isArray(v) ? v.forEach(o => ensureArrays(o, k)) : typeof v === 'object' && ensureArrays(v, k);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Convert ``else`` elements to the ``else_value`` schema key.
|
|
335
|
+
*
|
|
336
|
+
* Empty ``<else/>`` tags become an object literal so they survive
|
|
337
|
+
* subsequent calls to :func:`removeEmpty`.
|
|
338
|
+
*
|
|
339
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
340
|
+
*/
|
|
341
|
+
function fixEmptyElse(value) {
|
|
342
|
+
if (Array.isArray(value)) {
|
|
343
|
+
value.forEach(v => fixEmptyElse(v));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (value && typeof value === 'object') {
|
|
347
|
+
for (const [k, v] of Object.entries(value)) {
|
|
348
|
+
if (k === 'else') {
|
|
349
|
+
value.else_value = v === '' ? {} : v;
|
|
350
|
+
delete value.else;
|
|
351
|
+
fixEmptyElse(value.else_value);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
fixEmptyElse(v);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Normalise script elements after parsing.
|
|
360
|
+
*
|
|
361
|
+
* Ensures that each ``script`` entry is an object with a ``content`` array
|
|
362
|
+
* as required by the schema.
|
|
363
|
+
*
|
|
364
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
365
|
+
*/
|
|
366
|
+
function fixScripts(value) {
|
|
367
|
+
if (Array.isArray(value)) {
|
|
368
|
+
value.forEach(fixScripts);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (value && typeof value === 'object') {
|
|
372
|
+
for (const [k, v] of Object.entries(value)) {
|
|
373
|
+
if (k === 'script') {
|
|
374
|
+
if (Array.isArray(v)) {
|
|
375
|
+
value[k] = v.map(s => typeof s === 'string' ? { content: [s] } : (fixScripts(s), s));
|
|
376
|
+
}
|
|
377
|
+
else if (typeof v === 'string') {
|
|
378
|
+
value[k] = { content: [v] };
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
fixScripts(v);
|
|
382
|
+
}
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
fixScripts(v);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Normalise nested SCXML documents within invoke content.
|
|
391
|
+
*
|
|
392
|
+
* XML parser output represents nested ``<scxml>`` elements as a key
|
|
393
|
+
* named ``scxml`` inside the ``content`` element. The reference
|
|
394
|
+
* Python implementation instead stores the nested machine under a
|
|
395
|
+
* ``content`` array. This helper replicates that behaviour.
|
|
396
|
+
*
|
|
397
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
398
|
+
*/
|
|
399
|
+
function fixNestedScxml(value) {
|
|
400
|
+
if (Array.isArray(value)) {
|
|
401
|
+
value.forEach(fixNestedScxml);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (value && typeof value === 'object') {
|
|
405
|
+
if (Object.prototype.hasOwnProperty.call(value, 'scxml')) {
|
|
406
|
+
const sub = value.scxml;
|
|
407
|
+
delete value.scxml;
|
|
408
|
+
const arr = Array.isArray(sub) ? sub : [sub];
|
|
409
|
+
arr.forEach(v => {
|
|
410
|
+
if (Object.prototype.hasOwnProperty.call(v, 'final') && v.final === '') {
|
|
411
|
+
v.final = [{}];
|
|
412
|
+
}
|
|
413
|
+
if (v.initial_attribute !== undefined && v.initial === undefined) {
|
|
414
|
+
v.initial = v.initial_attribute;
|
|
415
|
+
delete v.initial_attribute;
|
|
416
|
+
}
|
|
417
|
+
if (typeof v.version === 'string') {
|
|
418
|
+
const n = parseFloat(v.version);
|
|
419
|
+
if (!Number.isNaN(n))
|
|
420
|
+
v.version = n;
|
|
421
|
+
}
|
|
422
|
+
if (v.version === undefined) {
|
|
423
|
+
v.version = 1.0;
|
|
424
|
+
}
|
|
425
|
+
for (const k of Object.keys(v)) {
|
|
426
|
+
if (k === '@_xmlns' || k.startsWith('xmlns')) {
|
|
427
|
+
delete v[k];
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (v.datamodel_attribute === undefined) {
|
|
431
|
+
v.datamodel_attribute = 'null';
|
|
432
|
+
}
|
|
433
|
+
fixNestedScxml(v);
|
|
434
|
+
});
|
|
435
|
+
value.content = [{ content: arr }];
|
|
436
|
+
}
|
|
437
|
+
for (const v of Object.values(value)) {
|
|
438
|
+
fixNestedScxml(v);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Apply default values for assign elements.
|
|
444
|
+
*
|
|
445
|
+
* The scjson schema expects ``assign`` elements to include a
|
|
446
|
+
* ``type_value`` attribute with a default of ``replacechildren``.
|
|
447
|
+
* This helper ensures the attribute is present when not specified in
|
|
448
|
+
* the original XML.
|
|
449
|
+
*
|
|
450
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
451
|
+
*/
|
|
452
|
+
function fixAssignDefaults(value) {
|
|
453
|
+
if (Array.isArray(value)) {
|
|
454
|
+
value.forEach(fixAssignDefaults);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (value && typeof value === 'object') {
|
|
458
|
+
if (Object.prototype.hasOwnProperty.call(value, 'assign')) {
|
|
459
|
+
const arr = Array.isArray(value.assign) ? value.assign : [value.assign];
|
|
460
|
+
arr.forEach(a => {
|
|
461
|
+
if (a.type_value === undefined) {
|
|
462
|
+
a.type_value = 'replacechildren';
|
|
463
|
+
}
|
|
464
|
+
fixAssignDefaults(a);
|
|
465
|
+
});
|
|
466
|
+
value.assign = arr;
|
|
467
|
+
}
|
|
468
|
+
for (const v of Object.values(value)) {
|
|
469
|
+
fixAssignDefaults(v);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Apply default values for send elements.
|
|
475
|
+
*
|
|
476
|
+
* The SCXML specification defines ``type="scxml"`` and ``delay="0s"``
|
|
477
|
+
* as defaults. This mirrors the behaviour of the Python converter so
|
|
478
|
+
* round-trip conversions remain consistent.
|
|
479
|
+
*
|
|
480
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
481
|
+
*/
|
|
482
|
+
function fixSendDefaults(value) {
|
|
483
|
+
if (Array.isArray(value)) {
|
|
484
|
+
value.forEach(fixSendDefaults);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (value && typeof value === 'object') {
|
|
488
|
+
if (Object.prototype.hasOwnProperty.call(value, 'send')) {
|
|
489
|
+
const arr = Array.isArray(value.send) ? value.send : [value.send];
|
|
490
|
+
arr.forEach(s => {
|
|
491
|
+
if (s.type_value === undefined) {
|
|
492
|
+
s.type_value = 'scxml';
|
|
493
|
+
}
|
|
494
|
+
if (s.delay === undefined) {
|
|
495
|
+
s.delay = '0s';
|
|
496
|
+
}
|
|
497
|
+
fixSendContent(s);
|
|
498
|
+
fixSendDefaults(s);
|
|
499
|
+
});
|
|
500
|
+
value.send = arr;
|
|
501
|
+
}
|
|
502
|
+
for (const v of Object.values(value)) {
|
|
503
|
+
fixSendDefaults(v);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Normalise inline content elements under ``send``.
|
|
509
|
+
*
|
|
510
|
+
* ``<content>`` children inside ``<send>`` should always be objects with a
|
|
511
|
+
* ``content`` array according to the scjson schema. The fast-xml-parser library
|
|
512
|
+
* collapses simple text nodes to strings which leads to mismatches when
|
|
513
|
+
* compared with the Python implementation. This helper wraps such strings in an
|
|
514
|
+
* object structure.
|
|
515
|
+
*
|
|
516
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
517
|
+
*/
|
|
518
|
+
function fixSendContent(value) {
|
|
519
|
+
if (Array.isArray(value)) {
|
|
520
|
+
value.forEach(fixSendContent);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (value && typeof value === 'object') {
|
|
524
|
+
if (Object.prototype.hasOwnProperty.call(value, 'send')) {
|
|
525
|
+
const arr = Array.isArray(value.send) ? value.send : [value.send];
|
|
526
|
+
arr.forEach(s => {
|
|
527
|
+
if (Object.prototype.hasOwnProperty.call(s, 'content')) {
|
|
528
|
+
const cArr = Array.isArray(s.content) ? s.content : [s.content];
|
|
529
|
+
const mapped = cArr.map(c => {
|
|
530
|
+
if (typeof c !== 'object') {
|
|
531
|
+
const sVal = String(c).trim();
|
|
532
|
+
return sVal ? { content: [{ content: [sVal] }] } : null;
|
|
533
|
+
}
|
|
534
|
+
if (c && typeof c === 'object') {
|
|
535
|
+
if (typeof c.content === 'string' || typeof c.content === 'number' || typeof c.content === 'boolean') {
|
|
536
|
+
c.content = [String(c.content)];
|
|
537
|
+
}
|
|
538
|
+
if (Array.isArray(c.content)) {
|
|
539
|
+
c.content = c.content
|
|
540
|
+
.map(i => (typeof i === 'string' ? i.trim() : i))
|
|
541
|
+
.filter(i => i !== '' && i !== null && i !== undefined);
|
|
542
|
+
if (c.content.length === 0)
|
|
543
|
+
delete c.content;
|
|
544
|
+
}
|
|
545
|
+
// Convert raw XML objects into canonical content structures
|
|
546
|
+
if (!c.qname && !c.expr && !c.content && Object.keys(c).length === 1) {
|
|
547
|
+
const [k, v] = Object.entries(c)[0];
|
|
548
|
+
c = { content: [convertDataNode(k, v)] };
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
fixSendContent(c);
|
|
552
|
+
}
|
|
553
|
+
return c;
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
}).filter(x => x !== null);
|
|
557
|
+
s.content = mapped;
|
|
558
|
+
}
|
|
559
|
+
fixSendContent(s);
|
|
560
|
+
});
|
|
561
|
+
value.send = arr;
|
|
562
|
+
}
|
|
563
|
+
for (const v of Object.values(value)) {
|
|
564
|
+
fixSendContent(v);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Normalise inline content elements under ``donedata``.
|
|
570
|
+
*
|
|
571
|
+
* ``<content>`` children inside ``<donedata>`` should always be objects with a
|
|
572
|
+
* ``content`` array so that round-trips match the reference Python
|
|
573
|
+
* implementation. Strings are wrapped in an object accordingly.
|
|
574
|
+
*
|
|
575
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
576
|
+
*/
|
|
577
|
+
function fixDonedataContent(value) {
|
|
578
|
+
if (Array.isArray(value)) {
|
|
579
|
+
value.forEach(fixDonedataContent);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (value && typeof value === 'object') {
|
|
583
|
+
if (Object.prototype.hasOwnProperty.call(value, 'donedata')) {
|
|
584
|
+
const arr = Array.isArray(value.donedata) ? value.donedata : [value.donedata];
|
|
585
|
+
arr.forEach(d => {
|
|
586
|
+
if (Object.prototype.hasOwnProperty.call(d, 'content')) {
|
|
587
|
+
const cArr = Array.isArray(d.content) ? d.content : [d.content];
|
|
588
|
+
const mapped = cArr.map(c => {
|
|
589
|
+
if (typeof c !== 'object') {
|
|
590
|
+
const s = String(c).trim();
|
|
591
|
+
return s ? { content: [s] } : null;
|
|
592
|
+
}
|
|
593
|
+
if (c && typeof c === 'object') {
|
|
594
|
+
if (typeof c.content === 'string' ||
|
|
595
|
+
typeof c.content === 'number' ||
|
|
596
|
+
typeof c.content === 'boolean') {
|
|
597
|
+
c.content = [String(c.content)];
|
|
598
|
+
}
|
|
599
|
+
// Convert raw XML objects into canonical content structures
|
|
600
|
+
if (!c.qname && !c.expr && !c.content && Object.keys(c).length === 1) {
|
|
601
|
+
const [k, v] = Object.entries(c)[0];
|
|
602
|
+
c = { content: [convertDataNode(k, v)] };
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
fixDonedataContent(c);
|
|
606
|
+
}
|
|
607
|
+
return c;
|
|
608
|
+
}
|
|
609
|
+
return null;
|
|
610
|
+
});
|
|
611
|
+
const clean = mapped.filter(x => x !== null);
|
|
612
|
+
d.content = clean.length === 1 ? clean[0] : clean;
|
|
613
|
+
}
|
|
614
|
+
fixDonedataContent(d);
|
|
615
|
+
});
|
|
616
|
+
value.donedata = arr;
|
|
617
|
+
}
|
|
618
|
+
for (const v of Object.values(value)) {
|
|
619
|
+
fixDonedataContent(v);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Convert arbitrary objects parsed under ``<data>`` elements into
|
|
625
|
+
* canonical content structures.
|
|
626
|
+
*
|
|
627
|
+
* The fast-xml-parser library represents child elements of ``<data>`` as
|
|
628
|
+
* direct properties on the data object. This helper converts those
|
|
629
|
+
* properties to the schema's ``content`` array with ``qname``,
|
|
630
|
+
* ``attributes`` and ``children`` fields so that round-trips match the
|
|
631
|
+
* Python implementation.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} name - Element name.
|
|
634
|
+
* @param {*} node - Parsed element value.
|
|
635
|
+
* @returns {object} Canonical content object.
|
|
636
|
+
*/
|
|
637
|
+
function convertDataNode(name, node) {
|
|
638
|
+
if (Array.isArray(node)) {
|
|
639
|
+
return node.map(n => convertDataNode(name, n));
|
|
640
|
+
}
|
|
641
|
+
if (node && typeof node === 'object') {
|
|
642
|
+
const attrs = {};
|
|
643
|
+
const children = [];
|
|
644
|
+
let text = '';
|
|
645
|
+
for (const [k, v] of Object.entries(node)) {
|
|
646
|
+
if (k === 'content') {
|
|
647
|
+
if (Array.isArray(v)) {
|
|
648
|
+
if (v.every(x => typeof x !== 'object')) {
|
|
649
|
+
text += v.join('');
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
v.forEach(sub => {
|
|
653
|
+
if (typeof sub === 'object' && Object.keys(sub).length === 1) {
|
|
654
|
+
const [ck, cv] = Object.entries(sub)[0];
|
|
655
|
+
const c = convertDataNode(ck, cv);
|
|
656
|
+
Array.isArray(c) ? children.push(...c) : children.push(c);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
else if (typeof v === 'string') {
|
|
662
|
+
text += v;
|
|
663
|
+
}
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (v && typeof v === 'object') {
|
|
667
|
+
const c = convertDataNode(k, v);
|
|
668
|
+
Array.isArray(c) ? children.push(...c) : children.push(c);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
attrs[k] = String(v);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const out = { qname: name, text };
|
|
675
|
+
if (children.length)
|
|
676
|
+
out.children = children;
|
|
677
|
+
if (Object.keys(attrs).length)
|
|
678
|
+
out.attributes = attrs;
|
|
679
|
+
if (out.text === undefined)
|
|
680
|
+
out.text = '';
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
return { qname: name, text: String(node) };
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Recursively normalise ``<data>`` elements that contain inline XML.
|
|
687
|
+
*
|
|
688
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
689
|
+
*/
|
|
690
|
+
function fixDataContent(value) {
|
|
691
|
+
if (Array.isArray(value)) {
|
|
692
|
+
value.forEach(fixDataContent);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (value && typeof value === 'object') {
|
|
696
|
+
if (Object.prototype.hasOwnProperty.call(value, 'data')) {
|
|
697
|
+
const arr = Array.isArray(value.data) ? value.data : [value.data];
|
|
698
|
+
arr.forEach(d => {
|
|
699
|
+
const content = [];
|
|
700
|
+
for (const [k, v] of Object.entries(d)) {
|
|
701
|
+
if (!['id', 'src', 'expr', 'otherAttributes', 'content'].includes(k)) {
|
|
702
|
+
const c = convertDataNode(k, v);
|
|
703
|
+
Array.isArray(c) ? content.push(...c) : content.push(c);
|
|
704
|
+
delete d[k];
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (content.length)
|
|
708
|
+
d.content = content;
|
|
709
|
+
});
|
|
710
|
+
value.data = arr;
|
|
711
|
+
}
|
|
712
|
+
for (const v of Object.values(value)) {
|
|
713
|
+
fixDataContent(v);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Convert a canonical content object back into XML element format.
|
|
719
|
+
*
|
|
720
|
+
* ``jsonToXml`` relies on this helper to rebuild inline XML stored under
|
|
721
|
+
* ``<data>`` elements. Objects with ``qname``, ``attributes``, and ``children``
|
|
722
|
+
* fields are translated to the structure expected by ``fast-xml-parser``.
|
|
723
|
+
*
|
|
724
|
+
* @param {object} node - Canonical content object.
|
|
725
|
+
* @returns {object} XML builder structure keyed by element name.
|
|
726
|
+
*/
|
|
727
|
+
function restoreDataNode(node) {
|
|
728
|
+
const out = {};
|
|
729
|
+
if (node.attributes) {
|
|
730
|
+
for (const [k, v] of Object.entries(node.attributes)) {
|
|
731
|
+
out[`@_${k}`] = v;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (node.text !== undefined && node.text !== '') {
|
|
735
|
+
out['#text'] = node.text;
|
|
736
|
+
}
|
|
737
|
+
if (Array.isArray(node.children)) {
|
|
738
|
+
node.children.forEach(c => {
|
|
739
|
+
const r = restoreDataNode(c);
|
|
740
|
+
const [ck, cv] = Object.entries(r)[0];
|
|
741
|
+
if (out[ck]) {
|
|
742
|
+
if (Array.isArray(out[ck])) {
|
|
743
|
+
out[ck].push(cv);
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
out[ck] = [out[ck], cv];
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
out[ck] = cv;
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
if (!node.qname.includes(':') && !node.qname.startsWith('{') && node.qname !== 'scxml') {
|
|
755
|
+
out['@_xmlns'] = '';
|
|
756
|
+
}
|
|
757
|
+
return { [node.qname]: out };
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Remove namespace URIs from ``qname`` fields.
|
|
761
|
+
*
|
|
762
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
763
|
+
*/
|
|
764
|
+
function stripQnameNs(value) {
|
|
765
|
+
if (Array.isArray(value)) {
|
|
766
|
+
value.forEach(stripQnameNs);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (value && typeof value === 'object') {
|
|
770
|
+
for (const [k, v] of Object.entries(value)) {
|
|
771
|
+
if (k === 'qname' && typeof v === 'string') {
|
|
772
|
+
value[k] = v.replace(/^\{[^}]+\}/, '');
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
stripQnameNs(v);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Collapse nested ``content`` wrappers created during parsing.
|
|
781
|
+
*
|
|
782
|
+
* A ``content`` array may contain a single object with its own
|
|
783
|
+
* ``content`` array when the original XML element only held text.
|
|
784
|
+
* This helper flattens that structure so that round-tripping through
|
|
785
|
+
* XML does not introduce spurious elements.
|
|
786
|
+
*
|
|
787
|
+
* @param {object|Array} value - Parsed object to adjust in place.
|
|
788
|
+
*/
|
|
789
|
+
function flattenContent(value) {
|
|
790
|
+
if (Array.isArray(value)) {
|
|
791
|
+
value.forEach(flattenContent);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (value && typeof value === 'object') {
|
|
795
|
+
if (Array.isArray(value.content) &&
|
|
796
|
+
value.content.length === 1 &&
|
|
797
|
+
value.content[0] &&
|
|
798
|
+
typeof value.content[0] === 'object' &&
|
|
799
|
+
Object.keys(value.content[0]).length === 1 &&
|
|
800
|
+
Array.isArray(value.content[0].content) &&
|
|
801
|
+
value.content[0].content.length === 1 &&
|
|
802
|
+
value.content[0].content[0] &&
|
|
803
|
+
typeof value.content[0].content[0] === 'object') {
|
|
804
|
+
value.content = [value.content[0].content[0]];
|
|
805
|
+
}
|
|
806
|
+
for (const v of Object.values(value)) {
|
|
807
|
+
flattenContent(v);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Remove nulls and empty containers from values recursively.
|
|
813
|
+
*
|
|
814
|
+
* Certain keys like ``final`` must always be preserved even when they
|
|
815
|
+
* would otherwise be considered empty. The caller provides the key so we
|
|
816
|
+
* can decide whether to keep an empty object.
|
|
817
|
+
*
|
|
818
|
+
* @param {*} value - Candidate value.
|
|
819
|
+
* @param {string} [key] - Key name associated with ``value`` in the parent.
|
|
820
|
+
* @returns {*} Sanitised value.
|
|
821
|
+
*/
|
|
822
|
+
function removeEmpty(value, key) {
|
|
823
|
+
if (Array.isArray(value)) {
|
|
824
|
+
const arr = value.map(v => removeEmpty(v, key)).filter(v => v !== undefined);
|
|
825
|
+
return arr.length > 0 ? arr : undefined;
|
|
826
|
+
}
|
|
827
|
+
if (value && typeof value === 'object') {
|
|
828
|
+
const obj = {};
|
|
829
|
+
for (const [k, v] of Object.entries(value)) {
|
|
830
|
+
const r = removeEmpty(v, k);
|
|
831
|
+
if (r !== undefined)
|
|
832
|
+
obj[k] = r;
|
|
833
|
+
}
|
|
834
|
+
if (Object.keys(obj).length > 0 || ALWAYS_KEEP.has(key)) {
|
|
835
|
+
return obj;
|
|
836
|
+
}
|
|
837
|
+
return undefined;
|
|
838
|
+
}
|
|
839
|
+
if (value === null) {
|
|
840
|
+
return undefined;
|
|
841
|
+
}
|
|
842
|
+
if (typeof value === 'string' && value.trim() === '') {
|
|
843
|
+
if (key) {
|
|
844
|
+
const base = key.startsWith('@_') ? key.slice(2) : key;
|
|
845
|
+
if (base.endsWith('_attribute') ||
|
|
846
|
+
base.endsWith('_value') ||
|
|
847
|
+
['expr', 'cond', 'event', 'target', 'id', 'name', 'label', 'text'].includes(key) ||
|
|
848
|
+
key === '@_xmlns') {
|
|
849
|
+
return '';
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return undefined;
|
|
853
|
+
}
|
|
854
|
+
return value;
|
|
855
|
+
}
|
|
856
|
+
const ajv = new Ajv({ useDefaults: true, strict: false });
|
|
857
|
+
const validate = ajv.compile(schema);
|
|
858
|
+
/**
|
|
859
|
+
* Convert an SCXML string to scjson.
|
|
860
|
+
*
|
|
861
|
+
* @param {string} xmlStr - XML input.
|
|
862
|
+
* @param {boolean} [omitEmpty=true] - Remove empty values when true.
|
|
863
|
+
* @returns {{result: string, valid: boolean, errors: object[]|null}} Conversion outcome.
|
|
864
|
+
*
|
|
865
|
+
* Removes the XML namespace attribute and injects default values
|
|
866
|
+
* expected by the schema.
|
|
867
|
+
*/
|
|
868
|
+
function xmlToJson(xmlStr, omitEmpty = true) {
|
|
869
|
+
const parser = new XMLParser({
|
|
870
|
+
ignoreAttributes: false,
|
|
871
|
+
trimValues: false,
|
|
872
|
+
parseTagValue: false,
|
|
873
|
+
});
|
|
874
|
+
let obj = parser.parse(xmlStr);
|
|
875
|
+
if (obj.scxml) {
|
|
876
|
+
obj = obj.scxml;
|
|
877
|
+
}
|
|
878
|
+
obj = normaliseKeys(obj);
|
|
879
|
+
fixNestedScxml(obj);
|
|
880
|
+
fixEmptyElse(obj);
|
|
881
|
+
obj = collapseWhitespace(obj);
|
|
882
|
+
splitTokenAttrs(obj);
|
|
883
|
+
ensureArrays(obj);
|
|
884
|
+
fixScripts(obj);
|
|
885
|
+
fixAssignDefaults(obj);
|
|
886
|
+
fixSendDefaults(obj);
|
|
887
|
+
fixSendContent(obj);
|
|
888
|
+
fixDonedataContent(obj);
|
|
889
|
+
fixDataContent(obj);
|
|
890
|
+
flattenContent(obj);
|
|
891
|
+
stripRootTransitions(obj);
|
|
892
|
+
obj = collapseWhitespace(obj);
|
|
893
|
+
if (omitEmpty) {
|
|
894
|
+
obj = removeEmpty(obj) || {};
|
|
895
|
+
}
|
|
896
|
+
if (obj.initial_attribute !== undefined && obj.initial === undefined) {
|
|
897
|
+
obj.initial = obj.initial_attribute;
|
|
898
|
+
delete obj.initial_attribute;
|
|
899
|
+
}
|
|
900
|
+
for (const k of Object.keys(obj)) {
|
|
901
|
+
if (k === '@_xmlns' || k.startsWith('xmlns')) {
|
|
902
|
+
delete obj[k];
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (obj.datamodel !== undefined) {
|
|
906
|
+
if (typeof obj.datamodel === 'string') {
|
|
907
|
+
obj.datamodel_attribute = obj.datamodel;
|
|
908
|
+
delete obj.datamodel;
|
|
909
|
+
}
|
|
910
|
+
else if (Array.isArray(obj.datamodel) &&
|
|
911
|
+
obj.datamodel.length === 1 &&
|
|
912
|
+
typeof obj.datamodel[0] === 'string') {
|
|
913
|
+
obj.datamodel_attribute = obj.datamodel[0];
|
|
914
|
+
delete obj.datamodel;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (typeof obj.version === 'string') {
|
|
918
|
+
const n = parseFloat(obj.version);
|
|
919
|
+
if (!Number.isNaN(n))
|
|
920
|
+
obj.version = n;
|
|
921
|
+
}
|
|
922
|
+
if (obj.version === undefined) {
|
|
923
|
+
obj.version = 1.0;
|
|
924
|
+
}
|
|
925
|
+
if (obj.datamodel_attribute === undefined) {
|
|
926
|
+
obj.datamodel_attribute = 'null';
|
|
927
|
+
}
|
|
928
|
+
stripQnameNs(obj);
|
|
929
|
+
reorderScxml(obj);
|
|
930
|
+
const valid = validate(obj);
|
|
931
|
+
const errors = valid ? null : validate.errors;
|
|
932
|
+
if (omitEmpty) {
|
|
933
|
+
obj = removeEmpty(obj) || {};
|
|
934
|
+
fixDataContent(obj);
|
|
935
|
+
stripQnameNs(obj);
|
|
936
|
+
}
|
|
937
|
+
let out = JSON.stringify(obj, null, 2);
|
|
938
|
+
out = out.replace(/"version": 1(?=[,\n])/g, '"version": 1.0');
|
|
939
|
+
return { result: out, valid, errors };
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Convert a scjson string to SCXML.
|
|
943
|
+
*
|
|
944
|
+
* Removes empty objects so that the generated XML does not include spurious
|
|
945
|
+
* `<content/>` elements. Nested SCXML documents are also normalised after
|
|
946
|
+
* restoring attribute names to ensure no stray wrapper nodes remain. The
|
|
947
|
+
* function validates the input against the SCJSON schema before conversion.
|
|
948
|
+
*
|
|
949
|
+
* @param {string} jsonStr - JSON input.
|
|
950
|
+
* @returns {{result: string, valid: boolean, errors: object[]|null}} Conversion outcome.
|
|
951
|
+
*/
|
|
952
|
+
function jsonToXml(jsonStr) {
|
|
953
|
+
const builder = new XMLBuilder({
|
|
954
|
+
ignoreAttributes: false,
|
|
955
|
+
format: true,
|
|
956
|
+
suppressEmptyNode: true,
|
|
957
|
+
suppressBooleanAttributes: false,
|
|
958
|
+
});
|
|
959
|
+
let obj = JSON.parse(jsonStr);
|
|
960
|
+
flattenContent(obj);
|
|
961
|
+
obj = removeEmpty(obj) || {};
|
|
962
|
+
const valid = validate(obj);
|
|
963
|
+
const errors = valid ? null : validate.errors;
|
|
964
|
+
function restoreKeys(value) {
|
|
965
|
+
if (Array.isArray(value)) {
|
|
966
|
+
return value.map(restoreKeys);
|
|
967
|
+
}
|
|
968
|
+
if (value && typeof value === 'object') {
|
|
969
|
+
if (Object.prototype.hasOwnProperty.call(value, 'qname')) {
|
|
970
|
+
return restoreDataNode(value);
|
|
971
|
+
}
|
|
972
|
+
if (Object.keys(value).every(k => k === 'content' || k.endsWith('_value') || k === 'location' || k === 'expr' || k === 'src') &&
|
|
973
|
+
Array.isArray(value.content) &&
|
|
974
|
+
value.content.length === 1 &&
|
|
975
|
+
value.content[0] &&
|
|
976
|
+
typeof value.content[0] === 'object' &&
|
|
977
|
+
(value.content[0].state ||
|
|
978
|
+
value.content[0].parallel ||
|
|
979
|
+
value.content[0].final ||
|
|
980
|
+
value.content[0].datamodel ||
|
|
981
|
+
value.content[0].datamodel_attribute !== undefined)) {
|
|
982
|
+
const outObj = {};
|
|
983
|
+
for (const [k, v] of Object.entries(value)) {
|
|
984
|
+
if (k !== 'content') {
|
|
985
|
+
outObj[k.startsWith('@_') ? k : `@_${k}`] = v;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
outObj.scxml = restoreKeys(value.content[0]);
|
|
989
|
+
return outObj;
|
|
990
|
+
}
|
|
991
|
+
const out = {};
|
|
992
|
+
for (const [k, v] of Object.entries(value)) {
|
|
993
|
+
let nk = k;
|
|
994
|
+
if (k === 'if_value') {
|
|
995
|
+
nk = 'if';
|
|
996
|
+
}
|
|
997
|
+
else if (k === 'raise_value') {
|
|
998
|
+
nk = 'raise';
|
|
999
|
+
}
|
|
1000
|
+
else if (k === 'else_value') {
|
|
1001
|
+
nk = 'else';
|
|
1002
|
+
}
|
|
1003
|
+
for (const [attr, prop] of Object.entries(ATTRIBUTE_MAP)) {
|
|
1004
|
+
if (prop === nk) {
|
|
1005
|
+
nk = `@_${attr}`;
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (nk === 'script') {
|
|
1010
|
+
if (Array.isArray(v)) {
|
|
1011
|
+
out[nk] = v.map(item => {
|
|
1012
|
+
if (item &&
|
|
1013
|
+
typeof item === 'object' &&
|
|
1014
|
+
Array.isArray(item.content) &&
|
|
1015
|
+
item.content.every(x => typeof x === 'string')) {
|
|
1016
|
+
return item.content.join('');
|
|
1017
|
+
}
|
|
1018
|
+
return restoreKeys(item);
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
else {
|
|
1022
|
+
out[nk] = v;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
else if (nk === 'content') {
|
|
1026
|
+
if (Array.isArray(v)) {
|
|
1027
|
+
out[nk] = v.map(item => {
|
|
1028
|
+
if (item &&
|
|
1029
|
+
typeof item === 'object' &&
|
|
1030
|
+
(item.state || item.parallel || item.final || item.datamodel ||
|
|
1031
|
+
item.datamodel_attribute !== undefined)) {
|
|
1032
|
+
return { scxml: restoreKeys(item) };
|
|
1033
|
+
}
|
|
1034
|
+
if (item &&
|
|
1035
|
+
typeof item === 'object' &&
|
|
1036
|
+
Object.keys(item).length === 1 &&
|
|
1037
|
+
Array.isArray(item.content) &&
|
|
1038
|
+
item.content.every(x => typeof x !== 'object')) {
|
|
1039
|
+
return item.content.join('');
|
|
1040
|
+
}
|
|
1041
|
+
return restoreKeys(item);
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
else if (v &&
|
|
1045
|
+
typeof v === 'object' &&
|
|
1046
|
+
(v.state || v.parallel || v.final || v.datamodel ||
|
|
1047
|
+
v.datamodel_attribute !== undefined)) {
|
|
1048
|
+
out[nk] = { scxml: restoreKeys(v) };
|
|
1049
|
+
}
|
|
1050
|
+
else if (v &&
|
|
1051
|
+
typeof v === 'object' &&
|
|
1052
|
+
Object.keys(v).length === 1 &&
|
|
1053
|
+
Array.isArray(v.content) &&
|
|
1054
|
+
v.content.every(x => typeof x !== 'object')) {
|
|
1055
|
+
out[nk] = v.content.join('');
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
out[nk] = restoreKeys(v);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
else if (Array.isArray(v) && v.every(x => typeof x !== 'object')) {
|
|
1062
|
+
const val = v.join(' ');
|
|
1063
|
+
if (nk.startsWith('@_')) {
|
|
1064
|
+
out[nk] = val;
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
out[`@_${nk}`] = val;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
else if (v === null || typeof v !== 'object') {
|
|
1071
|
+
if (nk.startsWith('@_')) {
|
|
1072
|
+
out[nk] = v;
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
out[`@_${nk}`] = v;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
out[nk] = restoreKeys(v);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (Array.isArray(out.content) &&
|
|
1083
|
+
out.content.every(x => typeof x !== 'object')) {
|
|
1084
|
+
const others = Object.keys(out).filter(k => k !== 'content' && !k.startsWith('@_'));
|
|
1085
|
+
const attrs = Object.keys(out).filter(k => k.startsWith('@_'));
|
|
1086
|
+
const sendAttrs = [
|
|
1087
|
+
'@_event',
|
|
1088
|
+
'@_eventexpr',
|
|
1089
|
+
'@_target',
|
|
1090
|
+
'@_targetexpr',
|
|
1091
|
+
'@_type',
|
|
1092
|
+
'@_type_value',
|
|
1093
|
+
'@_delay',
|
|
1094
|
+
'@_delayexpr',
|
|
1095
|
+
'@_namelist',
|
|
1096
|
+
];
|
|
1097
|
+
const isSend = attrs.some(a => sendAttrs.includes(a));
|
|
1098
|
+
if (others.length === 0 && !isSend) {
|
|
1099
|
+
out['#text'] = out.content.join('');
|
|
1100
|
+
delete out.content;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return out;
|
|
1104
|
+
}
|
|
1105
|
+
return value;
|
|
1106
|
+
}
|
|
1107
|
+
const restored = restoreKeys(obj);
|
|
1108
|
+
const cleaned = removeEmpty(restored) || {};
|
|
1109
|
+
if (cleaned['@_xmlns'] === undefined) {
|
|
1110
|
+
cleaned['@_xmlns'] = 'http://www.w3.org/2005/07/scxml';
|
|
1111
|
+
}
|
|
1112
|
+
return { result: builder.build({ scxml: cleaned }), valid, errors };
|
|
1113
|
+
}
|
|
1114
|
+
module.exports = {
|
|
1115
|
+
xmlToJson,
|
|
1116
|
+
jsonToXml,
|
|
1117
|
+
removeEmpty,
|
|
1118
|
+
normaliseKeys,
|
|
1119
|
+
ensureArrays,
|
|
1120
|
+
fixScripts,
|
|
1121
|
+
fixNestedScxml,
|
|
1122
|
+
fixAssignDefaults,
|
|
1123
|
+
fixSendDefaults,
|
|
1124
|
+
fixSendContent,
|
|
1125
|
+
fixDonedataContent,
|
|
1126
|
+
restoreDataNode,
|
|
1127
|
+
flattenContent,
|
|
1128
|
+
splitTokenAttrs,
|
|
1129
|
+
fixEmptyElse,
|
|
1130
|
+
stripRootTransitions,
|
|
1131
|
+
stripQnameNs,
|
|
1132
|
+
reorderScxml,
|
|
1133
|
+
};
|