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,422 @@
1
+ // library/daily-report.js
2
+ // A daily markdown report on the server, emailed at midnight (local time) - or, if the
3
+ // server was down at midnight, as soon as it comes up afterwards. It's only sent when
4
+ // there's a way to send it: the top-level "email" block (see library/mailer.js) and
5
+ // someone to send it to:
6
+ //
7
+ // "dailyReport": {
8
+ // "enabled": true, // default true; the report still needs "email" and "to"
9
+ // "to": ["ops@example.org"]
10
+ // }
11
+ //
12
+ // The report is:
13
+ // - a paragraph on the server's own stability since the last report: how much of the
14
+ // time it was up, restarts, and any upgrade (from the run history ServerStats keeps)
15
+ // - a paragraph from each running module that has something to say. A module takes part
16
+ // by having `dailyReport({since, now})` return markdown (a string, or a promise of one);
17
+ // an empty return means no paragraph. By default modules have nothing to say
18
+ // - a paragraph on the system: version, Node and OS, how long this run has been up, the
19
+ // log files and the free disk space they're drawing on
20
+ //
21
+ // When each report was sent is kept in [data]/daily-report.json, which is how a restart
22
+ // knows whether last midnight's report is still owed, and what period the next one covers.
23
+
24
+ const fs = require('fs');
25
+ const os = require('os');
26
+ const path = require('path');
27
+ const folders = require('./folder-setup');
28
+
29
+ const DAY_MS = 24 * 60 * 60 * 1000;
30
+ // headings for the modules' paragraphs; a module not listed is headed by its key
31
+ const MODULE_TITLES = {
32
+ tx: 'Terminology server',
33
+ registry: 'Terminology server registry',
34
+ packages: 'Packages',
35
+ xig: 'XIG',
36
+ shl: 'SMART Health Links',
37
+ ips: 'IPS',
38
+ testing: 'Testing',
39
+ publisher: 'Publisher',
40
+ vcl: 'VCL',
41
+ token: 'Tokens',
42
+ npmprojector: 'npm projector',
43
+ extTracker: 'Extension tracker',
44
+ folder: 'Folders'
45
+ };
46
+ const STATE_FILE = 'daily-report.json';
47
+ const TASK_NAME = 'daily report';
48
+
49
+ function pad(n) {
50
+ return String(n).padStart(2, '0');
51
+ }
52
+
53
+ /** 2026-10-22 for the local date of `time` */
54
+ function localDate(time) {
55
+ const d = new Date(time);
56
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
57
+ }
58
+
59
+ /** 2026-10-22 14:05 local */
60
+ function localDateTime(time) {
61
+ const d = new Date(time);
62
+ return `${localDate(time)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
63
+ }
64
+
65
+ /** The next local midnight after `now` */
66
+ function nextMidnight(now) {
67
+ const d = new Date(now);
68
+ d.setHours(24, 0, 0, 0);
69
+ return d.getTime();
70
+ }
71
+
72
+ /** "2d 3h 15m" - to the minute, which is all a daily report needs */
73
+ function formatSpan(ms) {
74
+ const minutes = Math.round(Math.max(0, ms) / 60000);
75
+ if (minutes < 1) {
76
+ return 'less than a minute';
77
+ }
78
+ const parts = [];
79
+ const days = Math.floor(minutes / 1440);
80
+ const hours = Math.floor((minutes % 1440) / 60);
81
+ const mins = minutes % 60;
82
+ if (days) parts.push(`${days}d`);
83
+ if (hours) parts.push(`${hours}h`);
84
+ if (mins || parts.length === 0) parts.push(`${mins}m`);
85
+ return parts.join(' ');
86
+ }
87
+
88
+ function plural(n, word) {
89
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
90
+ }
91
+
92
+ function formatBytes(bytes) {
93
+ if (bytes < 1024 * 1024) {
94
+ return `${(bytes / 1024).toFixed(0)} KB`;
95
+ }
96
+ if (bytes < 1024 * 1024 * 1024) {
97
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
98
+ }
99
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
100
+ }
101
+
102
+ class DailyReport {
103
+
104
+ /**
105
+ * @param config - the "dailyReport" block
106
+ * @param deps - {log, stats, modules, mailer, npmAudit, version, serverId, stateFile}
107
+ * modules is the server's {name: instance} map; npmAudit the server's NpmAudit (optional);
108
+ * version the server's version; serverId the name the report goes out under (config.hostName)
109
+ */
110
+ constructor(config, deps) {
111
+ this.config = config || {};
112
+ this.log = deps.log || console;
113
+ this.stats = deps.stats;
114
+ this.modules = deps.modules || {};
115
+ this.mailer = deps.mailer;
116
+ this.npmAudit = deps.npmAudit || null;
117
+ this.version = deps.version;
118
+ this.serverId = deps.serverId || os.hostname();
119
+ this.stateFile = deps.stateFile || folders.filePath(STATE_FILE);
120
+ this.timer = null;
121
+ this.running = false;
122
+ }
123
+
124
+ /**
125
+ * Whether reports will go out: turned on, somewhere to send them, and a
126
+ * way to send them.
127
+ */
128
+ get enabled() {
129
+ const to = this.config.to;
130
+ const hasRecipient = Array.isArray(to) ? to.length > 0 : Boolean(to);
131
+ return this.config.enabled !== false && hasRecipient && Boolean(this.mailer && this.mailer.configured());
132
+ }
133
+
134
+ /**
135
+ * Say in the log why reports won't be sent, if they won't; then send the
136
+ * one owed from last midnight if the server was down for it, and set the
137
+ * timer for the next.
138
+ */
139
+ start(now = Date.now()) {
140
+ if (this.config.enabled === false) {
141
+ return;
142
+ }
143
+ if (!this.enabled) {
144
+ const why = !(this.mailer && this.mailer.configured()) ? 'no "email" block in the configuration' : 'no "dailyReport.to" recipients';
145
+ this.log.info(`Daily report: not sent (${why})`);
146
+ return;
147
+ }
148
+ if (this.stats) {
149
+ this.stats.addTask(TASK_NAME, 'daily');
150
+ }
151
+ const state = this.readState();
152
+ // owed if a midnight has passed since the last report went out (a server
153
+ // that has never reported waits for its first midnight)
154
+ if (state.sentAt && localDate(state.sentAt) !== localDate(now)) {
155
+ this.log.info('Daily report: the server was down at midnight, sending it now');
156
+ void this.run(now);
157
+ }
158
+ this.schedule(now);
159
+ }
160
+
161
+ schedule(now = Date.now()) {
162
+ const delay = Math.max(1000, nextMidnight(now) - now);
163
+ this.timer = setTimeout(() => {
164
+ this.timer = null;
165
+ // run() handles its own errors, so the promise never rejects
166
+ void this.run().finally(() => this.schedule());
167
+ }, delay);
168
+ if (this.timer.unref) {
169
+ this.timer.unref();
170
+ }
171
+ }
172
+
173
+ stop() {
174
+ if (this.timer) {
175
+ clearTimeout(this.timer);
176
+ this.timer = null;
177
+ }
178
+ }
179
+
180
+ readState() {
181
+ try {
182
+ if (fs.existsSync(this.stateFile)) {
183
+ return JSON.parse(fs.readFileSync(this.stateFile, 'utf8')) || {};
184
+ }
185
+ } catch (e) {
186
+ this.log.warn(`Daily report: unable to read ${this.stateFile}: ${e.message}`);
187
+ }
188
+ return {};
189
+ }
190
+
191
+ writeState(state) {
192
+ try {
193
+ fs.mkdirSync(path.dirname(this.stateFile), {recursive: true});
194
+ fs.writeFileSync(this.stateFile, JSON.stringify(state, null, 2));
195
+ } catch (e) {
196
+ this.log.error(`Daily report: unable to write ${this.stateFile}: ${e.message}`);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Build and send the report covering the time since the last one (or the
202
+ * last day, for a first report). Never throws.
203
+ */
204
+ async run(now = Date.now()) {
205
+ if (this.running) {
206
+ return false;
207
+ }
208
+ this.running = true;
209
+ try {
210
+ const state = this.readState();
211
+ const since = state.sentAt || (now - DAY_MS);
212
+ if (this.stats) {
213
+ this.stats.task(TASK_NAME, 'Building');
214
+ if (typeof this.stats.flushNow === 'function') {
215
+ // so the modules' counts run right up to now
216
+ this.stats.flushNow(now);
217
+ }
218
+ }
219
+ const text = await this.build(since, now);
220
+ await this.mailer.send({to: this.config.to, subject: this.subject(), text: text});
221
+ this.writeState({sentAt: now, date: localDate(now)});
222
+ if (this.stats) {
223
+ this.stats.taskDone(TASK_NAME, `Sent ${localDateTime(now)}`);
224
+ }
225
+ return true;
226
+ } catch (e) {
227
+ this.log.error(`Daily report: not sent: ${e.message}`);
228
+ if (this.stats) {
229
+ this.stats.taskError(TASK_NAME, `Not sent: ${e.message}`);
230
+ }
231
+ return false;
232
+ } finally {
233
+ this.running = false;
234
+ }
235
+ }
236
+
237
+ subject() {
238
+ return `${this.serverId} daily report`;
239
+ }
240
+
241
+ /**
242
+ * The report as markdown, for the period since .. now.
243
+ */
244
+ async build(since, now = Date.now()) {
245
+ const parts = [];
246
+ parts.push(`# ${this.serverId} daily report`);
247
+ parts.push(`${localDateTime(now)}, covering the ${formatSpan(now - since)} since ${localDateTime(since)}.`);
248
+ parts.push('## Stability');
249
+ parts.push(this.stabilityParagraph(since, now));
250
+ for (const [name, module] of Object.entries(this.modules)) {
251
+ const text = await this.moduleParagraph(name, module, since, now);
252
+ if (text) {
253
+ parts.push(`## ${MODULE_TITLES[name] || name}`);
254
+ parts.push(text);
255
+ }
256
+ }
257
+ parts.push('## System');
258
+ parts.push(this.systemParagraph(now));
259
+ const tasks = this.tasksParagraph(now);
260
+ if (tasks) {
261
+ parts.push('## Background tasks');
262
+ parts.push(tasks);
263
+ }
264
+ return parts.join('\n\n') + '\n';
265
+ }
266
+
267
+ /**
268
+ * How much of the period the server was running, restarts, and upgrades -
269
+ * from the run history. Each run counts from its start to its last
270
+ * heartbeat (every minute), so a crash shows as down from about when it
271
+ * happened.
272
+ */
273
+ stabilityParagraph(since, now) {
274
+ const runs = this.stats ? this.stats.runHistory(since, now) : null;
275
+ if (!runs) {
276
+ return 'No run history is available (the statistics database is not in use), so the uptime over the period is unknown.';
277
+ }
278
+ const period = now - since;
279
+ let running = 0;
280
+ const restarts = [];
281
+ const upgrades = [];
282
+ let previous = null;
283
+ for (const run of runs) {
284
+ const from = Math.max(run.started, since);
285
+ const to = Math.min(run.lastSeen, now);
286
+ if (to > from) {
287
+ running += to - from;
288
+ }
289
+ if (run.started >= since) {
290
+ restarts.push(run);
291
+ }
292
+ if (previous && run.version && previous.version && run.version !== previous.version) {
293
+ upgrades.push({from: previous.version, to: run.version, at: run.started});
294
+ }
295
+ previous = run;
296
+ }
297
+ const percent = period > 0 ? Math.min(100, (running * 100) / period) : 100;
298
+ const pct = percent >= 100 ? '100' : (Math.floor(percent * 10) / 10).toFixed(1);
299
+ const sentences = [];
300
+ sentences.push(`${this.serverId} was up for ${formatSpan(running)} of the ${formatSpan(now - since)} since the last report (${pct}%).`);
301
+ if (restarts.length === 0) {
302
+ sentences.push('It ran without interruption.');
303
+ } else {
304
+ const when = restarts.map(r => localDateTime(r.started)).join(', ');
305
+ sentences.push(`It ${restarts.length === 1 ? 'started' : 'started ' + plural(restarts.length, 'time')} during the period (${when}).`);
306
+ }
307
+ // the gap before a restart is the downtime; name the worst one if there is one
308
+ let worst = null;
309
+ for (let i = 1; i < runs.length; i++) {
310
+ const gapStart = Math.max(runs[i - 1].lastSeen, since);
311
+ const gap = runs[i].started - gapStart;
312
+ if (gap > 0 && (!worst || gap > worst.length)) {
313
+ worst = {length: gap, at: gapStart};
314
+ }
315
+ }
316
+ if (worst && worst.length >= 60 * 1000) {
317
+ sentences.push(`The longest outage was ${formatSpan(worst.length)}, from ${localDateTime(worst.at)}.`);
318
+ }
319
+ for (const u of upgrades) {
320
+ sentences.push(`It was upgraded from v${u.from} to v${u.to} at ${localDateTime(u.at)}.`);
321
+ }
322
+ if (upgrades.length === 0) {
323
+ sentences.push(`It was not upgraded (v${this.version}).`);
324
+ }
325
+ return sentences.join(' ');
326
+ }
327
+
328
+ /**
329
+ * A module's paragraph, if it has one. A module that throws gets a line
330
+ * saying so rather than taking the report down.
331
+ */
332
+ async moduleParagraph(name, module, since, now) {
333
+ if (!module || typeof module.dailyReport !== 'function') {
334
+ return null;
335
+ }
336
+ try {
337
+ const text = await module.dailyReport({since: since, now: now});
338
+ return typeof text === 'string' && text.trim() ? text.trim() : null;
339
+ } catch (e) {
340
+ this.log.error(`Daily report: the ${name} module's report failed: ${e.message}`);
341
+ return `The ${name} module's report failed: ${e.message}`;
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Version, platform, how long this run has been up, and the log files and
347
+ * the disk they're on.
348
+ */
349
+ systemParagraph(now) {
350
+ const sentences = [];
351
+ sentences.push(`${this.serverId} is running FHIRsmith v${this.version} on Node.js ${process.version} (${os.type()} ${os.release()}, ${os.arch()}).`);
352
+ if (this.stats && typeof this.stats.startTime === 'number') {
353
+ let up = `This run has been up for ${formatSpan(now - this.stats.startTime)}, since ${localDateTime(this.stats.startTime)}`;
354
+ const overall = typeof this.stats.overallUptime === 'function' ? this.stats.overallUptime(now) : null;
355
+ if (overall) {
356
+ const pct = overall.percent >= 100 ? '100' : (Math.floor(overall.percent * 10) / 10).toFixed(1);
357
+ up += `; over all its recorded runs (since ${localDate(overall.since)}) the server has been up ${pct}% of the time`;
358
+ }
359
+ sentences.push(up + '.');
360
+ }
361
+ sentences.push(this.logsSentence());
362
+ if (this.npmAudit && typeof this.npmAudit.reportText === 'function') {
363
+ const audit = this.npmAudit.reportText();
364
+ if (audit) {
365
+ sentences.push(audit);
366
+ }
367
+ }
368
+ return sentences.join(' ');
369
+ }
370
+
371
+ /**
372
+ * The background tasks whose last run failed, if any - one line each. The
373
+ * npm audit has its own sentence in the system paragraph, and this report's
374
+ * own task is never in a failed state while it's being written.
375
+ */
376
+ tasksParagraph(now) {
377
+ if (!this.stats || typeof this.stats.failedTasks !== 'function') {
378
+ return null;
379
+ }
380
+ const failed = this.stats.failedTasks().filter(t => t.name !== 'npm audit' && t.name !== TASK_NAME);
381
+ if (failed.length === 0) {
382
+ return null;
383
+ }
384
+ const lines = failed.map(t => {
385
+ const when = t.date ? ` (${formatSpan(now - t.date)} ago)` : '';
386
+ return `- ${t.name}: the last run failed${when}: ${t.state}`;
387
+ });
388
+ return `${plural(failed.length, 'background task')} ${failed.length === 1 ? 'is' : 'are'} in a failed state:\n\n${lines.join('\n')}`;
389
+ }
390
+
391
+ logsSentence() {
392
+ const logDir = folders.logsDir();
393
+ try {
394
+ const files = fs.readdirSync(logDir).filter(f => f.endsWith('.log'));
395
+ let total = 0;
396
+ for (const f of files) {
397
+ total += fs.statSync(path.join(logDir, f)).size;
398
+ }
399
+ let disk = '';
400
+ try {
401
+ const fstats = fs.statfsSync(logDir);
402
+ const free = fstats.bavail * fstats.bsize;
403
+ const size = fstats.blocks * fstats.bsize;
404
+ const freePct = size > 0 ? Math.round((free * 100) / size) : 0;
405
+ disk = `, on a disk with ${formatBytes(free)} free of ${formatBytes(size)} (${freePct}% free)`;
406
+ } catch {
407
+ disk = ', on a disk whose free space could not be read';
408
+ }
409
+ return `The logs folder (${logDir}) holds ${plural(files.length, 'log file')} totalling ${formatBytes(total)}${disk}.`;
410
+ } catch (e) {
411
+ return `The logs folder (${logDir}) could not be read: ${e.message}.`;
412
+ }
413
+ }
414
+ }
415
+
416
+ module.exports = DailyReport;
417
+ module.exports.nextMidnight = nextMidnight;
418
+ module.exports.localDate = localDate;
419
+ module.exports.formatSpan = formatSpan;
420
+ // for modules writing their paragraph: "3 packages", "1 package"
421
+ module.exports.plural = plural;
422
+ module.exports.localDateTime = localDateTime;
@@ -0,0 +1,138 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * The value-level parts of FHIR search that every module with a search implements the
9
+ * same way: date ranges, the comparison prefixes, comma-separated values, and LIKE
10
+ * escaping. The SQL that uses them is each module's own (testing/search.js,
11
+ * ips/search.js).
12
+ *
13
+ * @module library/fhir-search-values
14
+ */
15
+
16
+ const PREFIXES = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'sa', 'eb', 'ap'];
17
+
18
+ const DATE_RE = /^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/;
19
+
20
+ /**
21
+ * The range of instants a FHIR date, dateTime or instant covers, as [lo, hi) in epoch
22
+ * milliseconds. "2026" covers the whole year; "2026-09-24T10:00:00.5Z" covers 100ms.
23
+ * A time with no zone is taken as UTC (FHIR requires a zone, but search values often
24
+ * leave it off).
25
+ *
26
+ * @param {string} value
27
+ * @returns {{lo: number, hi: number}|null} null if the value isn't a date
28
+ */
29
+ function dateRange(value) {
30
+ if (typeof value !== 'string') {
31
+ return null;
32
+ }
33
+ const m = DATE_RE.exec(value.trim());
34
+ if (!m) {
35
+ return null;
36
+ }
37
+ const [, y, mo, d, h, mi, s, frac, tz] = m;
38
+ const year = parseInt(y, 10);
39
+ if (!mo) {
40
+ return { lo: Date.UTC(year, 0, 1), hi: Date.UTC(year + 1, 0, 1) };
41
+ }
42
+ const month = parseInt(mo, 10) - 1;
43
+ if (month < 0 || month > 11) {
44
+ return null;
45
+ }
46
+ if (!d) {
47
+ return { lo: Date.UTC(year, month, 1), hi: Date.UTC(year, month + 1, 1) };
48
+ }
49
+ const day = parseInt(d, 10);
50
+ if (day < 1 || day > new Date(Date.UTC(year, month + 1, 0)).getUTCDate()) {
51
+ return null;
52
+ }
53
+ if (!h) {
54
+ return { lo: Date.UTC(year, month, day), hi: Date.UTC(year, month, day + 1) };
55
+ }
56
+ const zone = tz || 'Z';
57
+ const text = `${y}-${mo}-${d}T${h}:${mi}:${s || '00'}${frac || ''}${zone}`;
58
+ const lo = Date.parse(text);
59
+ if (Number.isNaN(lo) || parseInt(h, 10) > 23 || parseInt(mi, 10) > 59 || (s && parseInt(s, 10) > 59)) {
60
+ return null;
61
+ }
62
+ let width;
63
+ if (!s) {
64
+ width = 60000;
65
+ } else if (!frac) {
66
+ width = 1000;
67
+ } else {
68
+ // .5 is 100ms wide, .50 10ms, .500 and beyond 1ms
69
+ width = Math.max(1, Math.pow(10, 3 - (frac.length - 1)));
70
+ }
71
+ return { lo, hi: lo + width };
72
+ }
73
+
74
+ /**
75
+ * Take the comparison prefix (eq, ge, ...) off a date or number search value.
76
+ * @returns {{prefix: string, rest: string}} prefix is 'eq' when there is none
77
+ */
78
+ function splitPrefix(value) {
79
+ const p = value.substring(0, 2);
80
+ if (PREFIXES.includes(p) && value.length > 2 && !/^[a-z]/.test(value.substring(2))) {
81
+ return { prefix: p, rest: value.substring(2) };
82
+ }
83
+ return { prefix: 'eq', rest: value };
84
+ }
85
+
86
+ /** Escape a value for use in a LIKE pattern with ESCAPE '\'. */
87
+ function escapeLike(value) {
88
+ return value.replace(/[\\%_]/g, (c) => '\\' + c);
89
+ }
90
+
91
+ /**
92
+ * Split a parameter value on commas, honouring \, as an escaped comma.
93
+ */
94
+ function splitValues(value) {
95
+ const out = [];
96
+ let cur = '';
97
+ for (let i = 0; i < value.length; i++) {
98
+ const c = value[i];
99
+ if (c === '\\' && i + 1 < value.length) {
100
+ cur += value[i + 1];
101
+ i++;
102
+ } else if (c === ',') {
103
+ out.push(cur);
104
+ cur = '';
105
+ } else {
106
+ cur += c;
107
+ }
108
+ }
109
+ out.push(cur);
110
+ return out.filter(v => v.length > 0);
111
+ }
112
+
113
+ /**
114
+ * SQL comparing a stored [lo, hi) range (two column expressions) against a search
115
+ * range, for a comparison prefix.
116
+ *
117
+ * @param {{lo: string, hi: string}} cols - column expressions
118
+ * @param {string} prefix
119
+ * @param {{lo: number, hi: number}} range
120
+ * @returns {{sql: string, args: number[]}|null}
121
+ */
122
+ function dateClause(cols, prefix, range) {
123
+ const { lo, hi } = cols;
124
+ switch (prefix) {
125
+ case 'eq': return { sql: `(${lo} >= ? AND ${hi} <= ?)`, args: [range.lo, range.hi] };
126
+ case 'ne': return { sql: `NOT (${lo} >= ? AND ${hi} <= ?)`, args: [range.lo, range.hi] };
127
+ case 'gt': return { sql: `${hi} > ?`, args: [range.hi] };
128
+ case 'lt': return { sql: `${lo} < ?`, args: [range.lo] };
129
+ case 'ge': return { sql: `${hi} > ?`, args: [range.lo] };
130
+ case 'le': return { sql: `${lo} < ?`, args: [range.hi] };
131
+ case 'sa': return { sql: `${lo} >= ?`, args: [range.hi] };
132
+ case 'eb': return { sql: `${hi} <= ?`, args: [range.lo] };
133
+ case 'ap': return { sql: `(${lo} < ? AND ${hi} > ?)`, args: [range.hi, range.lo] };
134
+ }
135
+ return null;
136
+ }
137
+
138
+ module.exports = { PREFIXES, dateRange, splitPrefix, escapeLike, splitValues, dateClause };
@@ -68,37 +68,46 @@ class HtmlServer {
68
68
  ...options
69
69
  };
