fhirsmith 0.16.1 → 0.18.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
@@ -0,0 +1,415 @@
1
+ /**
2
+ * The syndication store: the content that has flowed through the tx library, as the
3
+ * Terminology Syndication Feed IG (https://build.fhir.org/ig/FHIR/fhir-syndication-ig/)
4
+ * will publish it.
5
+ *
6
+ * The store is a folder (terminology-cache/syndication) holding syndication.json - the
7
+ * metadata for every entry - and files/, copies of the naked resource files the library
8
+ * loaded. Packages and binaries are not copied: packages are permanently available from
9
+ * npm.fhir.org, and the binaries are write-once in the external store they were downloaded
10
+ * from, so their entries link there.
11
+ *
12
+ * Rules (see the syndication design doc in the FHIRsmith project):
13
+ * - content is inducted as the library loads it. Nothing is back-filled: anything the store
14
+ * has not seen before is new, and gets the time it was inducted
15
+ * - the store only grows. Entries are never changed or removed, and there are no retract
16
+ * entries. A later update to the same content (a changed file for the same url|version)
17
+ * is a new entry, so contentItemVersion is deliberately not unique (the IG says it must
18
+ * be; the spec authors agree that rule is wrong)
19
+ * - one store serves every endpoint. Only the core packages are FHIR version specific; their
20
+ * entries carry fhirVersion, and every other entry is in every endpoint's feed
21
+ *
22
+ * Induction is queued and done in the background, one item at a time, so that downloading
23
+ * a package to hash it never holds up the server starting. A failed induction is logged and
24
+ * not recorded, so it is tried again the next time the content is loaded.
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const fsp = fs.promises;
29
+ const path = require('path');
30
+ const crypto = require('crypto');
31
+
32
+ const NCTS_SCHEME = 'http://ns.electronichealth.net.au/ncts/syndication/asf/scheme/1.0.0';
33
+ const FHIRSMITH_SCHEME = 'http://healthintersections.com.au/syndication/fhirsmith/1.0.0';
34
+ const PACKAGE_SERVER = 'https://npm.fhir.org/web/';
35
+ const STORE_FORMAT = 1;
36
+ // no fetch is allowed to hold induction (or server shutdown, which waits for it) for longer
37
+ const FETCH_TIMEOUT_MS = 120000;
38
+
39
+ /** library source type -> category term in the FHIRsmith scheme, for the binary sources */
40
+ const BINARY_TERMS = {
41
+ snomed: 'SNOMED_CACHE',
42
+ loinc: 'LOINC_DB',
43
+ rxnorm: 'RXNORM_DB',
44
+ ndc: 'NDC_DB',
45
+ unii: 'UNII_DB',
46
+ omop: 'OMOP_DB',
47
+ icd11: 'ICD11_DB',
48
+ cpt: 'CPT_DB'
49
+ };
50
+
51
+ const RESOURCE_TYPES = ['CodeSystem', 'ValueSet', 'ConceptMap'];
52
+
53
+ const SNOMED_RIGHTS = 'Use of SNOMED CT is subject to use limitations: a licence from SNOMED International or a national release centre is required';
54
+ const OTHER_RIGHTS = 'Use of this content is subject to the applicable licence';
55
+
56
+ /**
57
+ * Whether a package version is a build rather than a release - #current and its kin are not
58
+ * syndicated, since they change under the same version.
59
+ */
60
+ function isBuildVersion(version) {
61
+ if (!version) {
62
+ return false;
63
+ }
64
+ const v = String(version);
65
+ return /^(current|dev)/.test(v) || v.includes('cibuild');
66
+ }
67
+
68
+ /** SHA-256 of a file, streamed (the binaries run to gigabytes) */
69
+ function hashFile(filePath) {
70
+ return new Promise((resolve, reject) => {
71
+ const hash = crypto.createHash('sha256');
72
+ const stream = fs.createReadStream(filePath);
73
+ stream.on('error', reject);
74
+ stream.on('data', (chunk) => hash.update(chunk));
75
+ stream.on('end', () => resolve(hash.digest('hex')));
76
+ });
77
+ }
78
+
79
+ function hashBuffer(buffer) {
80
+ return crypto.createHash('sha256').update(buffer).digest('hex');
81
+ }
82
+
83
+ /**
84
+ * The SHA-256 and length of a response body, streamed rather than held in memory (some
85
+ * packages are hundreds of megabytes)
86
+ */
87
+ async function hashResponse(response) {
88
+ const hash = crypto.createHash('sha256');
89
+ let length = 0;
90
+ if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
91
+ for await (const chunk of response.body) {
92
+ hash.update(chunk);
93
+ length += chunk.length;
94
+ }
95
+ } else {
96
+ const buffer = Buffer.from(await response.arrayBuffer());
97
+ hash.update(buffer);
98
+ length = buffer.length;
99
+ }
100
+ return { sha256: hash.digest('hex'), length };
101
+ }
102
+
103
+ class SyndicationStore {
104
+ /**
105
+ * @param {string} folder - the store folder (created if need be)
106
+ * @param {Object} log - logger
107
+ * @param {Object} [options]
108
+ * @param {Function} [options.fetch] - fetch implementation (tests)
109
+ * @param {Function} [options.now] - clock (tests): returns a Date
110
+ */
111
+ constructor(folder, log, options = {}) {
112
+ this.folder = folder;
113
+ this.filesFolder = path.join(folder, 'files');
114
+ this.jsonPath = path.join(folder, 'syndication.json');
115
+ this.log = log;
116
+ this.fetch = options.fetch || globalThis.fetch;
117
+ this.now = options.now || (() => new Date());
118
+ this.doc = null;
119
+ this.queue = Promise.resolve();
120
+ this.pending = 0;
121
+ }
122
+
123
+ /**
124
+ * Read the store, or start it if there isn't one: the store is defined from now on.
125
+ */
126
+ open() {
127
+ fs.mkdirSync(this.filesFolder, { recursive: true });
128
+ if (fs.existsSync(this.jsonPath)) {
129
+ const doc = JSON.parse(fs.readFileSync(this.jsonPath, 'utf8'));
130
+ if (!doc || !Array.isArray(doc.entries) || !doc.id) {
131
+ throw new Error(`The syndication store ${this.jsonPath} is not valid`);
132
+ }
133
+ this.doc = doc;
134
+ } else {
135
+ this.doc = {
136
+ format: STORE_FORMAT,
137
+ id: crypto.randomUUID(),
138
+ created: this.now().toISOString(),
139
+ entries: []
140
+ };
141
+ this.#save();
142
+ this.log.info(`Syndication store started in ${this.folder}`);
143
+ }
144
+ this.#index();
145
+ return this;
146
+ }
147
+
148
+ #index() {
149
+ // key -> the most recent entry with that key
150
+ this.byKey = new Map();
151
+ // name -> path, for each file copied into the store (the only files it serves)
152
+ this.files = new Map();
153
+ for (const entry of this.doc.entries) {
154
+ this.#indexEntry(entry);
155
+ }
156
+ }
157
+
158
+ #indexEntry(entry) {
159
+ this.byKey.set(entry.key, entry);
160
+ if (entry.link && entry.link.file) {
161
+ this.files.set(entry.link.file, path.join(this.filesFolder, path.basename(entry.link.file)));
162
+ }
163
+ }
164
+
165
+ #save() {
166
+ const tmp = this.jsonPath + '.tmp';
167
+ fs.writeFileSync(tmp, JSON.stringify(this.doc, null, 2));
168
+ fs.renameSync(tmp, this.jsonPath);
169
+ }
170
+
171
+ /** {string} the store's own id, from which each endpoint's feed id is made */
172
+ get id() {
173
+ return this.doc.id;
174
+ }
175
+
176
+ get created() {
177
+ return this.doc.created;
178
+ }
179
+
180
+ /** {Object[]} every entry, in the order they were inducted */
181
+ get entries() {
182
+ return this.doc.entries;
183
+ }
184
+
185
+ /**
186
+ * The entries in the feed for an endpoint at the given FHIR version: everything that isn't
187
+ * version specific, plus that version's own.
188
+ * @param {string} fhirVersion - major.minor
189
+ */
190
+ entriesFor(fhirVersion) {
191
+ return this.doc.entries.filter(e => !e.fhirVersion || e.fhirVersion === fhirVersion);
192
+ }
193
+
194
+ /** Resolves when everything queued so far has been inducted (or has failed) */
195
+ whenIdle() {
196
+ return this.queue;
197
+ }
198
+
199
+ #enqueue(label, task) {
200
+ this.pending++;
201
+ this.queue = this.queue.then(async () => {
202
+ try {
203
+ await task();
204
+ } catch (error) {
205
+ this.log.warn(`Syndication: unable to induct ${label}: ${error.message}`);
206
+ } finally {
207
+ this.pending--;
208
+ }
209
+ });
210
+ return this.queue;
211
+ }
212
+
213
+ #add(entry) {
214
+ const now = this.now().toISOString();
215
+ const full = {
216
+ id: 'urn:uuid:' + crypto.randomUUID(),
217
+ inducted: now,
218
+ ...entry
219
+ };
220
+ this.doc.entries.push(full);
221
+ this.#indexEntry(full);
222
+ this.#save();
223
+ this.log.info(`Syndication: inducted ${full.title} (${full.version})`);
224
+ return full;
225
+ }
226
+
227
+ /** download a url, keeping only its SHA-256 and length */
228
+ async #downloadHash(url) {
229
+ const response = await this.fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
230
+ if (!response.ok) {
231
+ throw new Error(`HTTP ${response.status} fetching ${url}`);
232
+ }
233
+ return hashResponse(response);
234
+ }
235
+
236
+ /**
237
+ * A package the library loaded (npm: or url: source, or an endpoint's core package).
238
+ *
239
+ * @param {Object} pkg
240
+ * @param {string} pkg.id - package id
241
+ * @param {string} pkg.version - package version
242
+ * @param {string} [pkg.canonical] - canonical from package.json
243
+ * @param {string} [pkg.title] - title from package.json
244
+ * @param {string} [pkg.description] - description from package.json
245
+ * @param {string} [pkg.url] - where it came from, for a url: source; otherwise npm.fhir.org
246
+ * @param {string} [pkg.fhirVersion] - major.minor, for a version specific (core) package only
247
+ * @param {string} [pkg.requestedVersion] - the version the library asked for
248
+ */
249
+ inductPackage(pkg) {
250
+ const label = `package ${pkg.id}#${pkg.version}`;
251
+ if (isBuildVersion(pkg.version) || isBuildVersion(pkg.requestedVersion)) {
252
+ return this.queue;
253
+ }
254
+ const key = `package:${pkg.id}#${pkg.version}`;
255
+ if (this.byKey.has(key)) {
256
+ return this.queue;
257
+ }
258
+ return this.#enqueue(label, async () => {
259
+ if (this.byKey.has(key)) {
260
+ return;
261
+ }
262
+ if (!pkg.canonical) {
263
+ throw new Error('the package has no canonical in package.json');
264
+ }
265
+ const href = pkg.url || `${PACKAGE_SERVER}${encodeURIComponent(pkg.id)}-${encodeURIComponent(pkg.version)}.tgz`;
266
+ // packages never change, so the copy on the server is the one that is hashed
267
+ const { sha256, length } = await this.#downloadHash(href);
268
+ const entry = {
269
+ key,
270
+ kind: 'package',
271
+ category: { scheme: NCTS_SCHEME, term: 'FHIR_Package', label: 'FHIR Package' },
272
+ identifier: pkg.canonical,
273
+ version: `${pkg.canonical}|${pkg.version}`,
274
+ title: pkg.title ? `${pkg.title} (${pkg.id}#${pkg.version})` : `${pkg.id}#${pkg.version}`,
275
+ link: { href, type: 'application/gzip', length, sha256 }
276
+ };
277
+ if (pkg.description) {
278
+ entry.summary = pkg.description;
279
+ }
280
+ if (pkg.fhirVersion) {
281
+ entry.fhirVersion = pkg.fhirVersion;
282
+ }
283
+ this.#add(entry);
284
+ });
285
+ }
286
+
287
+ /**
288
+ * A resource the library loaded from a file: or folder: source. Resources without a version
289
+ * are not syndicated. The file is copied into the store, and a later file with different
290
+ * content for the same url|version is a new entry.
291
+ *
292
+ * @param {Object} res
293
+ * @param {string} res.filePath - the file it was loaded from
294
+ * @param {string} res.resourceType
295
+ * @param {string} res.url
296
+ * @param {string} res.version
297
+ * @param {string} [res.title]
298
+ * @param {string} [res.description]
299
+ */
300
+ inductResource(res) {
301
+ if (!res.url || !res.version || !RESOURCE_TYPES.includes(res.resourceType)) {
302
+ return this.queue;
303
+ }
304
+ const label = `${res.resourceType} ${res.url}|${res.version}`;
305
+ const key = `resource:${res.url}|${res.version}`;
306
+ return this.#enqueue(label, async () => {
307
+ const bytes = await fsp.readFile(res.filePath);
308
+ const sha256 = hashBuffer(bytes);
309
+ const last = this.byKey.get(key);
310
+ if (last && last.link.sha256 === sha256) {
311
+ return;
312
+ }
313
+ const fileName = crypto.randomUUID() + '.json';
314
+ await fsp.writeFile(path.join(this.filesFolder, fileName), bytes);
315
+ const entry = {
316
+ key,
317
+ kind: 'resource',
318
+ category: { scheme: NCTS_SCHEME, term: 'FHIR_' + res.resourceType, label: 'FHIR ' + res.resourceType },
319
+ identifier: res.url,
320
+ version: `${res.url}|${res.version}`,
321
+ title: res.title || res.url,
322
+ // relative: the feed makes it absolute under the endpoint the feed is read from
323
+ link: { file: fileName, type: 'application/fhir+json', length: bytes.length, sha256 }
324
+ };
325
+ if (res.description) {
326
+ entry.summary = res.description;
327
+ }
328
+ this.#add(entry);
329
+ });
330
+ }
331
+
332
+ /**
333
+ * A binary the library loaded (SNOMED cache, LOINC database etc). Only binaries that were
334
+ * published to the external store (baseUrl) are syndicated - a file that is only local is a
335
+ * development build. The binary is not copied: the entry links to the external store.
336
+ * Binary file names change whenever their content does, so the file name is the key.
337
+ *
338
+ * @param {Object} bin
339
+ * @param {string} bin.sourceType - library source type (snomed, loinc ...)
340
+ * @param {string} bin.filePath - the local copy
341
+ * @param {string} bin.baseUrl - the external store the library downloads binaries from
342
+ * @param {string} bin.system - code system uri
343
+ * @param {string} bin.version - code system version (for SNOMED, the version uri)
344
+ * @param {string} [bin.name] - code system name
345
+ */
346
+ inductBinary(bin) {
347
+ const term = BINARY_TERMS[bin.sourceType];
348
+ const fileName = path.basename(bin.filePath);
349
+ if (!term || !bin.baseUrl) {
350
+ return this.queue;
351
+ }
352
+ // the full CPT database is licensed content, and is never distributed; the public
353
+ // fragment is
354
+ if (bin.sourceType === 'cpt' && !fileName.toLowerCase().includes('fragment')) {
355
+ return this.queue;
356
+ }
357
+ const key = `binary:${fileName}`;
358
+ if (this.byKey.has(key)) {
359
+ return this.queue;
360
+ }
361
+ return this.#enqueue(`${bin.sourceType} file ${fileName}`, async () => {
362
+ if (this.byKey.has(key)) {
363
+ return;
364
+ }
365
+ const href = bin.baseUrl.replace(/\/+$/, '') + '/' + fileName;
366
+ const stat = await fsp.stat(bin.filePath);
367
+ const head = await this.fetch(href, { method: 'HEAD', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
368
+ const length = Number(head.headers.get('content-length'));
369
+ if (!head.ok || length !== stat.size) {
370
+ this.log.info(`Syndication: not syndicating ${fileName}: it is not published at ${href} (a local build)`);
371
+ return;
372
+ }
373
+ const sha256 = await hashFile(bin.filePath);
374
+ let identifier = bin.system;
375
+ let version = bin.version ? `${bin.system}|${bin.version}` : bin.system;
376
+ const sct = /^(.*)\/version\/[^/]+$/.exec(bin.version || '');
377
+ if (bin.sourceType === 'snomed' && sct) {
378
+ // SNOMED editions are identified by their module uri, and versioned by the version uri
379
+ identifier = sct[1];
380
+ version = bin.version;
381
+ }
382
+ const name = bin.name || bin.system;
383
+ this.#add({
384
+ key,
385
+ kind: 'binary',
386
+ category: { scheme: FHIRSMITH_SCHEME, term, label: term },
387
+ identifier,
388
+ version,
389
+ title: `${name}${bin.version && !name.includes(bin.version) ? ' ' + bin.version : ''} (${fileName})`,
390
+ summary: `FHIRsmith ${term} file ${fileName}`,
391
+ rights: bin.sourceType === 'snomed' ? SNOMED_RIGHTS : OTHER_RIGHTS,
392
+ link: { href, type: 'application/octet-stream', length: stat.size, sha256 }
393
+ });
394
+ });
395
+ }
396
+
397
+ /**
398
+ * The local path of a file copied into the store, or null if there isn't one by that name.
399
+ * The name is only ever looked up among the files the store's entries record - the path
400
+ * returned is the store's own, never one made from the name asked for.
401
+ * @param {string} name
402
+ */
403
+ filePath(name) {
404
+ if (typeof name !== 'string' || !/^[0-9a-f-]{36}\.json$/.test(name)) {
405
+ return null;
406
+ }
407
+ const p = this.files.get(name);
408
+ return p && fs.existsSync(p) ? p : null;
409
+ }
410
+ }
411
+
412
+ module.exports = {
413
+ SyndicationStore, isBuildVersion, hashFile,
414
+ NCTS_SCHEME, FHIRSMITH_SCHEME, BINARY_TERMS
415
+ };
package/tx/tx-html.js CHANGED
@@ -9,6 +9,7 @@ const htmlServer = require('../library/html-server');
9
9
  const Logger = require('../library/logger');
