fhirsmith 0.14.1 → 0.14.2

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +3 -2
  3. package/config-template.json +9 -0
  4. package/library/fhir-openapi-schema.js +441 -0
  5. package/library/html-server.js +9 -3
  6. package/library/npm-audit.js +265 -0
  7. package/library/openapi-doc.js +557 -0
  8. package/library/utilities.js +21 -0
  9. package/package.json +10 -4
  10. package/packages/openapi.js +13 -0
  11. package/packages/openapi.yaml +603 -0
  12. package/packages/packages-template.html +4 -2
  13. package/packages/packages.js +286 -81
  14. package/packages/readme.md +120 -0
  15. package/registry/api.js +117 -41
  16. package/registry/crawler.js +24 -0
  17. package/registry/fhirsmith-releases.js +170 -0
  18. package/registry/model.js +10 -0
  19. package/registry/openapi.js +13 -0
  20. package/registry/openapi.yaml +311 -0
  21. package/registry/readme.md +107 -208
  22. package/registry/registry-template.html +4 -1
  23. package/registry/registry.js +180 -19
  24. package/server.js +19 -5
  25. package/testing/openapi-schemas.config.js +58 -0
  26. package/testing/openapi-schemas.json +1920 -0
  27. package/testing/openapi.js +15 -0
  28. package/testing/openapi.yaml +273 -0
  29. package/testing/readme.md +43 -1
  30. package/testing/search.js +1 -1
  31. package/testing/testing-template.html +2 -0
  32. package/testing/testing.js +24 -0
  33. package/translations/Messages.properties +1 -0
  34. package/tx/README.md +58 -0
  35. package/tx/cm/cm-database.js +10 -1
  36. package/tx/cm/cm-package.js +8 -1
  37. package/tx/cs/cs-rxnorm.js +23 -6
  38. package/tx/html/tx-template.html +2 -0
  39. package/tx/library/canonical-resource.js +50 -1
  40. package/tx/library/codesystem.js +1 -0
  41. package/tx/library/conceptmap.js +1 -0
  42. package/tx/library/valueset.js +16 -0
  43. package/tx/library.js +17 -2
  44. package/tx/openapi-operations.js +363 -0
  45. package/tx/openapi-schemas.config.js +118 -0
  46. package/tx/openapi-schemas.json +7903 -0
  47. package/tx/openapi.js +289 -0
  48. package/tx/openapi.yaml +151 -0
  49. package/tx/operation-context.js +15 -0
  50. package/tx/params.js +3 -12
  51. package/tx/provider.js +10 -1
  52. package/tx/tx-html.js +6 -0
  53. package/tx/tx.js +55 -0
  54. package/tx/vs/vs-database.js +14 -5
  55. package/tx/vs/vs-package.js +10 -0
  56. package/tx/workers/expand.js +35 -16
  57. package/tx/workers/metadata.js +0 -13
  58. package/tx/workers/validate.js +65 -19
  59. package/tx/workers/worker.js +29 -8
  60. package/utilities/extract-schema-json.js +167 -0
  61. package/utilities/generate-openapi-schemas.js +49 -0
  62. package/xig/xig.js +2 -4
@@ -620,19 +620,23 @@ class ValueSetExpander {
620
620
  return count;
621
621
  }
622
622
 