70
70
 
71
- // Perform template replacements
72
- let html = template
73
- .replace(/\[%title%\]/g, escape(title))
74
- .replace(/\[%content%\]/g, content) // Content is assumed to be already-safe HTML
71
+ // The placeholders, and what each becomes. Everything is substituted in one pass over
72
+ // the template, so what goes in - [%content%] above all, which holds what users wrote
73
+ // (document narratives, say) - is never itself searched for placeholders: a
74
+ // "[%context-link%]" inside content stays as it is, rather than becoming raw HTML in the
75
+ // middle of an attribute
76
+ const values = new Map([
77
+ ['title', () => escape(title)],
78
+ ['content', () => content], // Content is assumed to be already-safe HTML
75
79
  // [%ver%] is the FHIRsmith version in every template (it follows the FHIRsmith link)
76
- .replace(/\[%ver%\]/g, escape(packageJson.version))
77
- .replace(/\[%download-date%\]/g, escape(renderOptions.downloadDate))
80
+ ['ver', () => escape(packageJson.version)],
81
+ ['download-date', () => escape(renderOptions.downloadDate)],
78
82
  // "last updated 35 minutes ago" / "not yet updated", for the crawler-driven modules
79
- .replace(/\[%crawler-status%\]/g, escape(renderOptions.crawlerStatus || 'not yet updated'))
80
- .replace(/\[%total-resources%\]/g, escape(renderOptions.totalResources.toLocaleString()))
81
- .replace(/\[%total-packages%\]/g, escape(renderOptions.totalPackages.toLocaleString()))
82
- .replace(/\[%endpoint-path%\]/g, escape(renderOptions.endpointpath))
83
- .replace(/\[%fhir-version%\]/g, escape(renderOptions.fhirversion))
84
- .replace(/\[%ms%\]/g, escape(renderOptions.processingTime.toString()))
85
- .replace(/\[%sponsorMessage%\]/g, sponsorMessage)
86
- .replace(/\[%about%\]/g, renderOptions.about || '')
83
+ ['crawler-status', () => escape(renderOptions.crawlerStatus || 'not yet updated')],
84
+ ['total-resources', () => escape(renderOptions.totalResources.toLocaleString())],
85
+ ['total-packages', () => escape(renderOptions.totalPackages.toLocaleString())],
86
+ ['endpoint-path', () => escape(renderOptions.endpointpath)],
87
+ ['fhir-version', () => escape(renderOptions.fhirversion)],
88
+ ['ms', () => escape(renderOptions.processingTime.toString())],
89
+ ['sponsorMessage', () => sponsorMessage],
90
+ ['about', () => renderOptions.about || ''],
87
91
  // raw HTML, like [%about%]: the caller decides whether the nav item exists at all
88
- .replace(/\[%library-link%\]/g, renderOptions.libraryLink || '')
92
+ ['library-link', () => renderOptions.libraryLink || ''],
89
93
  // raw HTML too: the OpenAPI description, where a template's module has one in some places only
90
- .replace(/\[%api-link%\]/g, renderOptions.apiLink || '')
91
- .replace(/\[%api-head%\]/g, renderOptions.apiHead || '');
92
-
93
- // Handle any custom template variables
94
+ ['api-link', () => renderOptions.apiLink || ''],
95
+ // raw HTML: a link to the current project (or other context) in the nav bar, where the
96
+ // module has one
97
+ ['context-link', () => renderOptions.contextLink || ''],
98
+ ['api-head', () => renderOptions.apiHead || '']
99
+ ]);
100
+ // a module's own template variables, escaped when they are strings; the standard ones win
94
101
  if (options.templateVars) {
95
102
  for (const [key, value] of Object.entries(options.templateVars)) {
96
- const placeholder = `[%${key}%]`;
97
- const escapedValue = typeof value === 'string' ? escape(value) : String(value);
98
- html = html.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), escapedValue);
103
+ if (!values.has(key)) {
104
+ values.set(key, () => (typeof value === 'string' ? escape(value) : String(value)));
105
+ }
99
106
  }