10
10
  const packageJson = require("../package.json");
11
11
  const escape = require('escape-html');
12
+ const { checkResourceNarratives } = require('../library/narrative-check');
12
13
  const {ExpandWorker} = require("./workers/expand");
13
14
  const ValueSet = require("./library/valueset");
14
15
  const {CodeSystemXML} = require("./xml/codesystem-xml");
@@ -154,6 +155,14 @@ function publishesLibrarySource() {
154
155
  return publishLibrarySource;
155
156
  }
156
157
 
158
+ // Whether this server has a syndication feed (modules.tx.syndication.enabled): gates the nav
159
+ // item and the feed autodiscovery link, as publishLibrarySource does for the library
160
+ let syndicationFeed = false;
161
+
162
+ function setSyndicationFeed(enabled) {
163
+ syndicationFeed = enabled === true;
164
+ }
165
+
157
166
  /**
158
167
  * Highlight a YAML document for display.
159
168
  *
@@ -268,6 +277,11 @@ class TxHtmlRenderer {
268
277
  options.apiLink = '<a href="' + escape(endpoint.path) + '/openapi" style="color: gold">API</a> &nbsp;|&nbsp;';
269
278
  options.apiHead = '<link rel="service-desc" type="application/json" href="' + escape(endpoint.path) + '/openapi.json"/>';
270
279
  }
280
+ if (syndicationFeed) {
281
+ options.libraryLink += '<a href="' + escape(endpoint.path) + '/syndication" style="color: gold">Syndication</a> &nbsp;|&nbsp;';
282
+ options.apiHead = (options.apiHead || '') + '<link rel="alternate" type="application/atom+xml" title="Syndication Feed" href="'
283
+ + escape(endpoint.path) + '/syndication?_format=atom"/>';
284
+ }
271
285
 
272
286
  return htmlServer.renderPage('tx', title, content, options);
273
287
  }
@@ -1017,7 +1031,7 @@ class TxHtmlRenderer {
1017
1031
  } else if (_fmt == "html/xml") {
1018
1032
  html += await this.renderResourceXml(json);
1019
1033
  } else if (_fmt == "html/narrative") {
1020
- html += await this.renderResourceWithNarrative(json, json.text?.div);
1034
+ html += await this.renderOriginalNarrative(json);
1021
1035
  } else if (_fmt == "html/ops") {
1022
1036
  html += await this.liquid.renderFile('codesystem-operations', {
1023
1037
  opsId: this.generateResourceId(),
@@ -1040,9 +1054,10 @@ class TxHtmlRenderer {
1040
1054
 
1041
1055
  tab(b, name, rtype, type, id) {
1042
1056
  if (b) {
1043
- return `<li class="active"><a href="#">${name}</a></li>`;
1057
+ return `<li class="active"><a href="#">${escape(name)}</a></li>`;
1044
1058
  } else {
1045
- return `<li><a href="${this.path}/${rtype}/${id}?_format=${type}">${name}</a></li>`;
1059
+ const href = `${this.path}/${encodeURIComponent(rtype)}/${encodeURIComponent(id)}?_format=${encodeURIComponent(type)}`;
1060
+ return `<li><a href="${escape(href)}">${escape(name)}</a></li>`;
1046
1061
  }
1047
1062
  }
1048
1063
  /**
@@ -1078,7 +1093,7 @@ class TxHtmlRenderer {
1078
1093
  } else if (_fmt == "html/xml") {
1079
1094
  html += await this.renderResourceXml(json);
1080
1095
  } else if (_fmt == "html/narrative") {
1081
- html += await this.renderResourceWithNarrative(json, json.text?.div);
1096
+ html += await this.renderOriginalNarrative(json);
1082
1097
  } else if (_fmt == "html/ops") {
1083
1098
  html += await this.liquid.renderFile('valueset-operations', {
1084
1099
  opsId: this.generateResourceId(),
@@ -1218,7 +1233,7 @@ class TxHtmlRenderer {
1218
1233
  } else if (_fmt == "html/json") {
1219
1234
  html += await this.renderResourceJson(json);
1220
1235
  } else if (_fmt == "html/narrative") {
1221
- html += await this.renderResourceWithNarrative(json, json.text?.div);
1236
+ html += await this.renderOriginalNarrative(json);
1222
1237
  } else if (_fmt == "html/ops") {
1223
1238
  const sourceSet = new Set();
1224
1239
  const codeSet = new Set();
@@ -1725,7 +1740,7 @@ class TxHtmlRenderer {
1725
1740
  html += '<div class="card-header">Bundle</div>';
1726
1741
  html += '<div class="card-body">';
1727
1742
  html += `<p><strong>Type:</strong> ${escape(json.type)}</p>`;
1728
- html += `<p><strong>Total:</strong> ${json.total || 'N/A'}</p>`;
1743
+ html += `<p><strong>Total:</strong> ${escape(String(json.total ?? 'N/A'))}</p>`;
1729
1744
  html += '</div>';
1730
1745
  html += '</div>';
1731
1746
 
@@ -1826,6 +1841,18 @@ class TxHtmlRenderer {
1826
1841
  return html;
1827
1842
  }
1828
1843
 
1844
+ /**
1845
+ * The resource's own narrative, as it is - unless it carries active content, which is
1846
+ * refused at load time but may reach here from a source that isn't loaded that way
1847
+ */
1848
+ async renderOriginalNarrative(json) {
1849
+ const found = checkResourceNarratives({ resourceType: json.resourceType, id: json.id, text: json.text });
1850
+ if (found) {
1851
+ return `<div class="alert alert-danger">This narrative is not shown: ${escape(found)}</div>`;
1852
+ }
1853
+ return this.renderResourceWithNarrative(json, json.text?.div);
1854
+ }
1855
+
1829
1856
  async renderResourceJson(json) {
1830
1857
  let html = "";
1831
1858
  html += `<div class="json-content" style="margin-top: 10px;">`;
@@ -1864,7 +1891,8 @@ class TxHtmlRenderer {
1864
1891
  return await this.liquid.renderFile('operations-form', {
1865
1892
  vcSystemId,
1866
1893
  inferSystemId,
1867
- valueSetsJson: JSON.stringify(json.valueSets || [])
1894
+ // inside a <script>: no '<', so no value can close it
1895
+ valueSetsJson: JSON.stringify(json.valueSets || []).replace(/</g, '\\u003c')
1868
1896
  });
1869
1897
  }
1870
1898
 
@@ -1887,5 +1915,6 @@ class TxHtmlRenderer {
1887
1915
 
1888
1916
  module.exports = {
1889
1917
  TxHtmlRenderer, loadTemplate, acceptsHtml,
1890
- setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage
1918
+ setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage,
1919
+ setSyndicationFeed
1891
1920
  };
@@ -26,15 +26,13 @@ sources:
26
26
  - unii:unii_20240622.db
27
27
  - snomed:sct_intl_20240201.cache
28
28
  - snomed!:sct_intl_20250201.cache
29
- - snomed:sct_ch_20230607.cache
30
- - snomed:sct_dk_20260331.cache
31
29
  - snomed:sct_ips_20241216.cache
32
30
  - snomed:sct_nl_20240930.cache
33
31
  - snomed:sct_uk_20230412.cache
34
32
  - snomed:sct_us_20230301.cache
35
33
  - snomed:sct_us_20240301.cache
36
34
  - snomed:sct_us_20250901.cache
37
- - snomed:sct_test_20250909b.cache
35
+ - snomed:sct_test_20250909c.cache
38
36
  - cpt:CodeSystem-cpt.db|cpt-2023-fragment-0.1.db
39
37
  - omop:omop_v20250227.db
40
38
  - npm:hl7.terminology=tho
@@ -44,6 +42,7 @@ sources:
44
42
  - npm:hl7.fhir.us.core=us
45
43
  - npm:us.cdc.phinvads=cdc
46
44
  - npm:hl7.fhir.uv.sdc=sdc
45
+ - npm:hl7.fhir.uv.ips=ips
47
46
  - internal:vsac=vsac
48
47
  - icd11:icd11-2026-01-b.db
49
48