623
+ /**
624
+ * Exclude the codes of an (expanded) value set. Excludes are processed before includes,
625
+ * so the codes are recorded as excluded - as excludeCode does - and the includes then
626
+ * skip them. (This used to remove them from what had been included so far, which at
627
+ * that point is nothing, so excluding a value set excluded nothing.)
628
+ */
623
629
  excludeValueSet(vs, expansion, imports, offset) {
624
- for (const c of vs.expansion.contains) {
625
- this.worker.deadCheck('excludeValueSet');
626
- const s = this.keyC(c);
627
- if (this.passesImports(imports, c.system, c.code, offset) && this.map.has(s)) {
628
- const idx = this.fullList.indexOf(this.map.get(s));
629
- if (idx >= 0) {
630
- this.fullList.splice(idx, 1);
630
+ const walk = (list) => {
631
+ for (const c of list || []) {
632
+ this.worker.deadCheck('excludeValueSet');
633
+ if (c.code && this.passesImports(imports, c.system, c.code, offset)) {
634
+ this.excluded.add((this.doingVersion && !this.params.versionsMatch ? c.system + '|' + c.version : c.system) + '#' + c.code);
631
635
  }
632
- this.map.delete(s);
633
- this.decTotal();
636
+ walk(c.contains);
634
637
  }
635
- }
638
+ };
639
+ walk(vs.expansion.contains);
636
640
  }
637
641
 
638
642
  async checkSource(cset, exp, filter, srcURL, ts, vsInfo , source) {
@@ -1012,8 +1016,10 @@ class ValueSetExpander {
1012
1016
  let vs = await this.worker.findValueSet(s, '', vsSrc);
1013
1017
  const ivs = new ImportedValueSet(await this.expandValueSet(s, '', vs, filter, notClosed));
1014
1018
  this.checkResourceCanonicalStatus(expansion, ivs.valueSet, this.valueSet);
1015
- if (!vs.isContained && ivs.valueSet.vurl) {
1016
- this.addParamUri(expansion, 'used-valueset', ivs.valueSet.vurl);
1019
+ // ivs.valueSet is the expansion (plain JSON), so it has no vurl - as for the
1020
+ // includes, build it
1021
+ if (!vs.isContained && this.worker.makeVurl(ivs.valueSet)) {
1022
+ this.addParamUri(expansion, 'used-valueset', this.worker.makeVurl(ivs.valueSet));
1017
1023
  }
1018
1024
  valueSets.push(ivs);
1019
1025
  }
@@ -1314,13 +1320,22 @@ class ValueSetExpander {
1314
1320
  }
1315
1321
 
1316
1322
  async expand(source, filter, noCacheThisOne) {
1317
- this.noCacheThisOne = noCacheThisOne;
1318
- this.totalStatus = 'uninitialised';
1319
- this.total = 0;
1320
-
1321
1323
  Extensions.checkNoImplicitRules(source,'ValueSetExpander.Expand', 'ValueSet', source.vurl);
1322
1324
  Extensions.checkNoModifiers(source,'ValueSetExpander.Expand', 'ValueSet', source.vurl);
1325
+ // circular reference detection: this value set is in the chain being processed until
1326
+ // its expansion is done
1323
1327
  this.worker.seeValueSet(source, this.params);
1328
+ try {
1329
+ return await this.expandSeen(source, filter, noCacheThisOne);
1330
+ } finally {
1331
+ this.worker.unseeValueSet(source);
1332
+ }
1333
+ }
1334
+
1335
+ async expandSeen(source, filter, noCacheThisOne) {
1336
+ this.noCacheThisOne = noCacheThisOne;
1337
+ this.totalStatus = 'uninitialised';
1338
+ this.total = 0;
1324
1339
  this.valueSet = source;
1325
1340
 
1326
1341
  const result = structuredClone(source.jsonObj);
@@ -1348,6 +1363,10 @@ class ValueSetExpander {
1348
1363
  if (result.expansion) {
1349
1364
  return result; // just return the expansion
1350
1365
  }
1366
+ if (!source.jsonObj.compose) {
1367
+ throw new Issue('error', 'invalid', null, 'VALUESET_NO_COMPOSE',
1368
+ this.worker.i18n.translate('VALUESET_NO_COMPOSE', this.params.httpLanguages, [source.contextKey || source.vurlOrMsg]), 'vs-invalid', 422);
1369
+ }
1351
1370
 
1352
1371
  if (this.params.generateNarrative && !this.noDetails) {
1353
1372
  div_ = div();
@@ -437,7 +437,6 @@ class MetadataHandler {
437
437
  // Parameters the $expand handler reads - see handleTypeLevelExpand in
438
438
  // tx/workers/expand.js and TxParameters.seeParameter in tx/params.js.
439
439
  // Keep this list in step with those when adding a parameter.
440
- { name: '_incomplete' },
441
440
  { name: 'abstract' },
442
441
  { name: 'activeOnly' },
443
442
  { name: 'check-system-version' },
@@ -453,20 +452,12 @@ class MetadataHandler {
453
452
  { name: 'filter' },
454
453
  { name: 'force-system-version' },
455
454
  { name: 'force-valueset-version' },
456
- { name: 'inactive' },
457
- { name: 'includeAlternateCodes' },
458
455
  { name: 'includeDefinition' },
459
456
  { name: 'includeDesignations' },
460
- { name: 'incomplete-ok' },
461
457
  {
462
458
  name: 'limit',
463
459
  documentation: 'Upper bound on the size of the expansion; the operation fails as too-costly beyond it'
464
460
  },
465
- { name: 'limitedExpansion' },
466
- {
467
- name: 'mode',
468
- documentation: '=lenient-display-validation'
469
- },
470
461
  { name: 'no-cache' },
471
462
  { name: 'offset' },
472
463
  { name: 'profile' },
@@ -479,10 +470,6 @@ class MetadataHandler {
479
470
  { name: 'url' },
480
471
  { name: 'useSupplement' },
481
472
  { name: 'valueSet' },
482
- {
483
- name: 'valueSetMode',
484
- documentation: '= CHECK_MEMBERSHIP_ONLY | NO_MEMBERSHIP_CHECK'
485
- },
486
473
  { name: 'valueSetVersion' }
487
474
  ]
488
475
  };
@@ -285,7 +285,10 @@ class ValueSetChecker {
285
285
  }
286
286
 
287
287
  seeValueSet() {
288
- this.worker.opContext.seeContext(this.valueSet.vurl);
288
+ const key = this.valueSet.contextKey !== undefined ? this.valueSet.contextKey : this.valueSet.vurl;
289
+ if (key) {
290
+ this.worker.opContext.seeContext(key);
291
+ }
289
292
  if (this.valueSet.jsonObj.compose && this.valueSet.jsonObj.compose.extension) {
290
293
  for (let ext of this.valueSet.jsonObj.compose.extension) {
291
294
  if (ext.url === 'http://hl7.org/fhir/StructureDefinition/valueset-expansion-parameter' || ext.url === 'http://hl7.org/fhir/tools/StructureDefinition/valueset-expansion-parameter') {
@@ -305,8 +308,22 @@ class ValueSetChecker {
305
308
  async prepare() {
306
309
  if (this.valueSet === null) {
307
310
  throw new Issue('error', 'not-found', null, null, 'Error Error: vs = nil', null, 422);
308
- } else {
309
- this.seeValueSet();
311
+ }
312
+ // circular reference detection: this value set is in the chain being processed until
313
+ // it's prepared (which prepares everything it imports)
314
+ this.seeValueSet();
315
+ try {
316
+ await this.prepareSeen();
317
+ } finally {
318
+ const key = this.valueSet.contextKey !== undefined ? this.valueSet.contextKey : this.valueSet.vurl;
319
+ if (key) {
320
+ this.worker.opContext.unseeContext(key);
321
+ }
322
+ }
323
+ }
324
+
325
+ async prepareSeen() {
326
+ {
310
327
  this.worker.opContext.addNote(this.valueSet, 'Analysing ' + this.valueSet.vurl + ' for validation purposes', this.indentCount);
311
328
  if (this.indentCount === 0) {
312
329
  this.worker.opContext.addNote(this.valueSet, 'Parameters: ' + this.params.summary(), this.indentCount);
@@ -569,9 +586,14 @@ class ValueSetChecker {
569
586
  if (msg) {
570
587
  op.addIssue(new Issue('information', 'informational', addToPath(path, 'code'), null, msg, 'process-note'));
571
588
  }
572
- inactive.value = await cs.isInactive(ctxt.context);
573
- inactive.path = path;
574
- vstatus.value = await cs.getStatus(ctxt.context);
589
+ // in a CodeableConcept a later coding must not clear the inactive status an earlier
590
+ // coding found, or the warning silently disappears when the inactive coding comes first
591
+ if (!inactive.value) {
592
+ inactive.value = await cs.isInactive(ctxt.context);
593
+ inactive.path = path;
594
+ inactive.code = code;
595
+ vstatus.value = await cs.getStatus(ctxt.context);
596
+ }
575
597
  }
576
598
  if (displays !== null) {
577
599
  await this.worker.listDisplaysFromCodeSystem(displays, cs, ctxt.context);
@@ -925,6 +947,7 @@ class ValueSetChecker {
925
947
  let pdisp;
926
948
  let psys;
927
949
  let pcode;
950
+ let ppath; // the path of the coding that is returned
928
951
  let pver;
929
952
 
930
953
 
@@ -1063,6 +1086,7 @@ class ValueSetChecker {
1063
1086
  if (pcode === undefined) {
1064
1087
  psys = c.system;
1065
1088
  pcode = c.code;
1089
+ ppath = path;
1066
1090
  if (ver.value) {
1067
1091
  pver = ver.value;
1068
1092
  }
@@ -1185,6 +1209,7 @@ class ValueSetChecker {
1185
1209
  if (!this.params.membershipOnly && !inactive.value && await prov.isInactive(ctxt.context)) {
1186
1210
  inactive.value = true;
1187
1211
  inactive.path = path;
1212
+ inactive.code = c.code;
1188
1213
  const st = await prov.getStatus(ctxt.context);
1189
1214
  if (st) {
1190
1215
  vstatus.value = st;
@@ -1340,9 +1365,14 @@ class ValueSetChecker {
1340
1365
  }
1341
1366
 
1342
1367
  if (inactive.value) {
1343
- result.addParamBool('inactive', inactive.value);
1344
- if (vstatus.value && vstatus.value !== 'inactive') {
1345
- result.addParamCode('status', vstatus.value);
1368
+ // the inactive and status parameters describe the code being returned. In a CodeableConcept
1369
+ // the inactive coding may be a different one (or none was returned at all): it still gets
1370
+ // its warning below, but the parameters are not claimed for the returned code
1371
+ if (mode !== 'codeableConcept' || (pcode && inactive.path === ppath)) {
1372
+ result.addParamBool('inactive', inactive.value);
1373
+ if (vstatus.value && vstatus.value !== 'inactive') {
1374
+ result.addParamCode('status', vstatus.value);
1375
+ }
1346
1376
  }
1347
1377
  let mpath = inactive.path;
1348
1378
  if (!mpath) {
@@ -1354,7 +1384,9 @@ class ValueSetChecker {
1354
1384
  mid1 = 'INACTIVE_CONCEPT_FOUND_ADD';
1355
1385
  mm1 = 'inactive';
1356
1386
  }
1357
- let m = this.worker.i18n.translate(mid1, this.params.HTTPLanguages, [vstatus.value, tcode, mm1]);
1387
+ // report the code that is actually inactive - in a CodeableConcept that isn't necessarily
1388
+ // the first coding (tcode), which is what this message used to name
1389
+ let m = this.worker.i18n.translate(mid1, this.params.HTTPLanguages, [vstatus.value, inactive.code || tcode, mm1]);
1358
1390
  msg(m);
1359
1391
  op.addIssue(new Issue('warning', 'business-rule', mpath, 'INACTIVE_CONCEPT_FOUND', m, 'code-comment'));
1360
1392
  } else if (vstatus.value && vstatus.value.toLowerCase() === 'deprecated') {
@@ -1548,6 +1580,7 @@ class ValueSetChecker {
1548
1580
  if (!this.params.membershipOnly && role !== 'not in') {
1549
1581
  inactive.value = true;
1550
1582
  inactive.path = path;
1583
+ inactive.code = code;
1551
1584
  if (inactive.value) {
1552
1585
  vstatus.value = await cs.getStatus(loc.context);
1553
1586
  }
@@ -1558,6 +1591,7 @@ class ValueSetChecker {
1558
1591
  if (role !== 'not in') {
1559
1592
  inactive.value = true;
1560
1593
  inactive.path = path;
1594
+ inactive.code = code;
1561
1595
  vstatus.value = await cs.getStatus(loc.context);
1562
1596
  let msg = this.worker.i18n.translate('STATUS_CODE_WARNING_CODE', this.params.HTTPLanguages, ['not active', code]);
1563
1597
  messages.push(msg);
@@ -1566,9 +1600,12 @@ class ValueSetChecker {
1566
1600
  } else {
1567
1601
  result = true;
1568
1602
  if (role !== 'not in') {
1569
- inactive.value = await cs.isInactive(loc.context);
1570
- inactive.path = path;
1571
- vstatus.value = await cs.getStatus(loc.context);
1603
+ if (!inactive.value) {
1604
+ inactive.value = await cs.isInactive(loc.context);
1605
+ inactive.path = path;
1606
+ inactive.code = code;
1607
+ vstatus.value = await cs.getStatus(loc.context);
1608
+ }
1572
1609
  if (vcc !== null) {
1573
1610
  if (!vcc.coding) {
1574
1611
  vcc.coding = [];
@@ -1605,6 +1642,7 @@ class ValueSetChecker {
1605
1642
  if (!this.params.membershipOnly) {
1606
1643
  inactive.value = true;
1607
1644
  inactive.path = path;
1645
+ inactive.code = code;
1608
1646
  vstatus.value = await cs.getStatus(loc);
1609
1647
  }
1610
1648
  } else {
@@ -1618,9 +1656,12 @@ class ValueSetChecker {
1618
1656
  } else if (Extensions.has(cc,'http://hl7.org/fhir/StructureDefinition/valueset-deprecated')) {
1619
1657
  op.addIssue(new Issue('warning', 'business-rule', addToPath(path, 'code'), 'CONCEPT_DEPRECATED_IN_VALUESET', this.worker.i18n.translate('CONCEPT_DEPRECATED_IN_VALUESET', this.params.HTTPLanguages, [cs.system(), code, 'deprecated', vs.vurl]), 'code-comment'));
1620
1658
  }
1621
- inactive.value = await cs.isInactive(loc);
1622
- inactive.path = path;
1623
- vstatus.value = await cs.getStatus(loc);
1659
+ if (!inactive.value) {
1660
+ inactive.value = await cs.isInactive(loc);
1661
+ inactive.path = path;
1662
+ inactive.code = code;
1663
+ vstatus.value = await cs.getStatus(loc);
1664
+ }
1624
1665
  result = true;
1625
1666
  return result;
1626
1667
  }
@@ -1669,6 +1710,7 @@ class ValueSetChecker {
1669
1710
  if (!this.params.membershipOnly) {
1670
1711
  inactive.value = true;
1671
1712
  inactive.path = path;
1713
+ inactive.code = code;
1672
1714
  vstatus.value = await cs.getStatus(loc);
1673
1715
  }
1674
1716
  } else {
@@ -1681,6 +1723,7 @@ class ValueSetChecker {
1681
1723
  if (await cs.isInactive(loc)) {
1682
1724
  inactive.value = true;
1683
1725
  inactive.path = path;
1726
+ inactive.code = code;
1684
1727
  // only replace a status we already have with a real one - a filter context
1685
1728
  // doesn't always carry the status (SNOMED, LOINC), and the assembly reads
1686
1729
  // vstatus for the message wording as well as for the status parameter
@@ -1718,9 +1761,12 @@ class ValueSetChecker {
1718
1761
  }
1719
1762
  } else {
1720
1763
  result = true;
1721
- inactive.value = await cs.isInactive(loc.context);
1722
- inactive.path = path;
1723
- vstatus.value = await cs.getStatus(loc.context);
1764
+ if (!inactive.value) {
1765
+ inactive.value = await cs.isInactive(loc.context);
1766
+ inactive.path = path;
1767
+ inactive.code = code;
1768
+ vstatus.value = await cs.getStatus(loc.context);
1769
+ }
1724
1770
  await this.worker.listDisplaysFromCodeSystem(displays, cs, loc.context);
1725
1771
  return result;
1726
1772
  }
@@ -379,14 +379,22 @@ class TerminologyWorker {
379
379
  return null;
380
380
  }
381
381
  if (url.startsWith("#")) {
382
+ // A local reference: per FHIR, #id is resolved against the contained resources of
383
+ // the resource being processed - which, when that is itself a contained value set,
384
+ // means its container's (contained resources are all siblings in one list)
382
385
  if (source) {
383
- if (source.jsonObj) {
384
- source = source.jsonObj;
385
- }
386
- for (const contained of source.contained || []) {
387
- if (contained.id === url.substring(1)) {
386
+ const container = source.container || source;
387
+ const json = container.jsonObj || container;
388
+ for (const contained of json.contained || []) {
389
+ if (contained && contained.id === url.substring(1)) {
390
+ if (contained.resourceType !== 'ValueSet') {
391
+ // the resource wrappers already reject this; this is the second line of defence
392
+ throw new Issue('error', 'not-supported', null, 'CONTAINED_RESOURCE_NOT_SUPPORTED',
393
+ `The reference '${url}' is to a contained ${contained.resourceType}, not a ValueSet`, 'not-supported', 400);
394
+ }
388
395
  const ret = this.wrapRawResource(contained);
389
396
  ret.isContained = true;
397
+ ret.container = container.jsonObj ? container : this.wrapRawResource(container);
390
398
  return ret;
391
399
  }
392
400
  }
@@ -477,11 +485,24 @@ class TerminologyWorker {
477
485
  * Note: this is the caller's parameters, not this.params - the worker's own
478
486
  * params is null when expanding an imported ValueSet
479
487
  */
488
+ /** The circular reference key seeValueSet tracks for a value set */
489
+ valueSetContextKey(vs) {
490
+ return vs.contextKey !== undefined ? vs.contextKey : (vs.url ? (vs.url + (vs.version ? '|' + vs.version : '')) : null);
491
+ }
492
+
493
+ /** Finished processing a value set: see OperationContext.unseeContext */
494
+ unseeValueSet(vs) {
495
+ const key = this.valueSetContextKey(vs);
496
+ if (key) {
497
+ this.opContext.unseeContext(key);
498
+ }
499
+ }
500
+
480
501
  seeValueSet(vs, params) {
481
502
  // Build canonical URL from url and version
482
- const vurl = vs.url ? (vs.url + (vs.version ? '|' + vs.version : '')) : null;
483
- if (vurl) {
484
- this.opContext.seeContext(vurl);
503
+ const key = this.valueSetContextKey(vs);
504
+ if (key) {
505
+ this.opContext.seeContext(key);
485
506
  }
486
507
  // Check for expansion parameter extensions on compose
487
508
  if (vs.jsonObj.compose && vs.jsonObj.compose.extension) {
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
4
+ //
5
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
6
+ //
7
+
8
+ // The published FHIR specification has a page per resource for its JSON schema
9
+ // (e.g. testreport.schema.json.html), but not the schema itself as a file, so links to
10
+ // testreport.schema.json fail. This walks a tree of published specifications and, for each
11
+ // X.schema.json.html, takes the content of its <pre> element and writes it as X.schema.json
12
+ // beside it.
13
+ //
14
+ // node utilities/extract-schema-json.js <dir> [<dir> ...] [--dry-run] [--force] [--verbose]
15
+ //
16
+ // --dry-run report what would be written, but write nothing
17
+ // --force overwrite an existing X.schema.json whose content differs (by default it's
18
+ // left alone and reported)
19
+ // --verbose list every file written or skipped
20
+ // --no-recurse only look in the named directories themselves, not below them
21
+ //
22
+ // It can be stopped and run again: files already written are reported as up to date.
23
+ //
24
+ // A page is skipped (and reported) if it doesn't have exactly one <pre>, or if the content
25
+ // isn't valid JSON. Symbolic links are not followed. Exit status is 1 if anything was
26
+ // skipped for a problem, so it can be run in a script.
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ const SUFFIX = '.schema.json.html';
32
+
33
+ const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ' };
34
+
35
+ function decodeEntities(text) {
36
+ return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (m, e) => {
37
+ if (e[0] === '#') {
38
+ const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.substring(2), 16) : parseInt(e.substring(1), 10);
39
+ return Number.isFinite(code) ? String.fromCodePoint(code) : m;
40
+ }
41
+ const v = NAMED_ENTITIES[e.toLowerCase()];
42
+ return v === undefined ? m : v;
43
+ });
44
+ }
45
+
46
+ /**
47
+ * The text with the markup removed. Repeated until nothing changes, so that removing one tag
48
+ * can't leave another behind (<a<b>>).
49
+ */
50
+ function stripTags(html) {
51
+ let text = html;
52
+ let previous;
53
+ do {
54
+ previous = text;
55
+ text = text.replace(/<[^<>]*>/g, '');
56
+ } while (text !== previous);
57
+ return text;
58
+ }
59
+
60
+ /**
61
+ * The schema in a page: the text of its one <pre> element, with any markup removed and
62
+ * entities decoded.
63
+ *
64
+ * @param {string} html
65
+ * @returns {{json?: string, problem?: string}}
66
+ */
67
+ function extractSchema(html) {
68
+ const pres = [...html.matchAll(/<pre\b[^>]*>([\s\S]*?)<\/pre>/gi)];
69
+ if (pres.length !== 1) {
70
+ return { problem: pres.length === 0 ? 'no <pre> element' : `${pres.length} <pre> elements` };
71
+ }
72
+ // entities are decoded after the markup is gone: a decoded < is the schema's own text
73
+ const text = decodeEntities(stripTags(pres[0][1])).trim() + '\n';
74
+ try {
75
+ JSON.parse(text);
76
+ } catch (e) {
77
+ return { problem: 'the <pre> content is not valid JSON: ' + e.message };
78
+ }
79
+ return { json: text };
80
+ }
81
+
82
+ async function* walk(dir, recurse = true) {
83
+ let handle;
84
+ try {
85
+ handle = await fs.promises.opendir(dir);
86
+ } catch (e) {
87
+ console.error(`Cannot read ${dir}: ${e.message}`);
88
+ return;
89
+ }
90
+ for await (const entry of handle) {
91
+ const p = path.join(dir, entry.name);
92
+ if (entry.isDirectory()) {
93
+ if (recurse) {
94
+ yield* walk(p);
95
+ }
96
+ } else if (entry.isFile() && entry.name.endsWith(SUFFIX)) {
97
+ yield p;
98
+ }
99
+ }
100
+ }
101
+
102
+ async function run(roots, options = {}) {
103
+ const counts = { pages: 0, written: 0, unchanged: 0, different: 0, problems: 0 };
104
+ const log = (msg) => options.verbose && console.log(msg);
105
+ for (const root of roots) {
106
+ for await (const page of walk(root, !options.noRecurse)) {
107
+ counts.pages++;
108
+ const target = page.slice(0, -'.html'.length);
109
+ const { json, problem } = extractSchema(await fs.promises.readFile(page, 'utf8'));
110
+ if (problem) {
111
+ counts.problems++;
112
+ console.warn(`${page}: ${problem}`);
113
+ continue;
114
+ }
115
+ let existing = null;
116
+ try {
117
+ existing = await fs.promises.readFile(target, 'utf8');
118
+ } catch (e) {
119
+ // not there yet
120
+ }
121
+ if (existing === json) {
122
+ counts.unchanged++;
123
+ continue;
124
+ }
125
+ if (existing !== null && !options.force) {
126
+ counts.different++;
127
+ console.warn(`${target}: exists with different content - left alone (--force to overwrite)`);
128
+ continue;
129
+ }
130
+ if (!options.dryRun) {
131
+ await fs.promises.writeFile(target, json);
132
+ }
133
+ counts.written++;
134
+ log(`${options.dryRun ? 'would write' : 'wrote'} ${target}`);
135
+ }
136
+ }
137
+ return counts;
138
+ }
139
+
140
+ async function main(args) {
141
+ const options = {
142
+ dryRun: args.includes('--dry-run'),
143
+ force: args.includes('--force'),
144
+ verbose: args.includes('--verbose'),
145
+ noRecurse: args.includes('--no-recurse')
146
+ };
147
+ const roots = args.filter(a => !a.startsWith('--'));
148
+ if (roots.length === 0) {
149
+ console.error('usage: extract-schema-json.js <dir> [<dir> ...] [--dry-run] [--force] [--verbose] [--no-recurse]');
150
+ process.exit(2);
151
+ }
152
+ const start = Date.now();
153
+ const c = await run(roots, options);
154
+ console.log(`${c.pages} schema pages: ${c.written} ${options.dryRun ? 'to write' : 'written'}, ` +
155
+ `${c.unchanged} already up to date, ${c.different} existing and different (left alone), ` +
156
+ `${c.problems} with problems (${((Date.now() - start) / 1000).toFixed(1)}s)`);
157
+ process.exit(c.problems > 0 ? 1 : 0);
158
+ }
159
+
160
+ if (require.main === module) {
161
+ main(process.argv.slice(2)).catch((e) => {
162
+ console.error(e);
163
+ process.exit(1);
164
+ });
165
+ }
166
+
167
+ module.exports = { extractSchema, decodeEntities, run };
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
4
+ //
5
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
6
+ //
7
+
8
+ // Generates a module's FHIR component schemas for its OpenAPI description, from the
9
+ // StructureDefinitions in a FHIR package (see library/fhir-openapi-schema.js).
10
+ //
11
+ // node utilities/generate-openapi-schemas.js <module> [-package <dir>]
12
+ //
13
+ // reads <module>/openapi-schemas.config.js and writes <module>/openapi-schemas.json. The
14
+ // package is found in the server's terminology cache unless -package names the unpacked
15
+ // package directory.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { generateSchemas } = require('../library/fhir-openapi-schema');
20
+
21
+ function packageDir(id) {
22
+ const folders = require('../library/folder-setup');
23
+ return folders.filePath('terminology-cache', id, 'package');
24
+ }
25
+
26
+ function main(args) {
27
+ const module = args[0];
28
+ if (!module) {
29
+ console.error('usage: generate-openapi-schemas.js <module> [-package <dir>]');
30
+ process.exit(2);
31
+ }
32
+ const moduleDir = path.join(__dirname, '..', module);
33
+ const config = require(path.join(moduleDir, 'openapi-schemas.config.js'));
34
+ const i = args.indexOf('-package');
35
+ const dir = i >= 0 ? args[i + 1] : packageDir(config.package);
36
+ if (!fs.existsSync(dir)) {
37
+ console.error(`The package ${config.package} isn't at ${dir}. Load it into the terminology cache, or use -package <dir>`);
38
+ process.exit(1);
39
+ }
40
+ const out = path.join(moduleDir, 'openapi-schemas.json');
41
+ fs.writeFileSync(out, JSON.stringify(generateSchemas(config, dir), null, 2) + '\n');
42
+ console.log(`Wrote ${out}`);
43
+ }
44
+
45
+ if (require.main === module) {
46
+ main(process.argv.slice(2));
47
+ }
48
+
49
+ module.exports = { packageDir };
package/xig/xig.js CHANGED
@@ -397,8 +397,7 @@ async function gatherPageStatistics() {
397
397
  downloadDate: downloadDate,
398
398
  totalResources: tableCounts.resources || 0,
399
399
  totalPackages: tableCounts.packages || 0,
400
- processingTime: processingTime,
401
- version: getMetadata('fhir-version') || '4.0.1'
400
+ processingTime: processingTime
402
401
  };
403
402
 
404
403
  } catch (error) {
@@ -411,8 +410,7 @@ async function gatherPageStatistics() {
411
410
  downloadDate: 'Error',
412
411
  totalResources: 0,
413
412
  totalPackages: 0,
414
- processingTime: processingTime,
415
- version: '4.0.1'
413
+ processingTime: processingTime
416
414
  };
417
415
  }
418
416
  }