100
107
  }
101
-
108
+ // a placeholder that isn't known is left as it is, as before
109
+ const html = template.replace(/\[%([^%\]\s]{1,64})%\]/g, (m, name) => (values.has(name) ? values.get(name)() : m));
110
+
102
111
  return html;
103
112
  }
104
113
 
package/library/html.js CHANGED
@@ -1,6 +1,12 @@
1
1
  const {validateParameter, validateOptionalParameter} = require("./utilities");
2
2
  const escape = require('escape-html');
3
3
 
4
+ // Attributes that take a URL, and the schemes that make a URL run script. Browsers ignore
5
+ // leading whitespace and control characters, and embedded tabs and newlines, in the scheme.
6
+ const URL_ATTRIBUTES = new Set(['href', 'src', 'action', 'formaction', 'xlink:href']);
7
+ // eslint-disable-next-line no-control-regex -- control characters are exactly what browsers skip
8
+ const UNSAFE_URL = /^[\s\x00-\x1f]*(j[\s]*a[\s]*v[\s]*a[\s]*s[\s]*c[\s]*r[\s]*i[\s]*p[\s]*t|v[\s]*b[\s]*s[\s]*c[\s]*r[\s]*i[\s]*p[\s]*t|d[\s]*a[\s]*t[\s]*a)[\s]*:/i;
9
+
4
10
  const NodeType = {
5
11
  Document: 'Document',
6
12
  Element: 'Element',
@@ -25,6 +31,9 @@ class XhtmlNode {
25
31
  // Attribute methods
26
32
  setAttribute(name, value) {
27
33
  if (value != null) {
34
+ if (URL_ATTRIBUTES.has(String(name).toLowerCase()) && UNSAFE_URL.test(String(value))) {
35
+ value = '#'; // a link that runs script (javascript:, data:, vbscript:) - from resource content
36
+ }
28
37
  this.attributes.set(name, value);
29
38
  }
30
39
  return this;