babelfhir-ts 1.6.8 → 1.6.10

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.
@@ -112,14 +112,24 @@ export const EMITTED_RANGES = {
112
112
  /**
113
113
  * Prefab UI runtime for `--prefab` output.
114
114
  *
115
- * 0.3.0 is the floor: it introduced wire protocol 0.3, where the theme is
116
- * compiled into the top-level `css` array, `stylesheets` carries external
117
- * URLs, and `mode` forces a colour scheme. Emitted renderers pass `theme`
118
- * and spread `PrefabAppOptions` into `PrefabApp`, so they rely on a >= 0.3
115
+ * 0.3.0 introduced wire protocol 0.3, where the theme is compiled into the
116
+ * top-level `css` array, `stylesheets` carries external URLs, and `mode`
117
+ * forces a colour scheme. Emitted renderers pass `theme` and spread
118
+ * `PrefabAppOptions` into `PrefabApp`, so they rely on a >= 0.3
119
119
  * serializer/renderer pair to apply themes. A 0.2.x renderer would silently
120
120
  * drop them.
121
+ *
122
+ * 0.3.12 is the floor, for what the emitted form puts on a coded field.
123
+ * `AutoFormField` gained `options` in 0.3.10 — below that the property is not
124
+ * in the type, `autoForm` ignores it, and a bound field renders as free text.
125
+ * 0.3.12 then moved `required` onto every stateful control and added
126
+ * `Select({ multiple })`: before it, `autoForm` dropped `required` on the
127
+ * Select path because the component could not carry it, and a repeating coded
128
+ * element had no multi-select to render as. On 0.3.10 or 0.3.11 the form still
129
+ * builds and still offers the right choices, but a required element is not
130
+ * marked required and a repeating one takes a single value.
121
131
  */
122
- prefab: '^0.3.0',
132
+ prefab: '^0.3.12',
123
133
  /**
124
134
  * Generated FHIR client base package (`@babelfhir-ts/client-<slug>`).
125
135
  *
@@ -341,6 +341,32 @@ export function mergeFields(newFields, oldFields) {
341
341
  // Return the merged fields as an array
342
342
  return Array.from(fieldMap.values());
343
343
  }
344
+ /**
345
+ * Copies a directory tree, optionally keeping only the files a predicate accepts.
346
+ *
347
+ * Shared by the compile-time dependency providers, which each need part of an
348
+ * installed package inside the output's `node_modules` — the zod runtime whole,
349
+ * prefab's declarations only.
350
+ *
351
+ * @param filter - Called per file with its absolute source path. Omit to copy everything.
352
+ * A directory left empty by the filter is not created.
353
+ */
354
+ export function copyDirRecursive(src, dest, filter) {
355
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
356
+ const srcPath = path.join(src, entry.name);
357
+ const destPath = path.join(dest, entry.name);
358
+ if (entry.isDirectory()) {
359
+ fs.mkdirSync(destPath, { recursive: true });
360
+ copyDirRecursive(srcPath, destPath, filter);
361
+ if (fs.readdirSync(destPath).length === 0)
362
+ fs.rmdirSync(destPath);
363
+ }
364
+ else if (!filter || filter(srcPath)) {
365
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
366
+ fs.copyFileSync(srcPath, destPath);
367
+ }
368
+ }
369
+ }
344
370
  /**
345
371
  * Ensures the given directory exists, creating it if necessary.
346
372
  */
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Makes `@maxhealth.tech/prefab` resolvable while the generated output compiles.
3
+ *
4
+ * `--prefab` emits renderers that import the real prefab API, and the emitted
5
+ * package declares prefab as a dependency — but nothing puts it where the
6
+ * generator's own `tsc` pass can see it. Every emitted renderer then failed
7
+ * TS2307 and the whole package failed to compile, so a `--prefab` run produced
8
+ * no `prefab/` in the shipped package at all. It only ever passed when the
9
+ * output happened to sit inside a tree that already had prefab installed, which
10
+ * is why it looks fine from this repo's own `output/` and fails in CI.
11
+ *
12
+ * The real declarations are used rather than a stub. The emitted renderers are
13
+ * checked against prefab's actual component signatures, and their `.d.ts` quotes
14
+ * prefab's types back to the consumer — a hand-written stub would both drift
15
+ * from the library and erase those types from the emitted package.
16
+ *
17
+ * Two sources, in order: a copy already installed alongside the generator (this
18
+ * repo, where prefab is a devDependency), else a one-package install from the
19
+ * registry (an `npx babelfhir-ts` run, which ships no devDependencies). Only
20
+ * the declarations are copied — `tsc` reads no JavaScript — and whatever this
21
+ * added is removed after the compile, because the consumer installs the real
22
+ * dependency themselves.
23
+ */
24
+ import fs from 'fs';
25
+ import path from 'path';
26
+ import { spawnSync } from 'child_process';
27
+ import { fileURLToPath } from 'url';
28
+ import { copyDirRecursive } from '../../core/utils.js';
29
+ import { EMITTED_RANGES } from '../../core/generatedPackageDeps.js';
30
+ import { logger } from '../../../logger.js';
31
+ const log = logger.withTag('prefab');
32
+ const PACKAGE_NAME = '@maxhealth.tech/prefab';
33
+ const PACKAGE_SEGMENTS = PACKAGE_NAME.split('/');
34
+ /** How far up a tree to look for a `node_modules` holding the package. */
35
+ const MAX_LOOKUP_DEPTH = 8;
36
+ /** The package directory under a given tree root, whether or not it exists. */
37
+ function packageDirIn(root) {
38
+ return path.join(root, 'node_modules', ...PACKAGE_SEGMENTS);
39
+ }
40
+ /** Walk up from `start`, returning the first installed copy found. */
41
+ function findInstalledCopy(start) {
42
+ let dir = start;
43
+ for (let i = 0; i < MAX_LOOKUP_DEPTH; i++) {
44
+ const candidate = packageDirIn(dir);
45
+ if (fs.existsSync(path.join(candidate, 'package.json')))
46
+ return candidate;
47
+ const parent = path.dirname(dir);
48
+ if (parent === dir)
49
+ break;
50
+ dir = parent;
51
+ }
52
+ return undefined;
53
+ }
54
+ /** Install the package into the output dir, declarations included. */
55
+ function installFromRegistry(outputDir) {
56
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
57
+ const spec = `${PACKAGE_NAME}@${EMITTED_RANGES.prefab}`;
58
+ log.debug(`Installing ${spec} for the output typecheck`);
59
+ const result = spawnSync(npm, ['install', '--no-save', '--no-package-lock', '--no-audit', '--no-fund',
60
+ '--prefix', outputDir, spec], { stdio: 'pipe', encoding: 'utf8' });
61
+ if (result.status !== 0) {
62
+ log.warn(`Could not install ${spec}: ${result.stderr?.trim() || `exit ${result.status}`}`);
63
+ return false;
64
+ }
65
+ return fs.existsSync(path.join(packageDirIn(outputDir), 'package.json'));
66
+ }
67
+ /**
68
+ * Ensure the emitted `prefab/` files can be typechecked from `outputDir`.
69
+ *
70
+ * @returns a cleanup that removes what this added, and does nothing when the
71
+ * package was already resolvable.
72
+ */
73
+ export function providePrefabTypes(outputDir) {
74
+ const alreadyResolvable = findInstalledCopy(outputDir);
75
+ if (alreadyResolvable) {
76
+ log.debug(`Typechecking prefab output against ${alreadyResolvable}`);
77
+ return () => { };
78
+ }
79
+ const target = packageDirIn(outputDir);
80
+ const generatorDir = path.dirname(fileURLToPath(import.meta.url));
81
+ const alongsideGenerator = findInstalledCopy(generatorDir);
82
+ if (alongsideGenerator) {
83
+ fs.mkdirSync(target, { recursive: true });
84
+ // Declarations only: `tsc` never reads the JavaScript, and prefab ships
85
+ // browser bundles and source maps that would triple the copy for nothing.
86
+ copyDirRecursive(alongsideGenerator, target, src => src.endsWith('.d.ts') || path.basename(src) === 'package.json');
87
+ }
88
+ else if (!installFromRegistry(outputDir)) {
89
+ throw new Error(`--prefab needs ${PACKAGE_NAME} to typecheck the emitted renderers, and it could not be ` +
90
+ `resolved next to the generator or installed from the registry. Install ` +
91
+ `${PACKAGE_NAME}@${EMITTED_RANGES.prefab} where generation runs, or drop --prefab.`);
92
+ }
93
+ return () => {
94
+ try {
95
+ fs.rmSync(target, { recursive: true, force: true });
96
+ }
97
+ catch { /* ignore */ }
98
+ // Leave `@maxhealth.tech/` behind only while it still holds something else.
99
+ try {
100
+ fs.rmdirSync(path.dirname(target));
101
+ }
102
+ catch { /* not empty — fine */ }
103
+ };
104
+ }
@@ -28,9 +28,9 @@ function ensurePrefabDir(outputDir) {
28
28
  * Emit `prefab/<InterfaceName>Prefab.ts` for a single profile.
29
29
  * Registers the profile so {@link finalizePrefabOutput} can build the barrel.
30
30
  */
31
- export function writePrefabProfile(outputDir, interfaceName, fields, resourceType) {
31
+ export function writePrefabProfile(outputDir, interfaceName, fields, resourceType, valueSets) {
32
32
  const dir = ensurePrefabDir(outputDir);
33
- const { code, fieldCount } = generatePrefabRenderer(interfaceName, fields, { resourceType });
33
+ const { code, fieldCount } = generatePrefabRenderer(interfaceName, fields, { resourceType, valueSets });
34
34
  const filePath = path.join(dir, `${interfaceName}Prefab.ts`);
35
35
  fs.writeFileSync(filePath, code);
36
36
  let map = emittedProfiles.get(outputDir);
@@ -68,6 +68,17 @@ function emitToRowBody(descriptors) {
68
68
  const lines = descriptors.map(d => ` ${d.name}: ${rowAccessor(d)},`);
69
69
  return lines.join('\n');
70
70
  }
71
+ /**
72
+ * Emit an `AutoFormOption[]` literal for a bound field.
73
+ *
74
+ * The label is omitted when the concept carried no display, because prefab
75
+ * already falls back to the value and restating it would only make the
76
+ * generated file longer.
77
+ */
78
+ function emitOptionsLiteral(options) {
79
+ const entries = options.map(o => o.label ? `{ value: ${JSON.stringify(o.value)}, label: ${JSON.stringify(o.label)} }` : `{ value: ${JSON.stringify(o.value)} }`);
80
+ return `[${entries.join(', ')}]`;
81
+ }
71
82
  /** Emit `autoForm` field descriptors. */
72
83
  function emitFormFields(descriptors) {
73
84
  const lines = [];
@@ -78,8 +89,12 @@ function emitFormFields(descriptors) {
78
89
  // Forms can't capture complex nested structures cleanly; skip them.
79
90
  if (d.kind === 'nested' || d.kind === 'json' || d.kind === 'image' || d.kind === 'dicom')
80
91
  continue;
81
- if (d.isArray)
82
- continue; // v1: scalars only
92
+ const options = d.enumOptions?.length ? d.enumOptions : undefined;
93
+ // A repeating element is only expressible as a form field when the choices
94
+ // are enumerated: prefab's multi-select submits a list under the name, which
95
+ // is the element's own shape. Any other array still has no control.
96
+ if (d.isArray && !options)
97
+ continue;
83
98
  let type = 'text';
84
99
  if (d.kind === 'number')
85
100
  type = 'number';
@@ -87,14 +102,15 @@ function emitFormFields(descriptors) {
87
102
  type = 'switch';
88
103
  else if (d.kind === 'date')
89
104
  type = 'date';
90
- else if (d.enumValues && d.enumValues.length > 0)
105
+ else if (options)
91
106
  type = 'select';
92
107
  const extra = [`name: '${d.name}'`, `label: ${JSON.stringify(d.label)}`, `type: '${type}'`];
93
108
  if (d.required)
94
109
  extra.push('required: true');
95
- // NOTE: @maxhealth.tech/prefab AutoFormField does not yet carry an `options`
96
- // property for select inputs; the enum list is elided until the upstream
97
- // type is widened. See prefab-ui#TODO.
110
+ if (options)
111
+ extra.push(`options: ${emitOptionsLiteral(options)}`);
112
+ if (d.isArray)
113
+ extra.push('multiple: true');
98
114
  lines.push(` { ${extra.join(', ')} },`);
99
115
  seen.add(d.name);
100
116
  }
@@ -206,7 +222,7 @@ function emitBrowserTitleExpr(titleDescriptor, interfaceName) {
206
222
  : `selected.dot('${titleDescriptor.name}')`;
207
223
  }
208
224
  export function generatePrefabRenderer(interfaceName, fields, options) {
209
- const rawDescriptors = fields.flatMap(describeField);
225
+ const rawDescriptors = fields.flatMap(f => describeField(f, options?.valueSets));
210
226
  const descriptors = dedupeDescriptors(rawDescriptors);
211
227
  const resourceType = options?.resourceType;
212
228
  const tableDescriptors = pickTableDescriptors(descriptors);
@@ -8,6 +8,8 @@
8
8
  * This module is deliberately small and data-driven so it can later be
9
9
  * extracted into a shared codegen-spec package (see design notes).
10
10
  */
11
+ import { lookupBoundValueSet } from '../requiredBindings.js';
12
+ import { ctx } from '../../fhir/versionContext.js';
11
13
  /** Lower-cased FHIR primitive type codes. */
12
14
  const PRIMITIVE_TEXT = new Set([
13
15
  'string', 'code', 'uri', 'url', 'canonical', 'id', 'oid', 'uuid', 'markdown', 'xhtml',
@@ -23,6 +25,29 @@ function labelFor(name) {
23
25
  const spaced = name.replace(/([A-Z])/g, ' $1').replace(/[._-]/g, ' ').trim();
24
26
  return spaced.charAt(0).toUpperCase() + spaced.slice(1);
25
27
  }
28
+ /**
29
+ * Build the choice list for a bound field, or undefined when there is none to
30
+ * offer.
31
+ *
32
+ * The parsed ValueSet is preferred over the codes the SD parser sampled onto
33
+ * the binding: it carries the concept displays, and it is the whole set rather
34
+ * than a subset. Both paths are capped at the same `STORE_CODES` threshold the
35
+ * parser and the interface emitter already use to decide a ValueSet is small
36
+ * enough to enumerate — past it a Select stops being a usable control and the
37
+ * field stays a free-text input.
38
+ */
39
+ function selectOptionsFor(field, valueSets) {
40
+ const limit = ctx().valueSetThresholds.STORE_CODES;
41
+ const resolved = lookupBoundValueSet(field.binding?.uri, valueSets);
42
+ const concepts = resolved?.valueSet.concepts;
43
+ if (concepts?.length && concepts.length <= limit) {
44
+ return concepts.map(c => ({ value: c.code, ...(c.display ? { label: c.display } : {}) }));
45
+ }
46
+ const sampled = field.binding?.codes?.filter(c => c.code).map(c => ({ value: c.code }));
47
+ if (sampled?.length && sampled.length <= limit)
48
+ return sampled;
49
+ return undefined;
50
+ }
26
51
  function kindForType(fhirType) {
27
52
  if (PRIMITIVE_TEXT.has(fhirType))
28
53
  return 'text';
@@ -86,7 +111,7 @@ function kindForType(fhirType) {
86
111
  * on the typed interface. Detail views for nested backbone elements are a
87
112
  * follow-up once slicing-aware tree rendering lands.
88
113
  */
89
- export function describeField(field) {
114
+ export function describeField(field, valueSets) {
90
115
  if (field.isForbidden)
91
116
  return [];
92
117
  if (!field.type)
@@ -132,7 +157,7 @@ export function describeField(field) {
132
157
  label: labelFor(variantPropName),
133
158
  valueSetUri: field.binding?.uri,
134
159
  bindingStrength: field.binding?.strength,
135
- enumValues: field.binding?.codes?.map(c => c.code).filter(Boolean),
160
+ enumOptions: selectOptionsFor(field, valueSets),
136
161
  });
137
162
  }
138
163
  return out;
@@ -158,7 +183,7 @@ export function describeField(field) {
158
183
  label: labelFor(bareName),
159
184
  valueSetUri: field.binding?.uri,
160
185
  bindingStrength: field.binding?.strength,
161
- enumValues: field.binding?.codes?.map(c => c.code).filter(Boolean),
186
+ enumOptions: selectOptionsFor(field, valueSets),
162
187
  }];
163
188
  }
164
189
  /**
@@ -21,17 +21,31 @@ import { stripVersionFromCanonicalUrl } from '../core/utils.js';
21
21
  import { sanitizeValueSetName } from './valueset/valueSetGenerator.js';
22
22
  /** Types FHIR permits a binding on. */
23
23
  const BINDABLE_TYPES = ['code', 'Coding', 'CodeableConcept', 'Quantity', 'string', 'uri'];
24
- export function resolveRequiredBindingValueSet(bindingUri, valueSets) {
24
+ /**
25
+ * Resolve a binding URI to its parsed ValueSet, versioned key first.
26
+ *
27
+ * A binding may cite `…/ValueSet/foo|1.0.0` while the package indexes the
28
+ * ValueSet under its unversioned canonical, so both keys have to be tried.
29
+ * Every emitter that reads concepts off a binding needs the same two lookups,
30
+ * which is why it lives here rather than in each of them.
31
+ */
32
+ export function lookupBoundValueSet(bindingUri, valueSets) {
25
33
  if (!bindingUri || !valueSets)
26
34
  return undefined;
27
- let vs = valueSets.get(bindingUri);
28
- let resolvedUri = bindingUri;
29
- if (!vs && bindingUri.includes('|')) {
30
- resolvedUri = stripVersionFromCanonicalUrl(bindingUri);
31
- vs = valueSets.get(resolvedUri);
32
- }
33
- if (!vs)
35
+ const direct = valueSets.get(bindingUri);
36
+ if (direct)
37
+ return { valueSet: direct, resolvedUri: bindingUri };
38
+ if (!bindingUri.includes('|'))
39
+ return undefined;
40
+ const strippedUri = stripVersionFromCanonicalUrl(bindingUri);
41
+ const stripped = valueSets.get(strippedUri);
42
+ return stripped ? { valueSet: stripped, resolvedUri: strippedUri } : undefined;
43
+ }
44
+ export function resolveRequiredBindingValueSet(bindingUri, valueSets) {
45
+ const resolved = lookupBoundValueSet(bindingUri, valueSets);
46
+ if (!resolved || !bindingUri)
34
47
  return undefined;
48
+ const { valueSet: vs, resolvedUri } = resolved;
35
49
  const displayName = resolvedUri.split('/').pop() || vs.name;
36
50
  if (vs.concepts.length === 0) {
37
51
  if (vs.systems && vs.systems.length > 0) {
@@ -12,6 +12,7 @@ import path from 'path';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { versionSlug } from '../../fhir/versionContext.js';
14
14
  import { getZodPackageTypes } from './zodTypes.js';
15
+ import { copyDirRecursive } from '../../core/utils.js';
15
16
  /** Resolve the repo root from this file's location (src/generator/emitters/zod/) */
16
17
  function findRepoRoot() {
17
18
  const selfDir = path.dirname(fileURLToPath(import.meta.url));
@@ -26,20 +27,6 @@ function findRepoRoot() {
26
27
  }
27
28
  return path.resolve(selfDir, '..', '..', '..', '..');
28
29
  }
29
- /** Recursively copy a directory's contents, handling nested subdirectories. */
30
- function copyDirRecursive(src, dest) {
31
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
32
- const srcPath = path.join(src, entry.name);
33
- const destPath = path.join(dest, entry.name);
34
- if (entry.isDirectory()) {
35
- fs.mkdirSync(destPath, { recursive: true });
36
- copyDirRecursive(srcPath, destPath);
37
- }
38
- else {
39
- fs.copyFileSync(srcPath, destPath);
40
- }
41
- }
42
- }
43
30
  export function installBaseZodTypes(outputDir) {
44
31
  const slug = versionSlug();
45
32
  const pkgDir = path.join(outputDir, 'node_modules', '@babelfhir-ts', 'zod');
@@ -619,10 +619,19 @@ export async function generateIntoPackage(packageArchivePath, outArchivePath, fl
619
619
  // Install fhirpath type stub so tsc can resolve validator imports
620
620
  const { installFhirpathStub, removeFhirpathStub } = await import('./emitters/validator/fhirpathStubInstaller.js');
621
621
  installFhirpathStub(outputDir);
622
+ // Emitted renderers import the real prefab API, which nothing has installed here yet
623
+ const releasePrefabTypes = flags?.prefab
624
+ ? (await import('./emitters/prefab/prefabDepProvider.js')).providePrefabTypes(outputDir)
625
+ : undefined;
622
626
  // Compile TypeScript to JavaScript
623
627
  endPhase = startPhase('compile');
624
628
  logger.log('Compiling TypeScript to JavaScript...');
625
- await compileTypeScriptToJS(outputDir);
629
+ try {
630
+ await compileTypeScriptToJS(outputDir);
631
+ }
632
+ finally {
633
+ releasePrefabTypes?.();
634
+ }
626
635
  endPhase(); // compile
627
636
  // Remove fhirpath stub — the real package is a peer dependency
628
637
  removeFhirpathStub(outputDir);
@@ -841,9 +850,17 @@ export async function generateIntoPackageDirect(packageArchivePath, flags) {
841
850
  }
842
851
  const { installFhirpathStub, removeFhirpathStub } = await import('./emitters/validator/fhirpathStubInstaller.js');
843
852
  installFhirpathStub(outputDir);
853
+ const releasePrefabTypes = flags?.prefab
854
+ ? (await import('./emitters/prefab/prefabDepProvider.js')).providePrefabTypes(outputDir)
855
+ : undefined;
844
856
  endPhase = startPhase('compile');
845
857
  logger.log('Compiling TypeScript to JavaScript...');
846
- await compileTypeScriptToJS(outputDir);
858
+ try {
859
+ await compileTypeScriptToJS(outputDir);
860
+ }
861
+ finally {
862
+ releasePrefabTypes?.();
863
+ }
847
864
  endPhase(); // compile
848
865
  removeFhirpathStub(outputDir);
849
866
  const babelfhirDir = path.join(outputDir, 'node_modules', '@babelfhir-ts');
@@ -750,7 +750,7 @@ export async function processStructureDefinition(sd, ctx) {
750
750
  }
751
751
  if (flags?.prefab) {
752
752
  const { writePrefabProfile } = await import('./emitters/prefab/prefabEmitter.js');
753
- writePrefabProfile(outputDir, interfaceName, aligned.alignedNewFields, sd.type);
753
+ writePrefabProfile(outputDir, interfaceName, aligned.alignedNewFields, sd.type, valueSets);
754
754
  }
755
755
  logger.log(`Generated artifacts for ${interfaceName}`);
756
756
  return interfaceName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelfhir-ts",
3
- "version": "1.6.8",
3
+ "version": "1.6.10",
4
4
  "description": "BabelFHIR-TS: generate TypeScript interfaces, validators, and helper classes from FHIR R4/R4B/R5 StructureDefinitions (profiles) directly inside package archives.",
5
5
  "type": "module",
6
6
  "main": "out/src/main.js",
@@ -87,7 +87,7 @@
87
87
  "devDependencies": {
88
88
  "@eslint/js": "^10.0.1",
89
89
  "@max-health-inc/config": "^3.1.0",
90
- "@maxhealth.tech/prefab": "^0.3.7",
90
+ "@maxhealth.tech/prefab": "^0.3.12",
91
91
  "@types/node": "^25.9.5",
92
92
  "@types/semver": "^7.8.0",
93
93
  "@types/unzipper": "^0.10.11",