fhirsmith 0.13.1 → 0.13.3

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,49 @@ 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.3] - 2026-09-10
9
+
10
+ ### Fixed
11
+
12
+ - Fix SNOMED CT display selection on national editions. `_displayRefsetOrder()` knew a display
13
+ language reference set for only six editions (International, US, UK and the test edition);
14
+ every other edition fell through to "the first description marked Preferred in any language
15
+ reference set", which on a multi-language edition means whichever description happened to be
16
+ stored first. The Belgian edition (11000172109) marks a Preferred synonym in Belgian French
17
+ (21000172104) and Belgian Dutch (31000172101) as well as in English, so a request that named
18
+ no `displayLanguage` got French for one concept, Dutch for the next and English for a third.
19
+ Editions not in the table now default to US then GB English, and the $lookup designation
20
+ order ranks the edition's display reference set ahead of "preferred anywhere", so the choice
21
+ no longer depends on import order. An edition with no English language reference set at all
22
+ still falls back to its own preferred synonym. This changes one tx-ecosystem expectation:
23
+ `bugs/sct-display-2` recorded "Counselling" for 409063005 on the International edition, which
24
+ is the GB English preferred term picked only because it is stored before the US one - the same
25
+ concept already expanded as "Counseling". The expectation is now "Counseling".
26
+ - Fix the language tag on the display designation in $expand. It was hard-coded to `en-US`
27
+ while its value came from the edition display, so on a multi-language edition a French or
28
+ Dutch term was published as English - which put it in an implementation guide's English
29
+ display column and made the real French designation read as a duplicate of it.
30
+
31
+ ### Tx Conformance Statement
32
+
33
+ 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
34
+
35
+ ## [v0.13.2] - 2026-09-05
36
+
37
+ ### Added
38
+
39
+ - Add support for publishLibrarySource
40
+ -
41
+ ### Fixed
42
+
43
+ - Fix error in Snomed CT and ICD-11 where normal form difference and case difference being conflated
44
+ - Fix error in mime-type subsumption analysis
45
+ - adjust mime type validation to not be case sensitive
46
+
47
+ ### Tx Conformance Statement
48
+
49
+ 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)
50
+
8
51
  ## [v0.13.1] - 2026-09-05
9
52
 
10
53
  ### Fixed
@@ -94,7 +94,14 @@
94
94
  // (>100MB). Only applies to websites whose publish-setup.json has $.website.github = true.
95
95
  // Omit to leave them in place - the push will then fail. Relative paths resolve under the
96
96
  // publisher data folder
97
- "large-file-archive": "/absolute/path/to/large-file-archive"
97
+ "large-file-archive": "/absolute/path/to/large-file-archive",
98
+ // where to clone the IG registry from. Omit for the default,
99
+ // git@github.com:FHIR/ig-registry.git. Set this to an ssh_config Host alias when the
100
+ // server should push the registry with a repo-scoped deploy key instead of an account
101
+ // key - e.g. "git@github-ig-registry:FHIR/ig-registry.git", with a matching Host block
102
+ // in the service user's ~/.ssh/config carrying IdentityFile and IdentitiesOnly yes.
103
+ // The alias is what lets ssh tell this remote apart from the website remote.
104
+ "ig-registry-url": "git@github.com:FHIR/ig-registry.git"
98
105
  },
