babelfhir-ts 1.6.9 → 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.
@@ -119,13 +119,17 @@ export const EMITTED_RANGES = {
119
119
  * serializer/renderer pair to apply themes. A 0.2.x renderer would silently
120
120
  * drop them.
121
121
  *
122
- * 0.3.10 is the floor because `AutoFormField` gained `options` there, and the
123
- * emitted form now carries the bound ValueSet's concepts on every coded
124
- * field. Below it the property is not in the type and is ignored by
125
- * `autoForm`, so a required-binding field renders as free text: the form
126
- * still builds, and every constraint the binding states is gone from it.
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.
127
131
  */
128
- prefab: '^0.3.10',
132
+ prefab: '^0.3.12',
129
133
  /**
130
134
  * Generated FHIR client base package (`@babelfhir-ts/client-<slug>`).
131
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
+ }
@@ -89,9 +89,12 @@ function emitFormFields(descriptors) {
89
89
  // Forms can't capture complex nested structures cleanly; skip them.
90
90
  if (d.kind === 'nested' || d.kind === 'json' || d.kind === 'image' || d.kind === 'dicom')
91
91
  continue;
92
- if (d.isArray)
93
- continue; // v1: scalars only
94
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;
95
98
  let type = 'text';
96
99
  if (d.kind === 'number')
97
100
  type = 'number';
@@ -106,6 +109,8 @@ function emitFormFields(descriptors) {
106
109
  extra.push('required: true');
107
110
  if (options)
108
111
  extra.push(`options: ${emitOptionsLiteral(options)}`);
112
+ if (d.isArray)
113
+ extra.push('multiple: true');
109
114
  lines.push(` { ${extra.join(', ')} },`);
110
115
  seen.add(d.name);
111
116
  }
@@ -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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelfhir-ts",
3
- "version": "1.6.9",
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.11",
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",