scjson 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,46 @@
1
- export function xmlToJson(xmlStr: any, omitEmpty?: boolean): {
1
+ export type XmlToJsonOptions = {
2
+ /**
3
+ * Drop null and empty containers.
4
+ */
5
+ omitEmpty?: boolean;
6
+ /**
7
+ * Preserve
8
+ * ``xi:include`` as extension content or resolve includes before conversion.
9
+ */
10
+ xinclude?: "preserve" | "resolve";
11
+ /**
12
+ * Loader used by
13
+ * resolved XInclude mode.
14
+ */
15
+ xincludeLoader?: (href: string) => string;
16
+ /**
17
+ * File or directory used by the default
18
+ * Node loader in resolved XInclude mode.
19
+ */
20
+ xincludeBasePath?: string;
21
+ };
22
+ /**
23
+ * @typedef {object} XmlToJsonOptions
24
+ * @property {boolean} [omitEmpty=true] Drop null and empty containers.
25
+ * @property {'preserve'|'resolve'} [xinclude='preserve'] Preserve
26
+ * ``xi:include`` as extension content or resolve includes before conversion.
27
+ * @property {(href: string) => string} [xincludeLoader] Loader used by
28
+ * resolved XInclude mode.
29
+ * @property {string} [xincludeBasePath] File or directory used by the default
30
+ * Node loader in resolved XInclude mode.
31
+ */
32
+ /**
33
+ * Convert SCXML to canonical SCJSON.
34
+ *
35
+ * @param {string} xmlStr - SCXML source.
36
+ * @param {boolean|XmlToJsonOptions} [options=true] Backward-compatible boolean
37
+ * ``omitEmpty`` flag or expanded converter options.
38
+ * @returns {{result: string, valid: boolean, errors: object[]|null}} Conversion outcome.
39
+ */
40
+ export function xmlToJson(xmlStr: string, options?: boolean | XmlToJsonOptions): {
2
41
  result: string;
3
- valid: any;
4
- errors: any;
42
+ valid: boolean;
43
+ errors: object[] | null;
5
44
  };
6
45
  /**
7
46
  * Convert a scjson string to SCXML.
@@ -110,6 +149,14 @@ export function fixSendContent(value: object | any[]): void;
110
149
  * @param {object|Array} value - Parsed object to adjust in place.
111
150
  */
112
151
  export function fixDonedataContent(value: object | any[]): void;
152
+ export function normaliseXmlToJsonOptions(options: any): {
153
+ omitEmpty: boolean;
154
+ xinclude: any;
155
+ xincludeLoader: any;
156
+ xincludeBasePath: any;
157
+ };
158
+ export function resolveXIncludesInParsed(value: any, parser: any, options: any): any;
159
+ export function preserveExtensionElements(value: any): void;
113
160
  export function fixOtherAttributes(value: any): void;
114
161
  /**
115
162
  * Decode HTML entities in string values.
@@ -228,3 +275,27 @@ export function stripNestedDataAttrs(value: object | any[]): void;
228
275
  * @param {object|Array} value - Parsed object to adjust in place.
229
276
  */
230
277
  export function stripXmlns(value: object | any[]): void;
278
+ /**
279
+ * Keys that should always be represented as arrays.
280
+ *
281
+ * These are derived from `scjson.schema.json` where the corresponding
282
+ * properties have a ``type`` of ``array``. The parser used here will
283
+ * collapse single elements into objects, so we normalise the structure
284
+ * back to arrays in order to maintain canonical output that matches the
285
+ * reference Python implementation.
286
+ */
287
+ export const ARRAY_KEYS: Set<string>;
288
+ /**
289
+ * CONV-E: Set of known structural metadata keys that live alongside the
290
+ * SCXML child surface but are not themselves SCXML child elements. These
291
+ * keys MUST round-trip through JSON-side normalization without being folded
292
+ * into ``other_attributes`` or generic ``content``.
293
+ *
294
+ * ``help_text`` is the only member today; future structural metadata keys
295
+ * (e.g. promoted authoring annotations from CONV-F) should be added here.
296
+ */
297
+ export const STRUCTURAL_METADATA_KEYS: Set<string>;
298
+ import { extractHelpTextFromXml } from "./comment_promotion.js";
299
+ import { attachHelpTextToModel } from "./comment_promotion.js";
300
+ import { injectHelpTextCommentsIntoXml } from "./comment_promotion.js";
301
+ export { extractHelpTextFromXml, attachHelpTextToModel, injectHelpTextCommentsIntoXml };
@@ -12,6 +12,9 @@
12
12
  const { XMLParser, XMLBuilder } = require('fast-xml-parser');
13
13
  const Ajv = require('ajv');
14
14
  const schema = require('../scjson.schema.json');
15
+ const { extractHelpTextFromXml, attachHelpTextToModel, injectHelpTextCommentsIntoXml, } = require('./comment_promotion.js');
16
+ const XINCLUDE_NS = 'http://www.w3.org/2001/XInclude';
17
+ const XINCLUDE_CLARK_INCLUDE = `{${XINCLUDE_NS}}include`;
15
18
  /**
16
19
  * Keys that should always be represented as arrays.
17
20
  *
@@ -31,6 +34,7 @@ const ARRAY_KEYS = new Set([
31
34
  'final',
32
35
  'finalize',
33
36
  'foreach',
37
+ 'help_text',
34
38
  'history',
35
39
  'if_value',
36
40
  'initial',
@@ -46,6 +50,18 @@ const ARRAY_KEYS = new Set([
46
50
  'send',
47
51
  'state',
48
52
  ]);
53
+ /**
54
+ * CONV-E: Set of known structural metadata keys that live alongside the
55
+ * SCXML child surface but are not themselves SCXML child elements. These
56
+ * keys MUST round-trip through JSON-side normalization without being folded
57
+ * into ``other_attributes`` or generic ``content``.
58
+ *
59
+ * ``help_text`` is the only member today; future structural metadata keys
60
+ * (e.g. promoted authoring annotations from CONV-F) should be added here.
61
+ */
62
+ const STRUCTURAL_METADATA_KEYS = new Set([
63
+ 'help_text',
64
+ ]);
49
65
  /// Known SCXML structural fields that should be pulled out of `content[]`
50
66
  const STRUCTURAL_FIELDS = new Set([
51
67
  'state', 'parallel', 'final', 'history',
@@ -750,6 +766,83 @@ function fixDonedataContent(value) {
750
766
  }
751
767
  }
752
768
  }
769
+ function normaliseXmlToJsonOptions(options) {
770
+ if (typeof options === 'boolean' || options === undefined) {
771
+ return {
772
+ omitEmpty: options !== false,
773
+ xinclude: 'preserve',
774
+ xincludeLoader: null,
775
+ xincludeBasePath: null,
776
+ };
777
+ }
778
+ return {
779
+ omitEmpty: options.omitEmpty !== false,
780
+ xinclude: options.xinclude || 'preserve',
781
+ xincludeLoader: options.xincludeLoader || null,
782
+ xincludeBasePath: options.xincludeBasePath || null,
783
+ };
784
+ }
785
+ function mergeParsedChild(target, key, value) {
786
+ if (target[key] === undefined) {
787
+ target[key] = value;
788
+ return;
789
+ }
790
+ if (Array.isArray(target[key])) {
791
+ Array.isArray(value) ? target[key].push(...value) : target[key].push(value);
792
+ return;
793
+ }
794
+ target[key] = Array.isArray(value)
795
+ ? [target[key], ...value]
796
+ : [target[key], value];
797
+ }
798
+ function loadXInclude(href, options) {
799
+ if (typeof options.xincludeLoader === 'function') {
800
+ return options.xincludeLoader(href);
801
+ }
802
+ if (options.xincludeBasePath) {
803
+ const fs = require('fs');
804
+ const path = require('path');
805
+ const base = path.extname(options.xincludeBasePath)
806
+ ? path.dirname(options.xincludeBasePath)
807
+ : options.xincludeBasePath;
808
+ return fs.readFileSync(path.resolve(base, href), 'utf8');
809
+ }
810
+ throw new Error(`No XInclude loader configured for ${href}`);
811
+ }
812
+ function isXIncludeElementName(name) {
813
+ return name === 'xi:include' || name === XINCLUDE_CLARK_INCLUDE || name.endsWith(':include');
814
+ }
815
+ function parsedDocumentEntries(doc) {
816
+ return Object.entries(doc).filter(([key]) => key !== '?xml');
817
+ }
818
+ function resolveXIncludesInParsed(value, parser, options) {
819
+ if (Array.isArray(value)) {
820
+ return value.map(item => resolveXIncludesInParsed(item, parser, options));
821
+ }
822
+ if (!value || typeof value !== 'object') {
823
+ return value;
824
+ }
825
+ const out = {};
826
+ for (const [key, child] of Object.entries(value)) {
827
+ if (isXIncludeElementName(key)) {
828
+ const includes = Array.isArray(child) ? child : [child];
829
+ includes.forEach(includeNode => {
830
+ const href = includeNode && (includeNode['@_href'] || includeNode.href);
831
+ if (!href) {
832
+ throw new Error('XInclude element is missing href');
833
+ }
834
+ const includedXml = loadXInclude(href, options);
835
+ const includedDoc = resolveXIncludesInParsed(parser.parse(includedXml), parser, options);
836
+ parsedDocumentEntries(includedDoc).forEach(([incKey, incValue]) => {
837
+ mergeParsedChild(out, incKey, incValue);
838
+ });
839
+ });
840
+ continue;
841
+ }
842
+ out[key] = resolveXIncludesInParsed(child, parser, options);
843
+ }
844
+ return out;
845
+ }
753
846
  /**
754
847
  * Convert arbitrary objects parsed under ``<data>`` elements into
755
848
  * canonical content structures.
@@ -812,6 +905,53 @@ function convertDataNode(name, node) {
812
905
  }
813
906
  return { qname: name, text: String(node) };
814
907
  }
908
+ function appendOtherElement(target, node) {
909
+ const nodes = Array.isArray(node) ? node : [node];
910
+ if (!Array.isArray(target.other_element)) {
911
+ target.other_element = [];
912
+ }
913
+ target.other_element.push(...nodes);
914
+ }
915
+ function canonicalExtensionName(name) {
916
+ if (name === 'xi:include') {
917
+ return XINCLUDE_CLARK_INCLUDE;
918
+ }
919
+ return name;
920
+ }
921
+ function ensureExtensionNamespace(name, node) {
922
+ const nodes = Array.isArray(node) ? node : [node];
923
+ nodes.forEach(item => {
924
+ if (!item || typeof item !== 'object')
925
+ return;
926
+ if (name === 'xi:include' || name === XINCLUDE_CLARK_INCLUDE) {
927
+ item.attributes = item.attributes || {};
928
+ if (item.attributes['xmlns:xi'] === undefined) {
929
+ item.attributes['xmlns:xi'] = XINCLUDE_NS;
930
+ }
931
+ }
932
+ });
933
+ return node;
934
+ }
935
+ function preserveExtensionElements(value) {
936
+ if (Array.isArray(value)) {
937
+ value.forEach(preserveExtensionElements);
938
+ return;
939
+ }
940
+ if (!value || typeof value !== 'object') {
941
+ return;
942
+ }
943
+ for (const [key, child] of Object.entries(value)) {
944
+ if (key === 'other_element') {
945
+ continue;
946
+ }
947
+ if (key.includes(':') && !key.startsWith('xmlns')) {
948
+ appendOtherElement(value, ensureExtensionNamespace(key, convertDataNode(canonicalExtensionName(key), child)));
949
+ delete value[key];
950
+ continue;
951
+ }
952
+ preserveExtensionElements(child);
953
+ }
954
+ }
815
955
  /**
816
956
  * Recursively normalise ``<data>`` elements that contain inline XML.
817
957
  *
@@ -855,12 +995,19 @@ function fixDataContent(value) {
855
995
  * @returns {object} XML builder structure keyed by element name.
856
996
  */
857
997
  function restoreDataNode(node) {
998
+ let qname = node.qname;
858
999
  const out = {};
859
1000
  if (node.attributes) {
860
1001
  for (const [k, v] of Object.entries(node.attributes)) {
861
1002
  out[`@_${k}`] = v;
862
1003
  }
863
1004
  }
1005
+ if (qname === XINCLUDE_CLARK_INCLUDE) {
1006
+ qname = 'xi:include';
1007
+ if (out['@_xmlns:xi'] === undefined) {
1008
+ out['@_xmlns:xi'] = XINCLUDE_NS;
1009
+ }
1010
+ }
864
1011
  if (node.text !== undefined && node.text !== '') {
865
1012
  out['#text'] = node.text;
866
1013
  }
@@ -881,10 +1028,10 @@ function restoreDataNode(node) {
881
1028
  }
882
1029
  });
