babelfhir-ts 1.5.21 → 1.6.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.
- package/README.md +6 -3
- package/out/src/generator/core/extensionSdReader.js +2 -1
- package/out/src/generator/core/utils.js +69 -0
- package/out/src/generator/emitters/class/classGeneratorHelpers.js +10 -2
- package/out/src/generator/emitters/class/sliceElementDefaults.js +10 -1
- package/out/src/generator/emitters/interface/interfaceFieldProcessor.js +15 -12
- package/out/src/generator/emitters/interface/interfaceFieldUtils.js +6 -3
- package/out/src/generator/emitters/interface/postProcessExtensions.js +8 -2
- package/out/src/generator/emitters/interface/processNestedField.js +4 -6
- package/out/src/generator/emitters/namingsystem/namingSystemGenerator.js +136 -0
- package/out/src/generator/emitters/prefab/prefabEmitter.js +2 -15
- package/out/src/generator/emitters/prefab/prefabRenderer.js +2 -19
- package/out/src/generator/emitters/validator/closedSlicingValidation.js +5 -2
- package/out/src/generator/emitters/validator/fhirpathStubInstaller.js +7 -1
- package/out/src/generator/emitters/validator/sliceBackboneValidation.js +27 -12
- package/out/src/generator/emitters/validator/sliceValidatorGenerator.js +15 -27
- package/out/src/generator/emitters/validator/sliceValidatorUtils.js +5 -1
- package/out/src/generator/emitters/validator/subExtensionScope.js +10 -2
- package/out/src/generator/emitters/validator/validatorExpressions.js +44 -0
- package/out/src/generator/emitters/validator/validatorFieldBuilders.js +34 -17
- package/out/src/generator/emitters/validator/validatorGenerator.js +31 -40
- package/out/src/generator/emitters/validator/validatorRuntime.js +13 -2
- package/out/src/generator/emitters/valueset/valueSetGenerator.js +6 -10
- package/out/src/generator/emitters/zod/zodRefinementBuilder.js +39 -3
- package/out/src/generator/emitters/zod/zodSchemaGenerator.js +36 -8
- package/out/src/generator/generationHelpers.js +59 -7
- package/out/src/generator/index.js +90 -47
- package/out/src/generator/parser/packageParser.js +76 -0
- package/out/src/generator/parser/vsParser.js +4 -2
- package/out/src/generator/sdProcessor.js +30 -21
- package/out/src/main.js +5 -0
- package/package.json +12 -9
- package/parity-matrix.json +6 -0
package/README.md
CHANGED
|
@@ -61,9 +61,9 @@ Every push to `develop` and `main` runs the parity pipeline, which validates gen
|
|
|
61
61
|
4. Generates `empty()` and `random()` test resources for every profile
|
|
62
62
|
5. Validates those resources against two independent external FHIR validators
|
|
63
63
|
|
|
64
|
-
### Validated Implementation Guides (
|
|
64
|
+
### Validated Implementation Guides (31)
|
|
65
65
|
|
|
66
|
-
Every parity run validates **
|
|
66
|
+
Every parity run validates **31 real-world IGs** (29 R4, 2 R5). The list is not duplicated here — it is generated from the parity suite's own source of truth into [`parity-matrix.json`](./parity-matrix.json).
|
|
67
67
|
|
|
68
68
|
Each validated IG is also published as a ready-to-install package — `@max-health-inc/fhir-<name>` — so you do not have to generate it yourself:
|
|
69
69
|
|
|
@@ -111,6 +111,7 @@ The `firely` job validates generated resources using the [Firely SDK Validator](
|
|
|
111
111
|

|
|
112
112
|

|
|
113
113
|

|
|
114
|
+

|
|
114
115
|
|
|
115
116
|
</details>
|
|
116
117
|
|
|
@@ -148,10 +149,11 @@ The `hl7` job validates the same artifacts using the [official HL7 FHIR Validato
|
|
|
148
149
|

|
|
149
150
|

|
|
150
151
|

|
|
152
|
+

|
|
151
153
|
|
|
152
154
|
</details>
|
|
153
155
|
|
|
154
|
-
> External-validator badges cover the
|
|
156
|
+
> External-validator badges cover the 29 R4/R4B IGs. R5 packages are generated and typechecked, but neither external validator publishes R5 badges yet.
|
|
155
157
|
|
|
156
158
|
> Terminology validation requires a tx server. The pipeline uses `--tx-server https://tx.fhir.org/{r4|r5}` (matching the package's FHIR version) during generation to expand ValueSets and produce valid codes.
|
|
157
159
|
|
|
@@ -223,6 +225,7 @@ Options:
|
|
|
223
225
|
--no-classes Only generate interfaces and types (skip class generation)
|
|
224
226
|
--no-client Skip FHIR client generation (client generated by default)
|
|
225
227
|
--schema <format> Generate schema files alongside outputs (supported: zod)
|
|
228
|
+
--zod-invariants Emit FHIRPath invariants into Zod schemas (fuller checking, slower parse)
|
|
226
229
|
--dicomweb Generate DICOMweb helpers typed to ImagingStudy profiles in the IG
|
|
227
230
|
--prefab Generate Prefab UI render functions per profile (@maxhealth.tech/prefab)
|
|
228
231
|
--prefab-styles <path> Path to styles module (.ts/.js) copied into the generated prefab/ folder
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* binding, while KBV_EX_ERP_PracticeSupply_Payor binds it required to
|
|
10
10
|
* KBV_VS_FOR_Payor_type.
|
|
11
11
|
*/
|
|
12
|
+
import { stripVersionFromCanonicalUrl } from './utils.js';
|
|
12
13
|
/** `Extension.extension:<name>` with no further path below it. */
|
|
13
14
|
const SUB_SLICE_ID = /^Extension\.extension:([^.:]+)$/;
|
|
14
15
|
/**
|
|
@@ -32,7 +33,7 @@ export function readBinding(el) {
|
|
|
32
33
|
: binding.valueSet?.reference;
|
|
33
34
|
if (!raw)
|
|
34
35
|
return undefined;
|
|
35
|
-
return { strength: binding.strength, uri: raw
|
|
36
|
+
return { strength: binding.strength, uri: stripVersionFromCanonicalUrl(raw) };
|
|
36
37
|
}
|
|
37
38
|
/** Type codes declared on an element. */
|
|
38
39
|
function typeCodes(el) {
|
|
@@ -14,6 +14,33 @@ export function stripVersionFromCanonicalUrl(url) {
|
|
|
14
14
|
const pipeIndex = url.indexOf('|');
|
|
15
15
|
return pipeIndex >= 0 ? url.substring(0, pipeIndex) : url;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* An element path with any slice suffix removed: `identifier:NPI` -> `identifier`.
|
|
19
|
+
*
|
|
20
|
+
* FHIR element ids name the slice after a colon, and most path comparisons want
|
|
21
|
+
* the unsliced form. Written with indexOf rather than `split(':')[0]` so it
|
|
22
|
+
* returns a string: the split form is an indexing expression, which is only
|
|
23
|
+
* `string | undefined` to the compiler even though it can never be empty.
|
|
24
|
+
*/
|
|
25
|
+
export function stripSliceSuffix(path) {
|
|
26
|
+
if (!path)
|
|
27
|
+
return path;
|
|
28
|
+
const colonIndex = path.indexOf(':');
|
|
29
|
+
return colonIndex >= 0 ? path.substring(0, colonIndex) : path;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The first dot-separated segment of a path: `code.coding.system` -> `code`.
|
|
33
|
+
*
|
|
34
|
+
* indexOf rather than `split('.')[0]`, for the same reason as
|
|
35
|
+
* {@link stripSliceSuffix}: the split form is an indexing expression and reads as
|
|
36
|
+
* possibly-undefined even when it cannot be.
|
|
37
|
+
*/
|
|
38
|
+
export function firstSegment(path) {
|
|
39
|
+
if (!path)
|
|
40
|
+
return path;
|
|
41
|
+
const dotIndex = path.indexOf('.');
|
|
42
|
+
return dotIndex >= 0 ? path.substring(0, dotIndex) : path;
|
|
43
|
+
}
|
|
17
44
|
export function writeInterfaceAndValidatorToFile(outputDir, interfaceName, interfaceContent, validateFunctionContent) {
|
|
18
45
|
const filePath = path.join(outputDir, `${interfaceName}.ts`);
|
|
19
46
|
const content = `${interfaceContent}\n\n${validateFunctionContent}`;
|
|
@@ -331,6 +358,48 @@ export function sanitizeIdentifier(name) {
|
|
|
331
358
|
cleaned = `_${cleaned}`;
|
|
332
359
|
return cleaned;
|
|
333
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Turn a FHIR resource name into a PascalCase TypeScript identifier.
|
|
363
|
+
*
|
|
364
|
+
* Shared by the ValueSet and NamingSystem emitters, which previously carried
|
|
365
|
+
* the same split-and-capitalize loop with only the digit prefix differing.
|
|
366
|
+
* {@link toPascalCase} alone is not enough: it drops the underscore that
|
|
367
|
+
* {@link sanitizeIdentifier} adds, so a name starting with a digit comes back
|
|
368
|
+
* as an invalid identifier.
|
|
369
|
+
*/
|
|
370
|
+
export function pascalCaseIdentifier(name, opts) {
|
|
371
|
+
const stripped = opts.stripPrefix ? name.replace(opts.stripPrefix, '') : name;
|
|
372
|
+
const pascal = toPascalCase(stripped);
|
|
373
|
+
if (!pascal)
|
|
374
|
+
return opts.fallback;
|
|
375
|
+
return /^[0-9]/.test(pascal) ? `${opts.digitPrefix}${pascal}` : pascal;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Lower-case a name's first character, keeping a leading acronym run intact.
|
|
379
|
+
*
|
|
380
|
+
* "CHCorePatient" → "chCorePatient" (the acronym lowers wholesale, but the
|
|
381
|
+
* last capital starts the next word)
|
|
382
|
+
* "Patient" → "patient"
|
|
383
|
+
* "HumanName" → "humanName"
|
|
384
|
+
*
|
|
385
|
+
* Shared by the prefab renderer and the MCP view registry, which each carried a
|
|
386
|
+
* byte-identical copy — the second one commented "Mirrors the same logic in
|
|
387
|
+
* prefabRenderer.ts", which is the drift warning rather than a defence.
|
|
388
|
+
*
|
|
389
|
+
* charAt() is deliberate over indexing: it yields '' past the end, so the
|
|
390
|
+
* acronym run terminates at the boundary without an index assertion.
|
|
391
|
+
*/
|
|
392
|
+
export function lowerFirstPreservingAcronym(name) {
|
|
393
|
+
if (!name)
|
|
394
|
+
return name;
|
|
395
|
+
let i = 0;
|
|
396
|
+
while (i < name.length && name.charAt(i) >= 'A' && name.charAt(i) <= 'Z')
|
|
397
|
+
i++;
|
|
398
|
+
if (i <= 1)
|
|
399
|
+
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
400
|
+
const acronymEnd = i === name.length ? i : i - 1;
|
|
401
|
+
return name.slice(0, acronymEnd).toLowerCase() + name.slice(acronymEnd);
|
|
402
|
+
}
|
|
334
403
|
export function toPascalCase(name) {
|
|
335
404
|
if (!name)
|
|
336
405
|
return name;
|
|
@@ -95,8 +95,16 @@ export function generatePatternMetadata(fieldPatterns, forbiddenFields, valueSet
|
|
|
95
95
|
}
|
|
96
96
|
const hasBindings = Object.keys(valueSetBindings).length > 0;
|
|
97
97
|
return `
|
|
98
|
-
/**
|
|
99
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Pattern constraints this profile fixes on its fields, keyed by field path
|
|
100
|
+
* (e.g. \`"shlSession.system": "https://example.org/fhir/shl-session"\`).
|
|
101
|
+
*
|
|
102
|
+
* Public alongside \`valueSetBindings\`: a consumer that needs a fixed value —
|
|
103
|
+
* an identifier system, a fixed code — would otherwise hand-write the literal
|
|
104
|
+
* the profile already declares, which is the duplication this package exists
|
|
105
|
+
* to remove. Read by the random() generator too.
|
|
106
|
+
*/
|
|
107
|
+
static readonly _fieldPatterns = ${JSON.stringify(patternMap, null, 2)};
|
|
100
108
|
|
|
101
109
|
/** Fields that are forbidden (max=0) in this profile - must not be generated */
|
|
102
110
|
protected static readonly _forbiddenFields: string[] = ${JSON.stringify(forbiddenFields)};
|
|
@@ -252,6 +252,13 @@ export function buildNestedRequirementsObject(nestedRequirements) {
|
|
|
252
252
|
// Navigate/create nested objects for all but the last part
|
|
253
253
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
254
254
|
const part = parts[i];
|
|
255
|
+
// The loop bound guarantees a segment here; if one is ever missing the
|
|
256
|
+
// requirement is abandoned rather than navigated halfway, which is the same
|
|
257
|
+
// course the non-object branch below takes.
|
|
258
|
+
if (part === undefined) {
|
|
259
|
+
skipThis = true;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
255
262
|
const isArraySegment = allArraySegments.has(parts.slice(0, i + 1).join('.'));
|
|
256
263
|
if (!(part in current)) {
|
|
257
264
|
// Create either an array with one object or a plain object
|
|
@@ -290,7 +297,9 @@ export function buildNestedRequirementsObject(nestedRequirements) {
|
|
|
290
297
|
continue;
|
|
291
298
|
}
|
|
292
299
|
// Set the final value
|
|
293
|
-
const lastPart = parts
|
|
300
|
+
const lastPart = parts.at(-1);
|
|
301
|
+
if (lastPart === undefined)
|
|
302
|
+
continue;
|
|
294
303
|
// If this property already exists and is an object with properties, merge instead of overwrite
|
|
295
304
|
const existingValue = current[lastPart];
|
|
296
305
|
const hasExplicitConstraint = req.patternConstraint !== undefined || req.fixedValue !== undefined;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl } from '../../core/utils.js';
|
|
1
|
+
import { capitalize, choicePropertyName, narrowedChoiceType, sanitizeIdentifier, toPascalCase, stripVersionFromCanonicalUrl, stripSliceSuffix } from '../../core/utils.js';
|
|
2
2
|
import { sanitizeValueSetName } from '../valueset/valueSetGenerator.js';
|
|
3
3
|
import { logger } from '../../../logger.js';
|
|
4
4
|
import { getRules } from '../../fhir/versionContext.js';
|
|
@@ -45,8 +45,8 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
45
45
|
}
|
|
46
46
|
// Register external profiles for FHIR datatype profiles (like SimpleQuantity) early
|
|
47
47
|
// This must happen before any returns to ensure the profile is registered
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const profileUrl = field.isProfiled ? field.profileUrls?.[0] : undefined;
|
|
49
|
+
if (profileUrl) {
|
|
50
50
|
// Use profileUrlToName to get the actual interface name, fallback to URL-derived name
|
|
51
51
|
// Strip version suffix from canonical URL (e.g., |5.3.0-ballot-tc1) before extracting name
|
|
52
52
|
const strippedUrl = stripVersionFromCanonicalUrl(profileUrl);
|
|
@@ -61,13 +61,9 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
const fieldParts = field.name.split(".");
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
const colonIndex = fieldName.indexOf(':');
|
|
68
|
-
if (colonIndex !== -1) {
|
|
69
|
-
fieldName = fieldName.substring(0, colonIndex);
|
|
70
|
-
}
|
|
64
|
+
// Slice notation is dropped for the property name ("extension:Stadtteil"
|
|
65
|
+
// -> "extension"); field.sliceName keeps it for semantic purposes.
|
|
66
|
+
let fieldName = stripSliceSuffix(fieldParts.at(-1) ?? field.name);
|
|
71
67
|
const isRootInterface = parentInterfaceName === interfaceName;
|
|
72
68
|
const isNested = isRootInterface ? fieldParts.length > 1 : fieldParts.length >= 1;
|
|
73
69
|
// When generating a slice-specific interface, only include direct children
|
|
@@ -126,7 +122,7 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
126
122
|
// (2 segments: Identifier.system) and skip nested paths (4+ segments: Identifier.assigner.identifier.system)
|
|
127
123
|
if (isNested && fieldParts.length >= 2) {
|
|
128
124
|
const baseTypeName = fieldParts[0]; // e.g., "Identifier" from "Identifier.assigner.identifier.system"
|
|
129
|
-
const isExtendingBaseType = baseResource && baseTypeName
|
|
125
|
+
const isExtendingBaseType = !!baseResource && baseTypeName?.toLowerCase() === baseResource.toLowerCase();
|
|
130
126
|
// Check if this field will create a nested interface (has children in fields)
|
|
131
127
|
const hasChildren = fields.some((f) => f.name.startsWith(field.name + '.'));
|
|
132
128
|
const shouldKeepForNested = field.mustSupport && hasChildren;
|
|
@@ -173,7 +169,8 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
173
169
|
}
|
|
174
170
|
else if (!isLogicalModel) {
|
|
175
171
|
// For other profiles, use the original logic
|
|
176
|
-
|
|
172
|
+
const parentSegment = fieldParts.at(-2);
|
|
173
|
+
if (!parentSegment || !parentInterfaceName.toLowerCase().includes(parentSegment.toLowerCase())) {
|
|
177
174
|
return; // Skip nested fields that don't match the parent interface name
|
|
178
175
|
}
|
|
179
176
|
}
|
|
@@ -529,7 +526,13 @@ export function processFields(ctx, fields, parentInterfaceName, parentFieldType,
|
|
|
529
526
|
deduped.push(line); // JSDoc or other non-field lines
|
|
530
527
|
continue;
|
|
531
528
|
}
|
|
529
|
+
// Group 1 is present whenever the regex matched, but the type does not
|
|
530
|
+
// say so; a line without it is passed through rather than deduped.
|
|
532
531
|
const name = fieldMatch[1];
|
|
532
|
+
if (!name) {
|
|
533
|
+
deduped.push(line);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
533
536
|
if (seenFieldNames.has(name)) {
|
|
534
537
|
continue; // Skip duplicate — keep the first (more specific) version
|
|
535
538
|
}
|
|
@@ -26,11 +26,14 @@ export function canonicalToTypeName(name, profileUrlToName) {
|
|
|
26
26
|
matches.add(generatedName);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
if (matches.size === 1)
|
|
30
|
-
|
|
29
|
+
if (matches.size === 1) {
|
|
30
|
+
const [only] = [...matches];
|
|
31
|
+
if (only)
|
|
32
|
+
return only;
|
|
33
|
+
}
|
|
31
34
|
}
|
|
32
35
|
// if already a valid identifier with capitalization, return as-is
|
|
33
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) &&
|
|
36
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t) && /^[A-Z]/.test(t))
|
|
34
37
|
return t;
|
|
35
38
|
const parts = t.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
36
39
|
let pascal = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
|
|
@@ -300,9 +300,15 @@ export function postProcessExtensions(params) {
|
|
|
300
300
|
});
|
|
301
301
|
const baseFld2 = baseFieldByLastSegment.get(seg2);
|
|
302
302
|
let extendT2 = actualField2?.type || baseFld2?.type;
|
|
303
|
-
//
|
|
303
|
+
// A generic BackboneElement carries none of the base backbone's own properties, so
|
|
304
|
+
// resolve it against the level-1 backbone already resolved above
|
|
305
|
+
// (QuestionnaireResponseItem + 'answer' -> QuestionnaireResponseItemAnswer).
|
|
304
306
|
if (!extendT2 || extendT2 === 'any' || extendT2 === 'BackboneElement') {
|
|
305
|
-
|
|
307
|
+
const inferred2 = extendT1 ? inferBackboneType(extendT1, seg2) : undefined;
|
|
308
|
+
if (inferred2) {
|
|
309
|
+
extendT2 = inferred2;
|
|
310
|
+
}
|
|
311
|
+
else if (baseResource === 'Encounter' && seg1 === 'hospitalization' && seg2 === 'dischargeDisposition') {
|
|
306
312
|
extendT2 = 'CodeableConcept';
|
|
307
313
|
}
|
|
308
314
|
}
|
|
@@ -175,12 +175,10 @@ export function handleNestedField(args) {
|
|
|
175
175
|
}
|
|
176
176
|
else {
|
|
177
177
|
// No nested interface existed; create a minimal one extending the base/inferred type to host the extension union
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
parentT = inferred;
|
|
183
|
-
}
|
|
178
|
+
// Reuse the type resolved above rather than recomputing it: the local
|
|
179
|
+
// recomputation used to stop at a generic BackboneElement, which drops the
|
|
180
|
+
// base backbone's own properties (e.g. QuestionnaireResponseItem.linkId).
|
|
181
|
+
let parentT = baseFieldType;
|
|
184
182
|
if (parentT && !isPrimitiveType(parentT)) {
|
|
185
183
|
parentT = canonicalToTypeName(parentT);
|
|
186
184
|
const sanitized = sanitizeIdentifier(parentT);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NamingSystem Generator
|
|
3
|
+
*
|
|
4
|
+
* Emits a `namingsystems/` module mirroring `valuesets/`, so an IG's declared
|
|
5
|
+
* identifier systems are consumable as typed constants instead of retyped as
|
|
6
|
+
* string literals in every downstream repo.
|
|
7
|
+
*/
|
|
8
|
+
import { pascalCaseIdentifier } from '../../core/utils.js';
|
|
9
|
+
/**
|
|
10
|
+
* Sanitize a NamingSystem name for use as a TypeScript identifier.
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizeNamingSystemName(name) {
|
|
13
|
+
return pascalCaseIdentifier(name, {
|
|
14
|
+
stripPrefix: /^NamingSystem[-_]?/i,
|
|
15
|
+
digitPrefix: 'NS',
|
|
16
|
+
fallback: 'UnnamedNamingSystem',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Assign a unique identifier to every NamingSystem that will be emitted.
|
|
21
|
+
*
|
|
22
|
+
* Single source of truth for NamingSystem naming, shared by the file writer and
|
|
23
|
+
* the registry — the same arrangement `assignValueSetNames` uses, and for the
|
|
24
|
+
* same reason: two independent deduplication passes drift, and the registry
|
|
25
|
+
* then imports a file that was never written (TS2307).
|
|
26
|
+
*
|
|
27
|
+
* A NamingSystem has no canonical URL in R4, so identity here is the preferred
|
|
28
|
+
* uniqueId value rather than a `url` element.
|
|
29
|
+
*/
|
|
30
|
+
export function assignNamingSystemNames(namingSystems) {
|
|
31
|
+
const takenNames = new Set();
|
|
32
|
+
const seenIdentities = new Set();
|
|
33
|
+
const assigned = [];
|
|
34
|
+
for (const namingSystem of namingSystems) {
|
|
35
|
+
const identity = preferredUniqueId(namingSystem).value;
|
|
36
|
+
if (seenIdentities.has(identity))
|
|
37
|
+
continue;
|
|
38
|
+
seenIdentities.add(identity);
|
|
39
|
+
const base = sanitizeNamingSystemName(namingSystem.name);
|
|
40
|
+
let sanitized = base;
|
|
41
|
+
let suffix = 2;
|
|
42
|
+
while (takenNames.has(sanitized)) {
|
|
43
|
+
sanitized = `${base}${suffix}`;
|
|
44
|
+
suffix++;
|
|
45
|
+
}
|
|
46
|
+
takenNames.add(sanitized);
|
|
47
|
+
assigned.push({ namingSystem, sanitized });
|
|
48
|
+
}
|
|
49
|
+
return assigned;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The uniqueId a consumer means when they say "the system".
|
|
53
|
+
*
|
|
54
|
+
* `preferred: true` wins, then the first `uri` entry (what a profile fixes on
|
|
55
|
+
* `Identifier.system`), then the first entry at all.
|
|
56
|
+
*/
|
|
57
|
+
export function preferredUniqueId(namingSystem) {
|
|
58
|
+
return namingSystem.uniqueIds.find(u => u.preferred)
|
|
59
|
+
?? namingSystem.uniqueIds.find(u => u.type === 'uri')
|
|
60
|
+
?? namingSystem.uniqueIds[0];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Generate TypeScript content for a NamingSystem.
|
|
64
|
+
*/
|
|
65
|
+
export function generateNamingSystemTypeScript(namingSystem, options) {
|
|
66
|
+
const name = options?.nameOverride ?? sanitizeNamingSystemName(namingSystem.name);
|
|
67
|
+
const filename = `NamingSystem-${name}.ts`;
|
|
68
|
+
const preferred = preferredUniqueId(namingSystem);
|
|
69
|
+
let content = `/**\n * NamingSystem: ${namingSystem.title || namingSystem.name}\n`;
|
|
70
|
+
if (namingSystem.description) {
|
|
71
|
+
content += ` * ${namingSystem.description.replace(/\*\//g, '*\\/').split('\n').join('\n * ')}\n`;
|
|
72
|
+
}
|
|
73
|
+
if (namingSystem.kind)
|
|
74
|
+
content += ` * Kind: ${namingSystem.kind}\n`;
|
|
75
|
+
content += ` */\n\n`;
|
|
76
|
+
content += `export const ${name}UniqueIds = [\n`;
|
|
77
|
+
for (const uid of namingSystem.uniqueIds) {
|
|
78
|
+
const preferredPart = uid.preferred !== undefined ? `, preferred: ${uid.preferred}` : '';
|
|
79
|
+
const commentPart = uid.comment ? `, comment: ${JSON.stringify(uid.comment)}` : '';
|
|
80
|
+
content += ` { type: ${JSON.stringify(uid.type)}, value: ${JSON.stringify(uid.value)}${preferredPart}${commentPart} },\n`;
|
|
81
|
+
}
|
|
82
|
+
content += `] as const;\n\n`;
|
|
83
|
+
content += `/**\n * The identifier system this NamingSystem defines.\n`;
|
|
84
|
+
content += ` * Use in place of a hand-written literal on \`Identifier.system\`.\n */\n`;
|
|
85
|
+
content += `export const ${name}System = ${JSON.stringify(preferred.value)};\n\n`;
|
|
86
|
+
const valueUnion = [...new Set(namingSystem.uniqueIds.map(u => JSON.stringify(u.value)))].join(' | ');
|
|
87
|
+
content += `/** Union of every uniqueId value this NamingSystem declares */\n`;
|
|
88
|
+
content += `export type ${name}Value = ${valueUnion};\n\n`;
|
|
89
|
+
content += `/** Type representing a uniqueId entry of this NamingSystem */\n`;
|
|
90
|
+
content += `export type ${name}UniqueId = typeof ${name}UniqueIds[number];\n\n`;
|
|
91
|
+
content += `/**\n * Check whether a value is one of this NamingSystem's declared identifiers\n */\n`;
|
|
92
|
+
content += `export function isValid${name}Value(value: string): value is ${name}Value {\n`;
|
|
93
|
+
content += ` return ${name}UniqueIds.some(u => u.value === value);\n`;
|
|
94
|
+
content += `}\n\n`;
|
|
95
|
+
content += `/**\n * Get this NamingSystem's identifier for a given uniqueId type (oid, uuid, uri, other)\n */\n`;
|
|
96
|
+
content += `export function get${name}Value(type: string): string | undefined {\n`;
|
|
97
|
+
content += ` return ${name}UniqueIds.find(u => u.type === type)?.value;\n`;
|
|
98
|
+
content += `}\n`;
|
|
99
|
+
return { filename, content };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Generate a registry file that exports all NamingSystems.
|
|
103
|
+
*/
|
|
104
|
+
export function generateNamingSystemRegistry(namingSystems) {
|
|
105
|
+
let content = `/**\n * NamingSystem Registry\n * Central registry of all identifier systems declared by this package\n */\n\n`;
|
|
106
|
+
// Names come from the shared assignment pass so every import below refers to
|
|
107
|
+
// a file the writer actually emitted (see assignNamingSystemNames).
|
|
108
|
+
const sorted = assignNamingSystemNames(namingSystems)
|
|
109
|
+
.map(({ namingSystem, sanitized }) => ({ sanitized, system: preferredUniqueId(namingSystem).value }))
|
|
110
|
+
.sort((a, b) => a.sanitized.localeCompare(b.sanitized));
|
|
111
|
+
for (const { sanitized } of sorted) {
|
|
112
|
+
content += `import * as ${sanitized} from './NamingSystem-${sanitized}.js';\n`;
|
|
113
|
+
}
|
|
114
|
+
content += '\n';
|
|
115
|
+
content += `export const NamingSystemRegistry = {\n`;
|
|
116
|
+
for (const { sanitized } of sorted) {
|
|
117
|
+
content += ` ${sanitized},\n`;
|
|
118
|
+
}
|
|
119
|
+
content += `} as const;\n\n`;
|
|
120
|
+
content += `export type NamingSystemName = keyof typeof NamingSystemRegistry;\n\n`;
|
|
121
|
+
content += `export function getNamingSystem(name: NamingSystemName) {\n`;
|
|
122
|
+
content += ` return NamingSystemRegistry[name];\n`;
|
|
123
|
+
content += `}\n\n`;
|
|
124
|
+
content += `/** Map from identifier system value to registry name */\n`;
|
|
125
|
+
content += `export const NamingSystemUrlMap: Record<string, NamingSystemName> = {\n`;
|
|
126
|
+
for (const { sanitized, system } of sorted) {
|
|
127
|
+
content += ` ${JSON.stringify(system)}: '${sanitized}',\n`;
|
|
128
|
+
}
|
|
129
|
+
content += `};\n\n`;
|
|
130
|
+
content += `/**\n * Look up the registry entry that declares an identifier system\n * @param system - An identifier system value (e.g. from \`Identifier.system\`)\n */\n`;
|
|
131
|
+
content += `export function getNamingSystemBySystem(system: string) {\n`;
|
|
132
|
+
content += ` const name = NamingSystemUrlMap[system];\n`;
|
|
133
|
+
content += ` return name ? NamingSystemRegistry[name] : undefined;\n`;
|
|
134
|
+
content += `}\n`;
|
|
135
|
+
return content;
|
|
136
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { lowerFirstPreservingAcronym } from '../../core/utils.js';
|
|
11
12
|
import { generatePrefabRenderer } from './prefabRenderer.js';
|
|
12
13
|
import { FHIR_FORMAT_TS, DEFAULT_STYLES_TS, REGISTER_FHIR_PIPES_TS, buildPrefabIndexTs, } from './prefabRuntimeAssets.js';
|
|
13
14
|
import { logger } from '../../../logger.js';
|
|
@@ -88,21 +89,7 @@ export function resetPrefabState() {
|
|
|
88
89
|
emittedProfiles.clear();
|
|
89
90
|
}
|
|
90
91
|
// ── MCP View Registry generator ──────────────────────────────────────────────
|
|
91
|
-
|
|
92
|
-
* Lower-case the first character(s) of a name, preserving acronym runs.
|
|
93
|
-
* Mirrors the same logic in prefabRenderer.ts.
|
|
94
|
-
*/
|
|
95
|
-
function lowerFirst(name) {
|
|
96
|
-
if (!name)
|
|
97
|
-
return name;
|
|
98
|
-
let i = 0;
|
|
99
|
-
while (i < name.length && name[i] >= 'A' && name[i] <= 'Z')
|
|
100
|
-
i++;
|
|
101
|
-
if (i <= 1)
|
|
102
|
-
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
103
|
-
const acronymEnd = i === name.length ? i : i - 1;
|
|
104
|
-
return name.slice(0, acronymEnd).toLowerCase() + name.slice(acronymEnd);
|
|
105
|
-
}
|
|
92
|
+
const lowerFirst = lowerFirstPreservingAcronym;
|
|
106
93
|
/**
|
|
107
94
|
* Build the `mcpViews.ts` source — a `FhirViewRegistry` that maps FHIR
|
|
108
95
|
* resource types to their generated prefab table/detail components.
|
|
@@ -11,26 +11,9 @@
|
|
|
11
11
|
* The emitted code depends on `@maxhealth.tech/prefab` and the sibling
|
|
12
12
|
* `fhirFormat.ts` / `styles.ts` files produced in the same output folder.
|
|
13
13
|
*/
|
|
14
|
+
import { lowerFirstPreservingAcronym } from '../../core/utils.js';
|
|
14
15
|
import { describeField, dedupeDescriptors, pickTableDescriptors, findTitleDescriptor, } from './prefabTypeMapper.js';
|
|
15
|
-
|
|
16
|
-
* camelCase first char lowercased, but preserve leading acronym runs.
|
|
17
|
-
* "CHCorePatient" → "chCorePatient" (acronym lowered wholesale)
|
|
18
|
-
* "Patient" → "patient"
|
|
19
|
-
* "HumanName" → "humanName"
|
|
20
|
-
*/
|
|
21
|
-
function lowerFirst(name) {
|
|
22
|
-
if (!name)
|
|
23
|
-
return name;
|
|
24
|
-
// Count leading uppercase letters (the acronym run).
|
|
25
|
-
let i = 0;
|
|
26
|
-
while (i < name.length && name[i] >= 'A' && name[i] <= 'Z')
|
|
27
|
-
i++;
|
|
28
|
-
if (i <= 1)
|
|
29
|
-
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
30
|
-
// Preserve boundary: last uppercase stays uppercase if it starts the next word.
|
|
31
|
-
const acronymEnd = i === name.length ? i : i - 1;
|
|
32
|
-
return name.slice(0, acronymEnd).toLowerCase() + name.slice(acronymEnd);
|
|
33
|
-
}
|
|
16
|
+
const lowerFirst = lowerFirstPreservingAcronym;
|
|
34
17
|
/** Emit a JS expression that extracts the display value for a descriptor from `r`. */
|
|
35
18
|
function rowAccessor(d) {
|
|
36
19
|
// Descriptors can pre-bake a bespoke accessor (e.g. synthesized per-system
|
|
@@ -34,7 +34,8 @@ function instanceUrl(slice, fields) {
|
|
|
34
34
|
if (childId === sliceFullId + '.url' && typeof f.fixedValue === 'string')
|
|
35
35
|
return f.fixedValue;
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
const firstProfile = slice.profileUrls?.[0];
|
|
38
|
+
return firstProfile ? stripVersionFromCanonicalUrl(firstProfile) : undefined;
|
|
38
39
|
}
|
|
39
40
|
/**
|
|
40
41
|
* The property name a choice slice occupies.
|
|
@@ -87,7 +88,9 @@ export function generateClosedSlicingValidations(fields, profileUrl, baseResourc
|
|
|
87
88
|
if (elemId.includes(':'))
|
|
88
89
|
continue;
|
|
89
90
|
const segments = elemId.split('.');
|
|
90
|
-
const leaf = segments
|
|
91
|
+
const leaf = segments.at(-1);
|
|
92
|
+
if (!leaf)
|
|
93
|
+
continue;
|
|
91
94
|
const kind = classify(leaf);
|
|
92
95
|
if (!kind)
|
|
93
96
|
continue;
|
|
@@ -27,13 +27,19 @@ export function installFhirpathStub(outputDir) {
|
|
|
27
27
|
types: 'index.d.ts',
|
|
28
28
|
}, null, 2));
|
|
29
29
|
// Minimal type declaration — only what the generated validators import
|
|
30
|
+
// `compile` is declared alongside `evaluate`: the emitted Zod schemas compile
|
|
31
|
+
// each invariant once at module load rather than re-parsing it per parse.
|
|
30
32
|
fs.writeFileSync(path.join(pkgDir, 'index.d.ts'), `declare const fhirpath: {
|
|
31
33
|
evaluate(resource: any, expression: string, context?: any, model?: any, options?: any): any[];
|
|
34
|
+
compile(expression: string, model?: any, options?: any): (resource: any, context?: any) => any[];
|
|
32
35
|
};
|
|
33
36
|
export default fhirpath;
|
|
34
37
|
`);
|
|
35
38
|
// Minimal JS stub (default export so ESM import doesn't crash if somehow reached at runtime)
|
|
36
|
-
fs.writeFileSync(path.join(pkgDir, 'index.js'), `export default {
|
|
39
|
+
fs.writeFileSync(path.join(pkgDir, 'index.js'), `export default {
|
|
40
|
+
evaluate() { throw new Error("fhirpath stub — install the real package"); },
|
|
41
|
+
compile() { throw new Error("fhirpath stub — install the real package"); },
|
|
42
|
+
};\n`);
|
|
37
43
|
// Stub fhir-context/<version>/index.js for each supported FHIR version
|
|
38
44
|
for (const ver of FHIR_CONTEXT_VERSIONS) {
|
|
39
45
|
const ctxDir = path.join(pkgDir, 'fhir-context', ver);
|