scjson 0.3.0 → 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.
@@ -1,17 +1,7 @@
1
- /**
2
- * Convert an SCXML string to scjson.
3
- *
4
- * @param {string} xmlStr - XML input.
5
- * @param {boolean} [omitEmpty=true] - Remove empty values when true.
6
- * @returns {{result: string, valid: boolean, errors: object[]|null}} Conversion outcome.
7
- *
8
- * Removes the XML namespace attribute and injects default values
9
- * expected by the schema.
10
- */
11
- export function xmlToJson(xmlStr: string, omitEmpty?: boolean): {
1
+ export function xmlToJson(xmlStr: any, omitEmpty?: boolean): {
12
2
  result: string;
13
- valid: boolean;
14
- errors: object[] | null;
3
+ valid: any;
4
+ errors: any;
15
5
  };
16
6
  /**
17
7
  * Convert a scjson string to SCXML.
@@ -120,6 +110,18 @@ export function fixSendContent(value: object | any[]): void;
120
110
  * @param {object|Array} value - Parsed object to adjust in place.
121
111
  */
122
112
  export function fixDonedataContent(value: object | any[]): void;
113
+ export function fixOtherAttributes(value: any): void;
114
+ /**
115
+ * Decode HTML entities in string values.
116
+ *
117
+ * Fast XML parser leaves character references intact. This helper matches the
118
+ * Python implementation by converting entities like ``
`` to their literal
119
+ * characters.
120
+ *
121
+ * @param {object|Array|string} value - Parsed value to normalise.
122
+ * @returns {object|Array|string} Normalised value.
123
+ */
124
+ export function decodeEntities(value: object | any[] | string): object | any[] | string;
123
125
  /**
124
126
  * Convert a canonical content object back into XML element format.
125
127
  *
@@ -162,6 +164,16 @@ export function splitTokenAttrs(value: object | any[], parent: any): void;
162
164
  * @param {object|Array} value - Parsed object to adjust in place.
163
165
  */
164
166
  export function fixEmptyElse(value: object | any[]): void;
167
+ /**
168
+ * Normalise empty ``onentry`` and ``onexit`` elements.
169
+ *
170
+ * The XML parser represents empty tags as an empty string. The Python
171
+ * reference output preserves these elements as empty objects so they
172
+ * survive subsequent cleaning steps. This helper mirrors that behaviour.
173
+ *
174
+ * @param {object|Array} value - Parsed object to adjust in place.
175
+ */
176
+ export function fixEmptyOnentry(value: object | any[]): void;
165
177
  /**
166
178
  * Remove transition elements directly under the <scxml> root.
167
179
  *
@@ -189,3 +201,30 @@ export function stripQnameNs(value: object | any[]): void;
189
201
  * @param {object|Array} value - Parsed object to adjust in place.
190
202
  */
191
203
  export function reorderScxml(value: object | any[]): void;
204
+ /**
205
+ * Convert an SCXML string to scjson.
206
+ *
207
+ * @param {string} xmlStr - XML input.
208
+ * @param {boolean} [omitEmpty=true] - Remove empty values when true.
209
+ * @returns {{result: string, valid: boolean, errors: object[]|null}} Conversion outcome.
210
+ *
211
+ * Removes the XML namespace attribute and injects default values
212
+ * expected by the schema.
213
+ */
214
+ /**
215
+ * Recursively strip default attributes from nested data nodes.
216
+ *
217
+ * Any object with a ``qname`` property other than ``scxml`` may have
218
+ * ``version`` or ``datamodel_attribute`` inserted during validation.
219
+ * This helper removes those keys so that nested structures match the
220
+ * canonical Python output.
221
+ *
222
+ * @param {object|Array} value - Parsed object to adjust in place.
223
+ */
224
+ export function stripNestedDataAttrs(value: object | any[]): void;
225
+ /**
226
+ * Recursively remove ``xmlns`` attributes from nested objects.
227
+ *
228
+ * @param {object|Array} value - Parsed object to adjust in place.
229
+ */
230
+ export function stripXmlns(value: object | any[]): void;
@@ -59,7 +59,6 @@ const STRUCTURAL_FIELDS = new Set([
59
59
  * Recursively convert an XML Element to SCJSON-compliant JS object.
60
60
  */
61
61
  function convert(element) {
62
- var _a;
63
62
  const result = {
64
63
  tag: element.tagName,
65
64
  ...Object.fromEntries(Array.from(element.attributes).map(attr => [attr.name, attr.value]))
@@ -85,16 +84,16 @@ function convert(element) {
85
84
  }
86
85
  }
87
86
  // 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];
87
+ const rawText = element.textContent;
88
+ if (rawText && element.children.length === 0 && rawText.trim() !== '') {
89
+ result.content = [rawText];
91
90
  }
92
91
  return result;
93
92
  }
94
93
  /**
95
94
  * Keys that should never be pruned even when empty.
96
95
  */
97
- const ALWAYS_KEEP = new Set(['else_value', 'else', 'final']);
96
+ const ALWAYS_KEEP = new Set(['else_value', 'else', 'final', 'onentry']);
98
97
  /**
99
98
  * Remove transition elements directly under the <scxml> root.
100
99
  *
@@ -145,7 +144,12 @@ function collapseWhitespace(value) {
145
144
  if (value && typeof value === 'object') {
146
145
  for (const [k, v] of Object.entries(value)) {
147
146
  if ((k.endsWith('_attribute') || COLLAPSE_ATTRS.has(k)) && typeof v === 'string') {
148
- value[k] = v.replace(/[\n\r\t]/g, ' ');
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
+ }
149
153
  }
150
154
  else {
151
155
  value[k] = collapseWhitespace(v);
@@ -175,7 +179,7 @@ function splitTokenAttrs(value, parent) {
175
179
  continue;
176
180
  }
177
181
  if (k === 'transition') {
178
- if (parent !== 'history') {
182
+ if (parent !== 'history' && parent !== 'initial') {
179
183
  const arr = Array.isArray(v) ? v : [v];
180
184
  arr.forEach(tr => {
181
185
  if (typeof tr.target === 'string')
@@ -311,7 +315,7 @@ function ensureArrays(obj, parent) {
311
315
  continue;
312
316
  }
313
317
  if (k === 'transition' && v && typeof v === 'object') {
314
- if (parent !== 'history') {
318
+ if (parent !== 'history' && parent !== 'initial') {
315
319
  const arr = Array.isArray(v) ? v : [v];
316
320
  arr.forEach(tr => {
317
321
  if (tr.target !== undefined && !Array.isArray(tr.target))
@@ -355,6 +359,66 @@ function fixEmptyElse(value) {
355
359
  }
356
360
  }
357
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
+ }
358
422
  /**
359
423
  * Normalise script elements after parsing.
360
424
  *
@@ -470,6 +534,52 @@ function fixAssignDefaults(value) {
470
534
  }
471
535
  }
472
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
+ }
473
583
  /**
474
584
  * Apply default values for send elements.
475
585
  *
@@ -521,6 +631,14 @@ function fixSendContent(value) {
521
631
  return;
522
632
  }
523
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
+ }
524
642
  if (Object.prototype.hasOwnProperty.call(value, 'send')) {
525
643
  const arr = Array.isArray(value.send) ? value.send : [value.send];
526
644
  arr.forEach(s => {
@@ -528,8 +646,10 @@ function fixSendContent(value) {
528
646
  const cArr = Array.isArray(s.content) ? s.content : [s.content];
529
647
  const mapped = cArr.map(c => {
530
648
  if (typeof c !== 'object') {
531
- const sVal = String(c).trim();
532
- return sVal ? { content: [{ content: [sVal] }] } : null;
649
+ const raw = String(c);
650
+ if (raw.trim() === '')
651
+ return null;
652
+ return { content: [{ content: [raw] }] };
533
653
  }
534
654
  if (c && typeof c === 'object') {
535
655
  if (typeof c.content === 'string' || typeof c.content === 'number' || typeof c.content === 'boolean') {
@@ -537,8 +657,8 @@ function fixSendContent(value) {
537
657
  }
538
658
  if (Array.isArray(c.content)) {
539
659
  c.content = c.content
540
- .map(i => (typeof i === 'string' ? i.trim() : i))
541
- .filter(i => i !== '' && i !== null && i !== undefined);
660
+ .map(i => (typeof i === 'string' ? String(i) : i))
661
+ .filter(i => !(typeof i === 'string' && i.trim() === '') && i !== null && i !== undefined);
542
662
  if (c.content.length === 0)
543
663
  delete c.content;
544
664
  }
@@ -550,6 +670,10 @@ function fixSendContent(value) {
550
670
  else {
551
671
  fixSendContent(c);
552
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;
553
677
  return c;
554
678
  }
555
679
  return null;
@@ -587,8 +711,10 @@ function fixDonedataContent(value) {
587
711
  const cArr = Array.isArray(d.content) ? d.content : [d.content];
588
712
  const mapped = cArr.map(c => {
589
713
  if (typeof c !== 'object') {
590
- const s = String(c).trim();
591
- return s ? { content: [s] } : null;
714
+ const raw = String(c);
715
+ if (raw.trim() === '')
716
+ return null;
717
+ return { content: [raw] };
592
718
  }
593
719
  if (c && typeof c === 'object') {
594
720
  if (typeof c.content === 'string' ||
@@ -604,6 +730,10 @@ function fixDonedataContent(value) {
604
730
  else {
605
731
  fixDonedataContent(c);
606
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;
607
737
  return c;
608
738
  }
609
739
  return null;
@@ -776,6 +906,27 @@ function stripQnameNs(value) {
776
906
  }
777
907
  }
778
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
+ }
779
930
  /**
780
931
  * Collapse nested ``content`` wrappers created during parsing.
781
932
  *
@@ -800,7 +951,8 @@ function flattenContent(value) {
800
951
  Array.isArray(value.content[0].content) &&
801
952
  value.content[0].content.length === 1 &&
802
953
  value.content[0].content[0] &&
803
- typeof value.content[0].content[0] === 'object') {
954
+ typeof value.content[0].content[0] === 'object' &&
955
+ !Object.prototype.hasOwnProperty.call(value.content[0].content[0], 'qname')) {
804
956
  value.content = [value.content[0].content[0]];
805
957
  }
806
958
  for (const v of Object.values(value)) {
@@ -822,7 +974,10 @@ function flattenContent(value) {
822
974
  function removeEmpty(value, key) {
823
975
  if (Array.isArray(value)) {
824
976
  const arr = value.map(v => removeEmpty(v, key)).filter(v => v !== undefined);
825
- return arr.length > 0 ? arr : undefined;
977
+ if (arr.length > 0 || ALWAYS_KEEP.has(key)) {
978
+ return arr;
979
+ }
980
+ return undefined;
826
981
  }
827
982
  if (value && typeof value === 'object') {
828
983
  const obj = {};
@@ -844,7 +999,7 @@ function removeEmpty(value, key) {
844
999
  const base = key.startsWith('@_') ? key.slice(2) : key;
845
1000
  if (base.endsWith('_attribute') ||
846
1001
  base.endsWith('_value') ||
847
- ['expr', 'cond', 'event', 'target', 'id', 'name', 'label', 'text'].includes(key) ||
1002
+ ['expr', 'cond', 'event', 'target', 'id', 'name', 'label', 'text'].includes(base) ||
848
1003
  key === '@_xmlns') {
849
1004
  return '';
850
1005
  }
@@ -865,6 +1020,32 @@ const validate = ajv.compile(schema);
865
1020
  * Removes the XML namespace attribute and injects default values
866
1021
  * expected by the schema.
867
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
+ }
868
1049
  function xmlToJson(xmlStr, omitEmpty = true) {
869
1050
  const parser = new XMLParser({
870
1051
  ignoreAttributes: false,
@@ -876,17 +1057,21 @@ function xmlToJson(xmlStr, omitEmpty = true) {
876
1057
  obj = obj.scxml;
877
1058
  }
878
1059
  obj = normaliseKeys(obj);
1060
+ obj = decodeEntities(obj);
879
1061
  fixNestedScxml(obj);
880
1062
  fixEmptyElse(obj);
881
1063
  obj = collapseWhitespace(obj);
882
1064
  splitTokenAttrs(obj);
883
1065
  ensureArrays(obj);
1066
+ fixOtherAttributes(obj);
884
1067
  fixScripts(obj);
885
1068
  fixAssignDefaults(obj);
886
1069
  fixSendDefaults(obj);
887
1070
  fixSendContent(obj);
888
1071
  fixDonedataContent(obj);
889
1072
  fixDataContent(obj);
1073
+ fixEmptyOnentry(obj);
1074
+ fixSendContent(obj);
890
1075
  flattenContent(obj);
891
1076
  stripRootTransitions(obj);
892
1077
  obj = collapseWhitespace(obj);
@@ -927,12 +1112,16 @@ function xmlToJson(xmlStr, omitEmpty = true) {
927
1112
  }
928
1113
  stripQnameNs(obj);
929
1114
  reorderScxml(obj);
1115
+ stripNestedDataAttrs(obj);
1116
+ stripXmlns(obj);
930
1117
  const valid = validate(obj);
931
1118
  const errors = valid ? null : validate.errors;
932
1119
  if (omitEmpty) {
933
1120
  obj = removeEmpty(obj) || {};
934
1121
  fixDataContent(obj);
935
1122
  stripQnameNs(obj);
1123
+ stripNestedDataAttrs(obj);
1124
+ stripXmlns(obj);
936
1125
  }
937
1126
  let out = JSON.stringify(obj, null, 2);
938
1127
  out = out.replace(/"version": 1(?=[,\n])/g, '"version": 1.0');
@@ -961,6 +1150,15 @@ function jsonToXml(jsonStr) {
961
1150
  obj = removeEmpty(obj) || {};
962
1151
  const valid = validate(obj);
963
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);
964
1162
  function restoreKeys(value) {
965
1163
  if (Array.isArray(value)) {
966
1164
  return value.map(restoreKeys);
@@ -1000,6 +1198,14 @@ function jsonToXml(jsonStr) {
1000
1198
  else if (k === 'else_value') {
1001
1199
  nk = 'else';
1002
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
+ }
1003
1209
  for (const [attr, prop] of Object.entries(ATTRIBUTE_MAP)) {
1004
1210
  if (prop === nk) {
1005
1211
  nk = `@_${attr}`;
@@ -1024,6 +1230,34 @@ function jsonToXml(jsonStr) {
1024
1230
  }
1025
1231
  else if (nk === 'content') {
1026
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
+ }
1027
1261
  out[nk] = v.map(item => {
1028
1262
  if (item &&
1029
1263
  typeof item === 'object' &&
@@ -1123,11 +1357,16 @@ module.exports = {
1123
1357
  fixSendDefaults,
1124
1358
  fixSendContent,
1125
1359
  fixDonedataContent,
1360
+ fixOtherAttributes,
1361
+ decodeEntities,
1126
1362
  restoreDataNode,
1127
1363
  flattenContent,
1128
1364
  splitTokenAttrs,
1129
1365
  fixEmptyElse,
1366
+ fixEmptyOnentry,
1130
1367
  stripRootTransitions,
1131
1368
  stripQnameNs,
1132
1369
  reorderScxml,
1370
+ stripNestedDataAttrs,
1371
+ stripXmlns,
1133
1372
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scjson",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A JSON-based serialization of SCXML (State Chart XML).",
5
5
  "author": "Softoboros Technology Inc. <ira@softoboros.com>",
6
6
  "license": "BSD-1-Clause",