883
1030
  }
884
- if (!node.qname.includes(':') && !node.qname.startsWith('{') && node.qname !== 'scxml') {
1031
+ if (!qname.includes(':') && !qname.startsWith('{') && qname !== 'scxml') {
885
1032
  out['@_xmlns'] = '';
886
1033
  }
887
- return { [node.qname]: out };
1034
+ return { [qname]: out };
888
1035
  }
889
1036
  /**
890
1037
  * Remove namespace URIs from ``qname`` fields.
@@ -899,7 +1046,9 @@ function stripQnameNs(value) {
899
1046
  if (value && typeof value === 'object') {
900
1047
  for (const [k, v] of Object.entries(value)) {
901
1048
  if (k === 'qname' && typeof v === 'string') {
902
- value[k] = v.replace(/^\{[^}]+\}/, '');
1049
+ if (v !== XINCLUDE_CLARK_INCLUDE) {
1050
+ value[k] = v.replace(/^\{[^}]+\}/, '');
1051
+ }
903
1052
  continue;
904
1053
  }
905
1054
  stripQnameNs(v);
@@ -1046,18 +1195,60 @@ function stripNestedDataAttrs(value) {
1046
1195
  }
1047
1196
  }
1048
1197
  }
1049
- function xmlToJson(xmlStr, omitEmpty = true) {
1198
+ /**
1199
+ * @typedef {object} XmlToJsonOptions
1200
+ * @property {boolean} [omitEmpty=true] Drop null and empty containers.
1201
+ * @property {'preserve'|'resolve'} [xinclude='preserve'] Preserve
1202
+ * ``xi:include`` as extension content or resolve includes before conversion.
1203
+ * @property {(href: string) => string} [xincludeLoader] Loader used by
1204
+ * resolved XInclude mode.
1205
+ * @property {string} [xincludeBasePath] File or directory used by the default
1206
+ * Node loader in resolved XInclude mode.
1207
+ */
1208
+ /**
1209
+ * Convert SCXML to canonical SCJSON.
1210
+ *
1211
+ * @param {string} xmlStr - SCXML source.
1212
+ * @param {boolean|XmlToJsonOptions} [options=true] Backward-compatible boolean
1213
+ * ``omitEmpty`` flag or expanded converter options.
1214
+ * @returns {{result: string, valid: boolean, errors: object[]|null}} Conversion outcome.
1215
+ */
1216
+ function xmlToJson(xmlStr, options = true) {
1217
+ const xmlOptions = normaliseXmlToJsonOptions(options);
1218
+ if (!['preserve', 'resolve'].includes(xmlOptions.xinclude)) {
1219
+ throw new Error("xinclude must be 'preserve' or 'resolve'");
1220
+ }
1221
+ // CONV-F pre-pass: harvest XML comments into deterministic address ->
1222
+ // help_text lists and continue the rest of the pipeline on a
1223
+ // comment-free XML string. The pre-pass uses local element names so
1224
+ // addresses survive the namespace-attribute insertion below.
1225
+ let promotionAddressMap = null;
1226
+ try {
1227
+ const promo = extractHelpTextFromXml(xmlStr);
1228
+ if (promo && promo.addressMap && promo.addressMap.size > 0) {
1229
+ promotionAddressMap = promo.addressMap;
1230
+ xmlStr = promo.cleanedXml;
1231
+ }
1232
+ }
1233
+ catch (_promotionErr) {
1234
+ // Pre-pass failures fall through to the existing parser, which will
1235
+ // surface a descriptive error if the XML itself is malformed.
1236
+ }
1050
1237
  const parser = new XMLParser({
1051
1238
  ignoreAttributes: false,
1052
1239
  trimValues: false,
1053
1240
  parseTagValue: false,
1054
1241
  });
1055
1242
  let obj = parser.parse(xmlStr);
1243
+ if (xmlOptions.xinclude === 'resolve') {
1244
+ obj = resolveXIncludesInParsed(obj, parser, xmlOptions);
1245
+ }
1056
1246
  if (obj.scxml) {
1057
1247
  obj = obj.scxml;
1058
1248
  }
1059
1249
  obj = normaliseKeys(obj);
1060
1250
  obj = decodeEntities(obj);
1251
+ preserveExtensionElements(obj);
1061
1252
  fixNestedScxml(obj);
1062
1253
  fixEmptyElse(obj);
1063
1254
  obj = collapseWhitespace(obj);
@@ -1075,7 +1266,7 @@ function xmlToJson(xmlStr, omitEmpty = true) {
1075
1266
  flattenContent(obj);
1076
1267
  stripRootTransitions(obj);
1077
1268
  obj = collapseWhitespace(obj);
1078
- if (omitEmpty) {
1269
+ if (xmlOptions.omitEmpty) {
1079
1270
  obj = removeEmpty(obj) || {};
1080
1271
  }
1081
1272
  if (obj.initial_attribute !== undefined && obj.initial === undefined) {
@@ -1116,13 +1307,24 @@ function xmlToJson(xmlStr, omitEmpty = true) {
1116
1307
  stripXmlns(obj);
1117
1308
  const valid = validate(obj);
1118
1309
  const errors = valid ? null : validate.errors;
1119
- if (omitEmpty) {
1310
+ if (xmlOptions.omitEmpty) {
1120
1311
  obj = removeEmpty(obj) || {};
1121
1312
  fixDataContent(obj);
1122
1313
  stripQnameNs(obj);
1123
1314
  stripNestedDataAttrs(obj);
1124
1315
  stripXmlns(obj);
1125
1316
  }
1317
+ // CONV-F: attach promoted help_text after the model surface is stable
1318
+ // and after removeEmpty() so empty arrays from earlier passes do not
1319
+ // clobber what we add here.
1320
+ if (promotionAddressMap !== null) {
1321
+ try {
1322
+ attachHelpTextToModel(obj, promotionAddressMap);
1323
+ }
1324
+ catch (_attachErr) {
1325
+ // Promotion attach is best-effort; converter output remains valid.
1326
+ }
1327
+ }
1126
1328
  let out = JSON.stringify(obj, null, 2);
1127
1329
  out = out.replace(/"version": 1(?=[,\n])/g, '"version": 1.0');
1128
1330
  return { result: out, valid, errors };
@@ -1167,7 +1369,7 @@ function jsonToXml(jsonStr) {
1167
1369
  if (Object.prototype.hasOwnProperty.call(value, 'qname')) {
1168
1370
  return restoreDataNode(value);
1169
1371
  }
1170
- if (Object.keys(value).every(k => k === 'content' || k.endsWith('_value') || k === 'location' || k === 'expr' || k === 'src') &&
1372
+ if (Object.keys(value).every(k => k === 'content' || k === 'help_text' || k.endsWith('_value') || k === 'location' || k === 'expr' || k === 'src') &&
1171
1373
  Array.isArray(value.content) &&
1172
1374
  value.content.length === 1 &&
1173
1375
  value.content[0] &&
@@ -1179,7 +1381,7 @@ function jsonToXml(jsonStr) {
1179
1381
  value.content[0].datamodel_attribute !== undefined)) {
1180
1382
  const outObj = {};
1181
1383
  for (const [k, v] of Object.entries(value)) {
1182
- if (k !== 'content') {
1384
+ if (k !== 'content' && k !== 'help_text') {
1183
1385
  outObj[k.startsWith('@_') ? k : `@_${k}`] = v;
1184
1386
  }
1185
1387
  }
@@ -1206,6 +1408,31 @@ function jsonToXml(jsonStr) {
1206
1408
  }
1207
1409
  continue;
1208
1410
  }
1411
+ if (nk === 'help_text') {
1412
+ // CONV-E: help_text is first-class authoring metadata, not an SCXML
1413
+ // attribute or element. CONV-F injects it as leading XML comments
1414
+ // after XMLBuilder emits the comment-free structural tree.
1415
+ continue;
1416
+ }
1417
+ if (nk === 'other_element') {
1418
+ const elements = Array.isArray(v) ? v : [v];
1419
+ elements.forEach(item => {
1420
+ const r = restoreDataNode(item);
1421
+ const [ck, cv] = Object.entries(r)[0];
1422
+ if (out[ck]) {
1423
+ if (Array.isArray(out[ck])) {
1424
+ out[ck].push(cv);
1425
+ }
1426
+ else {
1427
+ out[ck] = [out[ck], cv];
1428
+ }
1429
+ }
1430
+ else {
1431
+ out[ck] = cv;
1432
+ }
1433
+ });
1434
+ continue;
1435
+ }
1209
1436
  for (const [attr, prop] of Object.entries(ATTRIBUTE_MAP)) {
1210
1437
  if (prop === nk) {
1211
1438
  nk = `@_${attr}`;
@@ -1343,7 +1570,18 @@ function jsonToXml(jsonStr) {
1343
1570
  if (cleaned['@_xmlns'] === undefined) {
1344
1571
  cleaned['@_xmlns'] = 'http://www.w3.org/2005/07/scxml';
1345
1572
  }
1346
- return { result: builder.build({ scxml: cleaned }), valid, errors };
1573
+ let xmlOut = builder.build({ scxml: cleaned });
1574
+ // CONV-F post-pass: inject leading XML comments for every model element
1575
+ // with a non-empty help_text array. ``obj`` (the input model) is the
1576
+ // source of truth for help_text; the XMLBuilder above already stripped
1577
+ // it via the ``nk === 'help_text'`` guard.
1578
+ try {
1579
+ xmlOut = injectHelpTextCommentsIntoXml(xmlOut, obj);
1580
+ }
1581
+ catch (_postPassErr) {
1582
+ // Best-effort: emit existing XML if injection fails.
1583
+ }
1584
+ return { result: xmlOut, valid, errors };
1347
1585
  }
1348
1586
  module.exports = {
1349
1587
  xmlToJson,
@@ -1357,6 +1595,9 @@ module.exports = {
1357
1595
  fixSendDefaults,
1358
1596
  fixSendContent,
1359
1597
  fixDonedataContent,
1598
+ normaliseXmlToJsonOptions,
1599
+ resolveXIncludesInParsed,
1600
+ preserveExtensionElements,
1360
1601
  fixOtherAttributes,
1361
1602
  decodeEntities,
1362
1603
  restoreDataNode,
@@ -1369,4 +1610,9 @@ module.exports = {
1369
1610
  reorderScxml,
1370
1611
  stripNestedDataAttrs,
1371
1612
  stripXmlns,
1613
+ ARRAY_KEYS,
1614
+ STRUCTURAL_METADATA_KEYS,
1615
+ extractHelpTextFromXml,
1616
+ attachHelpTextToModel,
1617
+ injectHelpTextCommentsIntoXml,
1372
1618
  };
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "scjson",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "A JSON-based serialization of SCXML (State Chart XML) with SCXML/SCML execution tooling and converters.",
5
5
  "author": "Softoboros Technology Inc. <ira@softoboros.com>",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "https://github.com/SoftOboros/scjson"
8
+ "url": "git+https://github.com/SoftOboros/scjson.git"
9
9
  },
10
10
  "license": "BSD-1-Clause",
11
11
  "main": "dist/index.js",
@@ -67,7 +67,7 @@
67
67
  "fast-xml-parser": "^5.2.5"
68
68
  },
69
69
  "peerDependencies": {
70
- "scion-core": "^2.6.0"
70
+ "scion-core": "*"
71
71
  },
72
72
  "devDependencies": {
73
73
  "@babel/parser": "^7.28.0",