fhirsmith 0.13.1 → 0.13.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [v0.13.2] - 2026-09-05
9
+
10
+ ### Added
11
+
12
+ - Add support for publishLibrarySource
13
+ -
14
+ ### Fixed
15
+
16
+ - Fix error in Snomed CT and ICD-11 where normal form difference and case difference being conflated
17
+ - Fix error in mime-type subsumption analysis
18
+ - adjust mime type validation to not be case sensitive
19
+
20
+ ### Tx Conformance Statement
21
+
22
+ FHIRsmith passed all 3507 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11, tests v1.9.5-SNAPSHOT, runner v6.10.4)
23
+
8
24
  ## [v0.13.1] - 2026-09-05
9
25
 
10
26
  ### Fixed
@@ -150,6 +150,11 @@
150
150
  "tx": {
151
151
  "enabled": false,
152
152
  "librarySource": "/absolute/path/to/library.yml",
153
+ // publish the library source above at <endpoint>/library, so users can see what this
154
+ // server actually loads. One URL, content negotiated: a browser gets a formatted page
155
+ // (and a "Library" item in the tx nav bar), anything else gets the YAML file itself.
156
+ // Off by default - the file names every database, cache and package the server runs.
157
+ "publishLibrarySource": false,
153
158
  "cacheTimeout": 30,
154
159
  "expansionCacheSize": 1000,
155
160
  "expansionCacheMemoryThreshold": 0,
@@ -80,7 +80,9 @@ class HtmlServer {
80
80
  .replace(/\[%fhir-version%\]/g, escape(renderOptions.fhirversion))
81
81
  .replace(/\[%ms%\]/g, escape(renderOptions.processingTime.toString()))
82
82
  .replace(/\[%sponsorMessage%\]/g, sponsorMessage)
83
- .replace(/\[%about%\]/g, renderOptions.about || '');
83
+ .replace(/\[%about%\]/g, renderOptions.about || '')
84
+ // raw HTML, like [%about%]: the caller decides whether the nav item exists at all
85
+ .replace(/\[%library-link%\]/g, renderOptions.libraryLink || '');
84
86
 
85
87
  // Handle any custom template variables
86
88
  if (options.templateVars) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.13.1",
4
- "txVersion": "1.9.4",
3
+ "version": "0.13.2",
4
+ "txVersion": "1.9.5-SNAPSHOT",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
7
7
  "engines": {
@@ -97,6 +97,7 @@ CDA_UNKNOWN_TEMPLATE_EXT = The CDA Template {0} / {1} is not known
97
97
  CDATA_is_not_allowed = CDATA is not allowed
98
98
  Circular_snapshot_references_detected_cannot_generate_snapshot_stack__ = Circular snapshot references detected; cannot generate snapshot (stack = {0})
99
99
  CODE_CASE_DIFFERENCE = The code ''{0}'' differs from the correct code ''{1}'' by case. Although the code system ''{2}'' is case insensitive, implementers are strongly encouraged to use the correct case anyway
100
+ CODE_NOT_IN_NORMAL_FORM = The code ''{0}'' is valid, but it is not the normal form for the code system ''{2}''; the normal form is ''{1}''
100
101
  Code_found_in_expansion_however_ = Code found in expansion, however: {0}
101
102
  CODESYSTEM_CONCEPT_NO_DEFINITION = HL7 Defined CodeSystems should ensure that every concept has a definition
102
103
  CODESYSTEM_CONCEPT_NO_DISPLAY = HL7 Defined CodeSystems should ensure that every concept has a display
package/tx/README.md CHANGED
@@ -25,6 +25,7 @@ Add the `tx` section to your `config.json`:
25
25
  "tx": {
26
26
  "enabled": true,
27
27
  "librarySource": "/path/to/library.yml",
28
+ "publishLibrarySource": false,
28
29
  "cacheTimeout": 30,
29
30
  "internalLimit" : 10000,
30
31
  "externalLimit" : 1000,
@@ -66,6 +67,7 @@ Add the `tx` section to your `config.json`:
66
67
  | `expansionCacheSize` | integer | No | Maximum number of expanded ValueSets to cache. Default: 1000 |
67
68
  | `expansionCacheMemoryThreshold` | integer | No | Heap memory usage in MB that triggers evicting oldest half of expansion cache. 0 = disabled. Default: 0 |
68
69
  | `librarySource` | string | Yes | Path to the YAML file that defines the terminology sources to load |
70
+ | `publishLibrarySource` | boolean | No | Publish that YAML file at `/{path}/library`. Default: false |
69
71
  | `internalLimit` | integer | No | Largest number of codes in internal expansions |
70
72
  | `externalLimit` | integer | No | Largest number of codes the server will return in an expansion |
71
73
  | `endpoints` | array | Yes | List of endpoint configurations (at least one required) |
@@ -125,6 +127,26 @@ Each endpoint provides the following FHIR terminology operations:
125
127
  Each endpoint also provides:
126
128
  - `GET /{path}/metadata` - Returns a CapabilityStatement describing the endpoint's capabilities
127
129
  - `GET /{path}/` - Returns basic endpoint information
130
+ - `GET /{path}/library` - Returns the library source YAML, if `publishLibrarySource` is set
131
+
132
+ ### Publishing the library source
133
+
134
+ `publishLibrarySource` lets users see what the server actually loads. It is off by default,
135
+ because the library YAML names every database, cache and package the server runs, and not
136
+ every deployment wants that public. Turn it on and each endpoint serves the file at
137
+ `/{path}/library`, and a **Library** item appears in the tx navigation bar.
138
+
139
+ There is one URL, content negotiated the same way the rest of the module negotiates:
140
+
141
+ | Request | Response |
142
+ |---------|----------|
143
+ | `Accept: text/html` (i.e. a browser) | the file, syntax highlighted, in the tx page template |
144
+ | anything else | the file itself, as `application/yaml` |
145
+ | `?_format=html` / `?_format=yaml` | forces either representation |
146
+
147
+ The file is read from disk per request, so editing it is reflected immediately - note that
148
+ this means the published file can be ahead of what the running server actually loaded, if it
149
+ has been edited since startup.
128
150
 
129
151
  ## Library Configuration
130
152
 
@@ -36,6 +36,99 @@ const { Issue } = require("../library/operation-outcome");
36
36
  */
37
37
  const UNDERSTOOD_PARAMETERS = new Set(['charset', 'format', 'delsp', 'version']);
38
38
 
39
+ /**
40
+ * Parameters whose VALUES are case-insensitive.
41
+ *
42
+ * RFC 2045 section 5.1 makes type, subtype and parameter names case-insensitive, and then says
43
+ * of values: "Parameter values are normally case sensitive, but sometimes are interpreted in a
44
+ * case-insensitive fashion, depending on the intended use." So a value is only folded where its
45
+ * own definition says to fold it, and this is the list of those we know of:
46
+ *
47
+ * - charset RFC 2046 section 4.1.2: "Unlike some other parameter values, the values of the
48
+ * charset parameter are NOT case sensitive." (RFC 9110 section 8.3.2 agrees.)
49
+ * - format RFC 3676 section 4, of Format and DelSp together: "(Neither the parameter names
50
+ * - delsp nor values are case sensitive.)"
51
+ *
52
+ * `version` is deliberately absent: there is no general definition of a version parameter -
53
+ * it exists only per-registration (text/vcard has one, text/html deliberately removed one) -
54
+ * so there is nothing that says its values fold, and folding them could equate two versions
55
+ * that a registration means to distinguish.
56
+ *
57
+ * @type {Set<string>}
58
+ */
59
+ const CASE_INSENSITIVE_VALUES = new Set(['charset', 'format', 'delsp']);
60
+
61
+ /**
62
+ * Media types whose registration gives a parameter a DEFAULT value.
63
+ *
64
+ * This is the thing that breaks the otherwise tidy rule that a parameter narrows a media type.
65
+ * Where a parameter has a default, the bare form is not the general case - it already carries
66
+ * that value - so `text/plain` does not subsume `text/plain; charset=utf-8`, the two are
67
+ * different things. Applying the default to whichever side omits it, and then comparing as
68
+ * usual, gets this right in both directions: it also makes `text/plain` and
69
+ * `text/plain; charset=us-ascii` come out equivalent, which they are and which we used to
70
+ * get wrong in the other direction.
71
+ *
72
+ * There is no list to load this from. IANA's registry schema has no parameter element at all -
73
+ * a record is a name, a deprecation flag, cross-references and a pointer to a template - and
74
+ * defaults live only in the prose of each defining RFC. `text/plain`, the type that needs this
75
+ * most, has no IANA registration template whatsoever ("No registration template available"),
76
+ * so all of the below comes from the RFCs directly:
77
+ *
78
+ * - text/plain charset: RFC 2046 section 4.1.2 made US-ASCII the default, and RFC 6657
79
+ * section 4 exists specifically to say it stayed: "The default "charset" parameter value
80
+ * for "text/plain" is unchanged from [RFC2046] and remains as "US-ASCII"."
81
+ * - text/plain format and delsp: RFC 3676 section 4, "If Format is not specified, or if the
82
+ * value is not recognized, a value of Fixed is assumed" and "If DelSp is not specified, or
83
+ * if its value is not recognized, a value of No is assumed."
84
+ * - text/vcard charset: RFC 6350 section 3.1, "The charset ... for vCard is UTF-8 ... There is
85
+ * no way to override this." A default expressed as a prohibition, but a default.
86
+ *
87
+ * Keyed by lower-case type/subtype; the inner map is lower-case parameter name to the value
88
+ * the bare form implies. Values are stored folded, matching CASE_INSENSITIVE_VALUES.
89
+ *
90
+ * @type {Map<string, Map<string, string>>}
91
+ */
92
+ const PARAMETER_DEFAULTS = new Map([
93
+ ['text/plain', new Map([['charset', 'us-ascii'], ['format', 'fixed'], ['delsp', 'no']])],
94
+ ['text/vcard', new Map([['charset', 'utf-8']])]
95
+ ]);
96
+
97
+ /**
98
+ * text/* types whose registration says explicitly that charset has NO default.
99
+ *
100
+ * RFC 6657 section 3 left a residual rule behind that is the awkward part of all this:
101
+ * "existing "text/*" registrations that fail to specify how the charset is determined still
102
+ * default to US-ASCII." So for a text/* type, silence in the registration is not the absence
103
+ * of a default - it IS a default, of US-ASCII. That set cannot be enumerated without reading
104
+ * every legacy text/* registration and judging whether it "clearly specifies", so instead of
105
+ * guessing, charset on an unlisted text/* type yields cannot-determine.
106
+ *
107
+ * This set is the escape hatch: text/* types we have actually read, which say there is no
108
+ * default, and for which the narrowing rule therefore holds.
109
+ *
110
+ * - text/markdown: RFC 7763 section 2, "charset: Per Section 4.2.1 of [RFC6838], charset is
111
+ * REQUIRED. There is no default value because neither [MDSYNTAX] nor many popular
112
+ * implementations at the time of this registration do either."
113
+ * - text/html: a judgement rather than a quoted default, and the only entry here that is.
114
+ * Neither registration states one - RFC 2854 section 6 recites the RFC 2046 and RFC 2616
115
+ * rules, observes that they conflict, and only recommends being explicit; the current IANA
116
+ * registration is the W3C one, whose whole optional-parameters text is that charset
117
+ * "may be provided to definitively specify the document's character encoding, overriding
118
+ * any character encoding declarations in the document". HTML determines its encoding in
119
+ * band, and the phrase "overriding any character encoding declarations in the document"
120
+ * only makes sense if the absence of the parameter means "whatever the document says"
121
+ * rather than "US-ASCII". Listed because text/html is the most asked-about media type
122
+ * there is, and declining to answer for it is a poor trade for a defensible reading.
123
+ *
124
+ * Non-text types need no entry: RFC 6657 is a text/* rule, so silence elsewhere really is
125
+ * silence (application/xml, for instance - RFC 7303 section 3.2 tells consumers they "SHOULD
126
+ * NOT assume a default encoding").
127
+ *
128
+ * @type {Set<string>}
129
+ */
130
+ const TEXT_TYPES_WITH_NO_CHARSET_DEFAULT = new Set(['text/markdown', 'text/html']);
131
+
39
132
  // The IANA media types registry. Only the names are kept - the registry is a few MB of
40
133
  // cross-references and templates, and all the `registered` filter asks is whether a type is in it
41
134
  const IANA_MEDIA_TYPES_URL = 'https://www.iana.org/assignments/media-types/media-types.xml';
@@ -46,6 +139,63 @@ class MimeTypeConcept {
46
139
  constructor(code) {
47
140
  this.code = code;
48
141
  this.mimeType = this.#parseMimeType(code);
142
+ this.normalized = this.#normalize(code);
143
+ }
144
+
145
+ /**
146
+ * The canonical spelling of this media type.
147
+ *
148
+ * Four things are canonicalised, and each is something the specs say carries no meaning:
149
+ *
150
+ * - case, in the type, the subtype, every parameter name, and the values of the parameters
151
+ * in CASE_INSENSITIVE_VALUES (RFC 2045 section 5.1, RFC 2046 section 4.1.2, RFC 3676
152
+ * section 4). Every other parameter value keeps its case: RFC 2045 makes values
153
+ * case-sensitive unless their own definition says otherwise, and a boundary or a profile
154
+ * URI can genuinely differ by case
155
+ * - parameter ORDER, which is not significant, so sorting by name gives two spellings of one
156
+ * media type one spelling. Without this, `text/plain; charset=utf-8; format=flowed` and
157
+ * `text/plain; format=flowed; charset=utf-8` would have two different "normal" forms,
158
+ * which is not a normal form at all
159
+ * - the separator, which becomes '; '
160
+ * - surrounding and internal padding
161
+ *
162
+ * Quoting is NOT canonicalised. A quoted value can always be written quoted, so removing the
163
+ * quotes needs to know that the value contains no tspecials, and getting that wrong produces
164
+ * a "canonical" form that is not a legal media type. Left for someone who needs it.
165
+ *
166
+ * Note the consequence for validation: reordering or respacing makes this differ from the
167
+ * submitted code by more than case, so it is reported as a normal-form difference rather
168
+ * than a case difference. That is the honest description of what changed.
169
+ */
170
+ #normalize(code) {
171
+ if (!this.mimeType.isValid) {
172
+ return code;
173
+ }
174
+ const segments = code.split(';');
175
+ const params = [];
176
+ for (const segment of segments.slice(1)) {
177
+ const trimmed = segment.trim();
178
+ if (trimmed === '') {
179
+ continue;
180
+ }
181
+ const eq = trimmed.indexOf('=');
182
+ if (eq === -1) {
183
+ // not a name=value pair. The parser ignores it, but dropping it here would make the
184
+ // canonical form say something the submitted code did not, so it is carried through -
185
+ // untouched, since only parameter NAMES fold and nothing says this is one
186
+ params.push({ name: trimmed.toLowerCase(), text: trimmed });
187
+ continue;
188
+ }
189
+ const name = trimmed.slice(0, eq).trim().toLowerCase();
190
+ const value = trimmed.slice(eq + 1).trim();
191
+ params.push({
192
+ name: name,
193
+ text: name + '=' + (CASE_INSENSITIVE_VALUES.has(name) ? value.toLowerCase() : value)
194
+ });
195
+ }
196
+ // by name, then by the whole parameter, so a repeated name is still ordered predictably
197
+ params.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : (a.text < b.text ? -1 : a.text > b.text ? 1 : 0)));
198
+ return [segments[0].trim().toLowerCase(), ...params.map(p => p.text)].join('; ');
49
199
  }
50
200
 
51
201
  #parseMimeType(code) {
@@ -71,7 +221,7 @@ class MimeTypeConcept {
71
221
  if (value.length > 1 && value.startsWith('"') && value.endsWith('"')) {
72
222
  value = value.slice(1, -1);
73
223
  }
74
- if (name === 'charset') {
224
+ if (CASE_INSENSITIVE_VALUES.has(name)) {
75
225
  value = value.toLowerCase();
76
226
  }
77
227
  if (name) {
@@ -196,7 +346,7 @@ class MimeTypeServices extends CodeSystemProvider {
196
346
  async code(code) {
197
347
 
198
348
  const ctxt = await this.#ensureContext(code);
199
- return ctxt ? ctxt.code : null;
349
+ return ctxt ? ctxt.normalized : null;
200
350
  }
201
351
 
202
352
  async display(code) {
@@ -206,14 +356,15 @@ class MimeTypeServices extends CodeSystemProvider {
206
356
  return null;
207
357
  }
208
358
 
209
- // Check supplements first
210
- const suppDisplay = this._displayFromSupplements(ctxt.code);
359
+ // Check supplements first, by the normalised code - a supplement that designates
360
+ // 'text/plain' has to be found for a request that spells it 'TEXT/Plain'
361
+ const suppDisplay = this._displayFromSupplements(ctxt.normalized);
211
362
  if (suppDisplay) {
212
363
  return suppDisplay;
213
364
  }
214
365
 
215
- // Default display is the code itself, trimmed
216
- return ctxt.code.trim();
366
+ // Default display is the code itself, normalised and trimmed
367
+ return ctxt.normalized.trim();
217
368
  }
218
369
 
219
370
  async definition(code) {
@@ -314,11 +465,27 @@ class MimeTypeServices extends CodeSystemProvider {
314
465
  return 'not-subsumed';
315
466
  }
316
467
 
468
+ // Where the registration gives a parameter a default, the bare form already carries that
469
+ // value, so fill it in on both sides before comparing anything. After this, a parameter
470
+ // that defaults is present on both sides and the narrowing logic below never sees it as
471
+ // "added" - which is the point: adding format=flowed to text/plain does not narrow it, it
472
+ // contradicts the format=fixed the bare form meant all along.
473
+ const key = a.type + '/' + a.subtype;
474
+ const defaults = PARAMETER_DEFAULTS.get(key);
475
+ const aParams = new Map(a.params);
476
+ const bParams = new Map(b.params);
477
+ if (defaults) {
478
+ for (const [name, value] of defaults) {
479
+ if (!aParams.has(name)) aParams.set(name, value);
480
+ if (!bParams.has(name)) bParams.set(name, value);
481
+ }
482
+ }
483
+
317
484
  // Only the parameters the two codes DISAGREE on can decide the answer - one present
318
485
  // on one side only, or present on both with different values. A parameter carried
319
486
  // identically by both, understood or not, cannot affect the result and is ignored.
320
- const deciding = [...new Set([...a.params.keys(), ...b.params.keys()])]
321
- .filter(name => a.params.get(name) !== b.params.get(name));
487
+ const deciding = [...new Set([...aParams.keys(), ...bParams.keys()])]
488
+ .filter(name => aParams.get(name) !== bParams.get(name));
322
489
  const unknown = deciding.filter(name => !UNDERSTOOD_PARAMETERS.has(name));
323
490
  if (unknown.length > 0) {
324
491
  throw cannotDetermineSubsumption(
@@ -331,15 +498,34 @@ class MimeTypeServices extends CodeSystemProvider {
331
498
  + 'that may be wrong');
332
499
  }
333
500
 
501
+ // RFC 6657 section 3: a text/* registration that does not say how the charset is determined
502
+ // still defaults to US-ASCII. So on a text/* type we have not read, we do not know whether
503
+ // the side without a charset means "any charset" or "US-ASCII", and that is exactly the
504
+ // difference between subsumes and not-subsumed. Only bites when charset is present on one
505
+ // side and absent on the other - two explicit values are just two values.
506
+ if (deciding.includes('charset') && a.type === 'text'
507
+ && !(defaults && defaults.has('charset'))
508
+ && !TEXT_TYPES_WITH_NO_CHARSET_DEFAULT.has(key)
509
+ && aParams.has('charset') !== bParams.has('charset')) {
510
+ throw cannotDetermineSubsumption(
511
+ `Unable to determine the subsumption relationship between '${a.source}' and `
512
+ + `'${b.source}': one carries a charset and the other does not, and this server does `
513
+ + `not know whether '${key}' gives charset a default value. RFC 6657 leaves every `
514
+ + 'text/* registration that does not say how the charset is determined defaulting to '
515
+ + 'US-ASCII, so the absence of a charset may mean "any charset" or may mean "US-ASCII", '
516
+ + 'and those give opposite answers. No outcome is returned rather than one that may be '
517
+ + 'wrong');
518
+ }
519
+
334
520
  // Any parameter they both carry has to agree, or neither includes the other
335
- for (const [name, value] of a.params) {
336
- if (b.params.has(name) && b.params.get(name) !== value) {
521
+ for (const [name, value] of aParams) {
522
+ if (bParams.has(name) && bParams.get(name) !== value) {
337
523
  return 'not-subsumed';
338
524
  }
339
525
  }
340
526
 
341
- const aInB = [...a.params.keys()].every(name => b.params.has(name));
342
- const bInA = [...b.params.keys()].every(name => a.params.has(name));
527
+ const aInB = [...aParams.keys()].every(name => bParams.has(name));
528
+ const bInA = [...bParams.keys()].every(name => aParams.has(name));
343
529
 
344
530
  if (aInB && bInA) {
345
531
  return 'equivalent';
@@ -90,6 +90,7 @@
90
90
  <a href="[%endpoint-path%]/metadata?mode=terminology" style="color: gold">Terminology Capabilities</a> &nbsp;|&nbsp;
91
91
  <a href="[%endpoint-path%]/op.html" style="color: gold">Operations</a> &nbsp;|&nbsp;
92
92
  <a href="[%endpoint-path%]/problems.html" style="color: gold">Problems</a> &nbsp;|&nbsp;
93
+ [%library-link%]
93
94
  &nbsp;
94
95
  </div>
95
96
  </div>
@@ -537,18 +537,40 @@ class SnomedExpressionParser {
537
537
 
538
538
  /**
539
539
  * Parse refinements for an expression
540
+ *
541
+ * The compositional grammar ABNF is
542
+ * refinement = (attributeSet / attributeGroup) *( ws ["," ws] attributeGroup )
543
+ * attributeSet = attribute *(ws "," ws attribute)
544
+ * so a comma is required between the ungrouped attributes of the attribute set, but is
545
+ * OPTIONAL before an attribute group. Requiring it everywhere rejected the minimal
546
+ * rendering this parser's own renderer produces (e.g. 249943000:260868000=6934004{...}).
547
+ *
548
+ * We stay liberal in what we accept: a group may also be followed by further ungrouped
549
+ * attributes, which the ABNF does not allow but which this parser has always taken.
540
550
  */
541
551
  refinements(expr) {
542
- let next = true;
543
- while (next) {
544
- if (this.peek() !== '{') {
545
- expr.refinements.push(this.attribute());
546
- } else {
552
+ this.ws();
553
+
554
+ let first = true;
555
+ for (;;) {
556
+ if (!first) {
557
+ const mark = this.cursor;
558
+ const comma = this.gchar(',');
559
+ this.ws();
560
+ if (this.peek() !== '{' && !comma) {
561
+ // neither a separating comma nor a group: the refinements are finished
562
+ this.cursor = mark;
563
+ break;
564
+ }
565
+ }
566
+
567
+ if (this.peek() === '{') {
547
568
  expr.refinementGroups.push(this.attributeGroup());
569
+ } else {
570
+ expr.refinements.push(this.attribute());
548
571
  }
549
572
  this.ws();
550
- next = this.gchar(',');
551
- this.ws();
573
+ first = false;
552
574
  }
553
575
  }
554
576
 
@@ -2444,10 +2466,13 @@ class SnomedExpressionServices {
2444
2466
  }
2445
2467
  }
2446
2468
 
2447
- // Grouped refinements
2469
+ // Grouped refinements. The comma between the attribute set and the first group is
2470
+ // optional in the grammar, but we emit it: it is what makes the boundary between the
2471
+ // ungrouped attributes and the first group legible, and it is the safer of the two
2472
+ // spellings to hand to a third-party parser.
2448
2473
  if (expr.hasRefinementGroups()) {
2449
2474
  for (let j = 0; j < expr.refinementGroups.length; j++) {
2450
- if (j > 0) parts.push(',');
2475
+ if (j > 0 || expr.hasRefinements()) parts.push(',');
2451
2476
  parts.push('{');
2452
2477
 
2453
2478
  for (let i = 0; i < expr.refinementGroups[j].refinements.length; i++) {
package/tx/tx-html.js CHANGED
@@ -86,6 +86,99 @@ function acceptsHtml(req) {
86
86
  return _fmt.includes('text/html');
87
87
  }
88
88
 
89
+ // Whether this server publishes its library source YAML. Off unless the operator turns
90
+ // modules.tx.publishLibrarySource on, and it gates both the route and the nav item, so a
91
+ // server that does not publish it has no link to a 404.
92
+ let publishLibrarySource = false;
93
+
94
+ function setPublishLibrarySource(enabled) {
95
+ publishLibrarySource = enabled === true;
96
+ }
97
+
98
+ function publishesLibrarySource() {
99
+ return publishLibrarySource;
100
+ }
101
+
102
+ /**
103
+ * Highlight a YAML document for display.
104
+ *
105
+ * Deliberately line-oriented and modest: this is the operator's own source file, not a
106
+ * general YAML parser, and it has to survive anything a hand-edited file contains without
107
+ * ever emitting the file's bytes unescaped. Each line is split into its parts FIRST and
108
+ * every part is escaped before any markup goes near it, so no amount of angle brackets or
109
+ * ampersands in a value can break out.
110
+ */
111
+ function highlightYaml(source) {
112
+ const lines = String(source).replace(/\r\n/g, '\n').split('\n');
113
+ const out = lines.map(line => {
114
+ if (line.trim() === '') {
115
+ return '';
116
+ }
117
+ // a whole-line comment
118
+ const wholeComment = /^(\s*)(#.*)$/.exec(line);
119
+ if (wholeComment) {
120
+ return escape(wholeComment[1]) + '<span class="y-c">' + escape(wholeComment[2]) + '</span>';
121
+ }
122
+ // split off a trailing comment, but only one introduced by whitespace-then-#, so a
123
+ // '#' inside a value (a FHIR fragment URL, say) stays part of the value
124
+ let body = line;
125
+ let comment = '';
126
+ const trailing = /^(.*?)(\s+#.*)$/.exec(line);
127
+ if (trailing) {
128
+ body = trailing[1];
129
+ comment = '<span class="y-c">' + escape(trailing[2]) + '</span>';
130
+ }
131
+
132
+ // a mapping key needs whitespace (or end of line) after the colon - without it the
133
+ // colon is just part of a scalar, which is exactly what a source line like
134
+ // '- internal:lang' or '- snomed!:sct_intl_20250201.cache' is
135
+ const m = /^(\s*)(-\s+)?([A-Za-z0-9_.$-]+)(:)(\s+|$)(.*)$/.exec(body);
136
+ if (m) {
137
+ return escape(m[1])
138
+ + (m[2] ? '<span class="y-d">' + escape(m[2]) + '</span>' : '')
139
+ + '<span class="y-k">' + escape(m[3]) + '</span>'
140
+ + '<span class="y-p">' + escape(m[4]) + '</span>'
141
+ + escape(m[5])
142
+ + (m[6] ? '<span class="y-v">' + escape(m[6]) + '</span>' : '')
143
+ + comment;
144
+ }
145
+ const item = /^(\s*)(-\s+)(.*)$/.exec(body);
146
+ if (item) {
147
+ return escape(item[1])
148
+ + '<span class="y-d">' + escape(item[2]) + '</span>'
149
+ + '<span class="y-v">' + escape(item[3]) + '</span>'
150
+ + comment;
151
+ }
152
+ return escape(body) + comment;
153
+ });
154
+ return out.join('\n');
155
+ }
156
+
157
+ const YAML_STYLE = '<style>' +
158
+ '.yaml-source { background: #f7f7f7; border: 1px solid #ddd; padding: 12px; overflow-x: auto; ' +
159
+ 'font-family: Menlo, Consolas, monospace; font-size: 90%; line-height: 1.45; }' +
160
+ '.yaml-source .y-k { color: #0b5394; font-weight: bold; }' +
161
+ '.yaml-source .y-v { color: #444; }' +
162
+ '.yaml-source .y-p, .yaml-source .y-d { color: #999; }' +
163
+ '.yaml-source .y-c { color: #777; font-style: italic; }' +
164
+ '</style>';
165
+
166
+ /**
167
+ * The page body for the library source: what the file is, where to get the raw bytes, and
168
+ * the file itself.
169
+ */
170
+ function buildLibrarySourcePage(source, filename, endpointPath) {
171
+ return YAML_STYLE
172
+ + '<p>This is the library source this server loads its content from - the code system files, '
173
+ + 'the packages, and the terminology sources it makes available. It is shown here as it is on '
174
+ + 'disk.</p>'
175
+ + '<p>The same URL returns the file itself to anything that does not ask for HTML: '
176
+ + '<code>curl -H "Accept: application/yaml" ' + escape(endpointPath) + '/library</code>, or '
177
+ + '<a href="library?_format=yaml">' + escape(endpointPath) + '/library?_format=yaml</a>.</p>'
178
+ + '<p>Source file: <code>' + escape(filename) + '</code></p>'
179
+ + '<pre class="yaml-source">' + highlightYaml(source) + '</pre>';
180
+ }
181
+
89
182
 
90
183
  class TxHtmlRenderer {
91
184
  renderer;
@@ -110,7 +203,10 @@ class TxHtmlRenderer {
110
203
  version: packageJson.version,
111
204
  endpointpath: endpoint.path,
112
205
  fhirversion: endpoint.fhirVersion,
113
- ms: Date.now() - startTime
206
+ ms: Date.now() - startTime,
207
+ libraryLink: publishLibrarySource
208
+ ? '<a href="' + escape(endpoint.path) + '/library" style="color: gold">Library</a> &nbsp;|&nbsp;'
209
+ : ''
114
210
  };
115
211
 
116
212
  return htmlServer.renderPage('tx', title, content, options);
@@ -244,7 +340,14 @@ class TxHtmlRenderer {
244
340
  html += await this.buildSearchForm(req);
245
341
 
246
342
  // ===== Packages and Factories Section =====
247
- html += '<hr/><h3>Source Content</h3>';
343
+ // What follows is the loaded content; the library YAML is where it came from, so when
344
+ // the operator publishes it, link it from the heading over the list it produced.
345
+ html += '<hr/><h3>Source Content';
346
+ if (publishLibrarySource) {
347
+ html += ` <a href="${escape(this.path)}/library" style="font-size: 60%; font-weight: normal;"` +
348
+ ' title="The library source this server loaded this content from">source</a>';
349
+ }
350
+ html += '</h3>';
248
351
 
249
352
  // List Packages
250
353
  html += '<h6>FHIR Packages</h6>';
@@ -1371,5 +1474,6 @@ class TxHtmlRenderer {
1371
1474
  }
1372
1475
 
1373
1476
  module.exports = {
1374
- TxHtmlRenderer, loadTemplate, acceptsHtml
1477
+ TxHtmlRenderer, loadTemplate, acceptsHtml,
1478
+ setPublishLibrarySource, publishesLibrarySource, highlightYaml, buildLibrarySourcePage
1375
1479
  };
package/tx/tx.js CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  const express = require('express');
9
9
  const path = require('path');
10
+ const fs = require('fs');
10
11
  const Logger = require('../library/logger');
11
12
  const { Library } = require('./library');
12
13
  const { OperationContext, ResourceCache, ExpansionCache, debugLog} = require('./operation-context');
@@ -149,6 +150,10 @@ class TXModule {
149
150
  // Load HTML template
150
151
  txHtml.loadTemplate();
151
152
 
153
+ // Off unless the operator opts in. This gates the nav item as well as the route, so a
154
+ // server that does not publish its library source shows no link to it.
155
+ txHtml.setPublishLibrarySource(config.publishLibrarySource === true);
156
+
152
157
  // Validate config
153
158
  if (!config.librarySource) {
154
159
  throw new Error('TX module requires librarySource configuration');
@@ -956,6 +961,45 @@ class TXModule {
956
961
  }
957
962
  });
958
963
 
964
+ // The library source YAML - the file this server loaded its content from.
965
+ //
966
+ // Only registered when the operator turns modules.tx.publishLibrarySource on: the file
967
+ // names every database, cache and package the server runs, which some deployments are
968
+ // happy to publish and others are not, so it is opt-in rather than opt-out.
969
+ //
970
+ // One URL, negotiated: a browser (Accept: text/html) gets the highlighted page, anything
971
+ // else gets the file itself. ?_format=html and ?_format=yaml force either way, through the
972
+ // same acceptsHtml() the rest of the module uses.
973
+ if (this.config.publishLibrarySource === true) {
974
+ router.get('/library', async (req, res) => {
975
+ const start = Date.now();
976
+ try {
977
+ const filename = this.config.librarySource;
978
+ let source;
979
+ try {
980
+ source = await fs.promises.readFile(filename, 'utf8');
981
+ } catch (error) {
982
+ this.log.error(`Error reading library source ${filename}: ${error.message}`);
983
+ res.status(500).json(this.operationOutcome('error', 'exception',
984
+ 'The library source is published by this server, but could not be read'));
985
+ return;
986
+ }
987
+ if (txHtml.acceptsHtml(req)) {
988
+ let txhtml = new TxHtmlRenderer(new Renderer(req.txOpContext, req.txProvider), this.liquid, this.languages, this.i18n, req.txEndpoint.path);
989
+ const content = txHtml.buildLibrarySourcePage(source, path.basename(filename), req.txEndpoint.path);
990
+ const html = await txhtml.renderPage('Library Source', content, req.txEndpoint, req.txStartTime);
991
+ res.setHeader('Content-Type', 'text/html');
992
+ res.send(html);
993
+ } else {
994
+ res.setHeader('Content-Type', 'application/yaml; charset=utf-8');
995
+ res.send(source);
996
+ }
997
+ } finally {
998
+ this.countRequest(endpointPath, 'library', Date.now() - start);
999
+ }
1000
+ });
1001
+ }
1002
+
959
1003
  // Metadata / CapabilityStatement
960
1004
  router.get('/metadata', async (req, res) => {
961
1005
  const start = Date.now();
@@ -558,10 +558,10 @@ class ValueSetChecker {
558
558
  } else {
559
559
  this.worker.opContext.addNote(this.valueSet, 'found OK', this.indentCount);
560
560
  result = true;
561
- if ((await cs.code(ctxt.context)) !== code) {
562
- let msg = this.worker.i18n.translate('CODE_CASE_DIFFERENCE', this.params.HTTPLanguages, [code, await cs.code(ctxt), cs.system]);
563
- messages.push(msg);
564
- op.addIssue(new Issue('warning', 'business-rule', addToPath(path, 'code'), 'CODE_CASE_DIFFERENCE', msg, 'code-rule'));
561
+ const normalised = await cs.code(ctxt.context);
562
+ if (normalised && normalised !== code) {
563
+ messages.push(this.addNormalisationIssue(op, path, code, normalised, cs));
564
+ normalForm.value = normalised;
565
565
  }
566
566
  let msg = await cs.incompleteValidationMessage(ctxt.context, this.params.HTTPLanguages);
567
567
  if (msg) {
@@ -1611,6 +1611,34 @@ class ValueSetChecker {
1611
1611
  return result;
1612
1612
  }
1613
1613
 
1614
+ /**
1615
+ * The code as submitted differs from the code as the code system renders it. Report why.
1616
+ *
1617
+ * Every such difference used to be reported as CODE_CASE_DIFFERENCE, which is where that
1618
+ * assumption came from - cs-lang, where the canonical form really does differ only in case.
1619
+ * It is not true in general. cs-snomed re-renders any postcoordinated expression through
1620
+ * renderExpression(Minimal), so every legal alternative serialisation (the optional comma
1621
+ * before an attribute group, whitespace, |terms|, attribute order) came back to the caller
1622
+ * as a complaint about their casing; ICD-11 normalises a URI to the code it denotes and did
1623
+ * the same. Neither has anything to do with case, and unlike case there is nothing the
1624
+ * caller did wrong - the submitted form is valid, it simply is not the normal form.
1625
+ *
1626
+ * So: case-only differences stay CODE_CASE_DIFFERENCE, everything else is
1627
+ * CODE_NOT_IN_NORMAL_FORM. Both are information severity (they were warning in one of the
1628
+ * two call sites and information in the other; every tx-ecosystem fixture expects
1629
+ * information).
1630
+ *
1631
+ * @returns {string} the message, for callers that also collect it into `messages`
1632
+ */
1633
+ addNormalisationIssue(op, path, code, normalised, cs) {
1634
+ const caseOnly = normalised.toLowerCase() === code.toLowerCase();
1635
+ const id = caseOnly ? 'CODE_CASE_DIFFERENCE' : 'CODE_NOT_IN_NORMAL_FORM';
1636
+ const csDesc = cs.version() ? cs.system() + '|' + cs.version() : cs.system();
1637
+ const msg = this.worker.i18n.translate(id, this.params.HTTPLanguages, [code, normalised, csDesc]);
1638
+ op.addIssue(new Issue('information', 'business-rule', addToPath(path, 'code'), id, msg, 'code-rule'));
1639
+ return msg;
1640
+ }
1641
+
1614
1642
  async checkConceptSet(path, role, cs, cset, code, displays, vs, message, inactive, normalForm, vstatus, op, vcc, messages) {
1615
1643
  this.worker.opContext.addNote(vs, 'check code ' + role + ' ' + this.worker.renderer.displayValueSetInclude(cset) + ' at ' + path, this.indentCount);
1616
1644
  let result = false;
@@ -1633,15 +1661,10 @@ class ValueSetChecker {
1633
1661
  }
1634
1662
  } else {
1635
1663
  this.worker.opContext.addNote(this.valueSet, 'Code "' + code + '" found in ' + this.worker.renderer.displayCoded(cs), this.indentCount);
1636
- if (await cs.code(loc.context) != code) {
1637
- let msg;
1638
- if (cs.version()) {
1639
- msg = this.worker.i18n.translate('CODE_CASE_DIFFERENCE', this.params.HTTPLanguages, [code, await cs.code(loc.context), cs.system() + '|' + cs.version()]);
1640
- } else {
1641
- msg = this.worker.i18n.translate('CODE_CASE_DIFFERENCE', this.params.HTTPLanguages, [code, await cs.code(loc.context), cs.system()]);
1642
- }
1643
- op.addIssue(new Issue('information', 'business-rule', addToPath(path, 'code'), 'CODE_CASE_DIFFERENCE', msg, 'code-rule'));
1644
- normalForm.value = await cs.code(loc.context);
1664
+ const normalised = await cs.code(loc.context);
1665
+ if (normalised && normalised != code) {
1666
+ this.addNormalisationIssue(op, path, code, normalised, cs);
1667
+ normalForm.value = normalised;
1645
1668
  }
1646
1669
  let msg = await cs.incompleteValidationMessage(loc.context, this.params.HTTPLanguages);
1647
1670
  if (msg) {