99
106
  "npmprojector": {
100
107
  "enabled": true,
@@ -150,6 +157,11 @@
150
157
  "tx": {
151
158
  "enabled": false,
152
159
  "librarySource": "/absolute/path/to/library.yml",
160
+ // publish the library source above at <endpoint>/library, so users can see what this
161
+ // server actually loads. One URL, content negotiated: a browser gets a formatted page
162
+ // (and a "Library" item in the tx nav bar), anything else gets the YAML file itself.
163
+ // Off by default - the file names every database, cache and package the server runs.
164
+ "publishLibrarySource": false,
153
165
  "cacheTimeout": 30,
154
166
  "expansionCacheSize": 1000,
155
167
  "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.3",
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": {
@@ -46,7 +46,7 @@
46
46
  "express-rate-limit": "^7.4.1",
47
47
  "express-session": "^1.19.0",
48
48
  "fast-xml-parser": "^5.3.4",
49
- "fhir-validator-wrapper": "1.3.0",
49
+ "fhir-validator-wrapper": "1.4.0",
50
50
  "fhirpath": "^4.8.3",
51
51
  "fs-extra": "^11.3.3",
52
52
  "ini": "^6.0.0",
@@ -1117,8 +1117,15 @@ class PublisherModule {
1117
1117
  // Step 1: Clone supporting repositories into the task directory
1118
1118
  const registryDir = path.join(taskDir, 'ig-registry');
1119
1119
 
1120
- await this.runCommand('git', ['clone', 'git@github.com:FHIR/ig-registry.git', registryDir],
1121
- {}, task.id, 'Cloning ig-registry');
1120
+ // The URL is configurable so that the credential arrangement can live in server config
1121
+ // rather than here. A deployment that pushes ig-registry with a repo-scoped deploy key
1122
+ // rather than an account key points this at an ssh_config Host alias carrying that key,
1123
+ // e.g. git@github-ig-registry:FHIR/ig-registry.git - the alias is the only way ssh can
1124
+ // tell this remote apart from the website remote, both being github.com.
1125
+ const igRegistryUrl = this.config['ig-registry-url'] || 'git@github.com:FHIR/ig-registry.git';
1126
+
1127
+ await this.runCommand('git', ['clone', igRegistryUrl, registryDir],
1128
+ {}, task.id, 'Cloning ig-registry from ' + igRegistryUrl);
1122
1129
 
1123
1130
  // Use website-configured history templates path if provided, otherwise clone the default repo
1124
1131
  let historyDir;
@@ -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';
@@ -28,6 +28,21 @@ const {debugLog} = require("../operation-context");
28
28
  // shared - it simply recomputes.
29
29
  const SNOMED_FILTER_ANALYSIS = Symbol('snomedFilterAnalysis');
30
30
 
31
+ // Description language index -> language code. The index is assigned at import
32
+ // time by SnomedImporter.mapLanguageCode(); this table is its inverse and must
33
+ // be kept in step with it.
34
+ const SCT_LANGUAGE_CODES = {
35
+ 1: 'en',
36
+ 2: 'fr',
37
+ 3: 'nl',
38
+ 4: 'es',
39
+ 5: 'sv',
40
+ 6: 'da',
41
+ 7: 'de',
42
+ 8: 'it',
43
+ 9: 'cs'
44
+ };
45
+
31
46
  // Context kinds matching Pascal enum
32
47
  const SnomedProviderContextKind = {
33
48
  CODE: 0,
@@ -346,8 +361,20 @@ class SnomedServices {
346
361
  // preferred synonym in more than one dialect at once (the International
347
362
  // edition ships both US English 509007 and GB English 508004, each marking a
348
363
  // different synonym Preferred). The edition default decides which term is
349
- // shown. Editions not listed here fall back to "preferred synonym in any
350
- // language refset" (the historical behaviour), so no edition regresses.
364
+ // shown.
365
+ //
366
+ // Editions not listed here default to English (US then GB) rather than to
367
+ // "preferred synonym in any language refset". A national edition carries a
368
+ // Preferred synonym in each of its own language refsets as well as in English
369
+ // (e.g. the Belgian edition 11000172109 marks a French term Preferred in
370
+ // 21000172104 and a Dutch one in 31000172101), so "any refset" resolved to
371
+ // whichever description happened to be stored first - French for one concept,
372
+ // Dutch for the next, English for a third. A caller who asked for no
373
+ // particular language got an arbitrary mix. English is the only defensible
374
+ // default: it is the one language every edition has, and it is what a request
375
+ // with no displayLanguage is taken to mean everywhere else in the server. An
376
+ // edition with no English at all still falls through to the any-refset step
377
+ // below, so nothing regresses.
351
378
  _displayRefsetOrder() {
352
379
  if (this._dispOrder) return this._dispOrder;
353
380
  const idx = (id) => { const r = this.concepts.findConcept(id); return r.found ? r.index : -1; };
@@ -361,7 +388,7 @@ class SnomedServices {
361
388
  '83821000000107': [GB], // UK Edition
362
389
  '999000021000000109': [GB] // UK Clinical Edition
363
390
  };
364
- const ids = byEdition[String(this.edition)] || [];
391
+ const ids = byEdition[String(this.edition)] || [US, GB];
365
392
  this._dispOrder = ids.map(idx).filter((i) => i >= 0);
366
393
  return this._dispOrder;
367
394
  }
@@ -400,59 +427,80 @@ class SnomedServices {
400
427
  return false;
401
428
  }
402
429
 
403
- // Return a concept's display: the synonym marked Preferred in the US English
404
- // language reference set; failing that the FSN; failing that the first active
405
- // description. Previously this returned the first active description outright,
406
- // which is only the preferred term by accident of import order.
407
- getDisplayName(reference = 0) {
430
+ // Return a concept's display, together with the language it is actually in:
431
+ // the synonym marked Preferred in the edition's display language reference
432
+ // set; failing that a preferred synonym in any language refset; failing that
433
+ // the FSN; failing that the first active description. Previously this
434
+ // returned the first active description outright, which is only the preferred
435
+ // term by accident of import order.
436
+ //
437
+ // The language matters as much as the term: the display is emitted as a
438
+ // designation (DesignationUse.DISPLAY) during $expand, and tagging a French
439
+ // term as en-US puts it in an IG's English display column and makes the real
440
+ // French designation look like a duplicate.
441
+ //
442
+ // Returns {term, lang} - lang is a language code, or null when no description
443
+ // could be read.
444
+ getDisplayNameEx(reference = 0) {
445
+ const none = { term: '', lang: null };
408
446
  try {
409
447
  const concept = this.concepts.getConcept(reference);
410
448
  const descriptionsRef = concept.descriptions;
411
449
 
412
450
  if (descriptionsRef === 0) {
413
- return '';
451
+ return none;
414
452
  }
415
453
 
416
454
  const descriptionIndices = this.refs.getReferences(descriptionsRef);
417
455
  const K = this._displayConstants();
456
+ const pick = (description) => ({
457
+ term: this.strings.getEntry(description.iDesc).trim(),
458
+ lang: SCT_LANGUAGE_CODES[description.lang] || 'en'
459
+ });
418
460
 
419
461
  // 1. Preferred synonym in the edition's default display refset(s), tried
420
462
  // in priority order. This is what disambiguates dialects: on the
421
463
  // International edition a concept may be Preferred in both US and GB
422
- // English, and the edition default (US English) must win.
464
+ // English, and the edition default (US English) must win. It is also
465
+ // what keeps a multi-language national edition from picking a
466
+ // different language for each concept.
423
467
  for (const refsetIdx of this._displayRefsetOrder()) {
424
468
  for (const descIndex of descriptionIndices) {
425
469
  const description = this.descriptions.getDescription(descIndex);
426
470
  if (!description.active || description.kind !== K.synonym) continue;
427
471
  if (this._descAcceptability(description, refsetIdx) === K.preferred) {
428
- return this.strings.getEntry(description.iDesc).trim();
472
+ return pick(description);
429
473
  }
430
474
  }
431
475
  }
432
476
 
433
477
  // 2. Fallback: preferred synonym in ANY language refset; then FSN; then
434
- // the first active description. Used for editions without a mapped
435
- // default refset, and for concepts with no preferred synonym there.
436
- let fsnTerm = '';
437
- let firstActive = '';
478
+ // the first active description. Reached only by editions with no
479
+ // English language refset at all, and by concepts with no preferred
480
+ // synonym in the edition's display refsets.
481
+ let fsn = null;
482
+ let firstActive = null;
438
483
  for (const descIndex of descriptionIndices) {
439
484
  const description = this.descriptions.getDescription(descIndex);
440
485
  if (!description.active) continue;
441
- const term = this.strings.getEntry(description.iDesc).trim();
442
- if (firstActive === '') firstActive = term;
486
+ if (firstActive === null) firstActive = pick(description);
443
487
  if (this._synonymIsPreferred(description)) {
444
- return term; // preferred synonym (any English language refset)
488
+ return pick(description); // preferred synonym (any language refset)
445
489
  }
446
- if (description.kind === K.fsn && fsnTerm === '') {
447
- fsnTerm = term;
490
+ if (description.kind === K.fsn && fsn === null) {
491
+ fsn = pick(description);
448
492
  }
449
493
  }
450
- return fsnTerm || firstActive || '';
494
+ return fsn || firstActive || none;
451
495
  } catch (error) {
452
- return '';
496
+ return none;
453
497
  }
454
498
  }
455
499
 
500
+ getDisplayName(reference = 0) {
501
+ return this.getDisplayNameEx(reference).term;
502
+ }
503
+
456
504
  getConceptDescendants(reference) {
457
505
  try {
458
506
  const allDescRef = this.concepts.getAllDesc(reference);
@@ -1687,8 +1735,16 @@ class SnomedProvider extends BaseCSServices {
1687
1735
  if (!prefSyns.has(langCode)) prefSyns.set(langCode, { langCode, use, term });
1688
1736
  }
1689
1737
  }
1690
- const display = this.sct.getDisplayName(ctxt.getReference());
1691
- if (display) displays.addDesignation(true, 'active', 'en-US', null, display);
1738
+ // Tagged with the language the term is actually in, not a
1739
+ // hard-coded en-US: on a multi-language edition the edition
1740
+ // display can be French or Dutch, and mislabelling it as English
1741
+ // is what puts it in an IG's English display column and makes the
1742
+ // real French designation look like a duplicate of it. English
1743
+ // stays 'en-US' (the term comes from the US English language
1744
+ // reference set), so nothing changes for English editions.
1745
+ const display = this.sct.getDisplayNameEx(ctxt.getReference());
1746
+ const displayLang = (!display.lang || display.lang === 'en') ? 'en-US' : display.lang;
1747
+ if (display.term) displays.addDesignation(true, 'active', displayLang, null, display.term);
1692
1748
  // FSNs before synonyms, matching the designation order the published
1693
1749
  // tx-ecosystem expectations were written against (sct/expand-inactive).
1694
1750
  // Which of the two is chosen as the display is settled by
@@ -1697,14 +1753,31 @@ class SnomedProvider extends BaseCSServices {
1697
1753
  for (const d of fsns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1698
1754
  for (const d of prefSyns.values()) displays.addDesignation(false, 'active', d.langCode, d.use, d.term);
1699
1755
  } else {
1700
- // $lookup: emit every description (preferred synonym first so the
1701
- // display resolves correctly; order is not otherwise significant).
1756
+ // $lookup: emit every description. Order matters only for the
1757
+ // display: a request with no displayLanguage takes the first
1758
+ // designation Designations._isPreferred() accepts, which is any
1759
+ // SNOMED synonym. Ranking by the edition's display language
1760
+ // reference set first (rather than by "preferred in any refset")
1761
+ // keeps that pick in one language - otherwise a national edition
1762
+ // answers $lookup in French for one concept and Dutch for the
1763
+ // next, depending on description storage order.
1764
+ const K2 = this.sct._displayConstants();
1765
+ const displayRefsets = this.sct._displayRefsetOrder();
1766
+ // Buckets: 0 = preferred in the edition's display refset, 1 =
1767
+ // preferred in some other language refset, 2 = everything else
1768
+ // (FSNs and acceptable synonyms, left in their original order -
1769
+ // Array.sort is stable, so nothing else about the output moves).
1770
+ const rank = (description) => {
1771
+ if (description.kind !== K2.synonym) return 2;
1772
+ for (const refsetIdx of displayRefsets) {
1773
+ if (this.sct._descAcceptability(description, refsetIdx) === K2.preferred) return 0;
1774
+ }
1775
+ return this.sct._synonymIsPreferred(description) ? 1 : 2;
1776
+ };
1702
1777
  const orderedIndices = descriptionIndices.slice().sort((a, b) => {
1703
1778
  const da = this.sct.descriptions.getDescription(a);
1704
1779
  const db = this.sct.descriptions.getDescription(b);
1705
- const pa = this.sct._synonymIsPreferred(da) ? 0 : 1;
1706
- const pb = this.sct._synonymIsPreferred(db) ? 0 : 1;
1707
- return pa - pb;
1780
+ return rank(da) - rank(db);
1708
1781
  });
1709
1782
  for (const descIndex of orderedIndices) {
1710
1783
  const description = this.sct.descriptions.getDescription(descIndex);
@@ -1733,18 +1806,7 @@ class SnomedProvider extends BaseCSServices {
1733
1806
  }
1734
1807
 
1735
1808
  getLanguageCode(langIndex) {
1736
- const languageMap = {
1737
- 1: 'en',
1738
- 2: 'fr',
1739
- 3: 'nl',
1740
- 4: 'es',
1741
- 5: 'sv',
1742
- 6: 'da',
1743
- 7: 'de',
1744
- 8: 'it',
1745
- 9: 'cs'
1746
- };
1747
- return languageMap[langIndex] || 'en';
1809
+ return SCT_LANGUAGE_CODES[langIndex] || 'en';
1748
1810
  }
1749
1811
 
1750
1812
  // Lookup methods
@@ -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) {