babelfhir-ts 1.4.2 → 1.5.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 +3 -0
- package/out/src/cli/installCommand.js +2 -1
- package/out/src/cli/updateCommand.js +91 -35
- package/out/src/generator/core/fetchUtils.js +47 -6
- package/out/src/generator/emitters/prefab/prefabEmitter.js +160 -0
- package/out/src/generator/emitters/prefab/prefabRenderer.js +528 -0
- package/out/src/generator/emitters/prefab/prefabRuntimeAssets.js +492 -0
- package/out/src/generator/emitters/prefab/prefabTypeMapper.js +276 -0
- package/out/src/generator/emitters/validator/validatorConstraintBuilders.js +10 -2
- package/out/src/generator/index.js +40 -2
- package/out/src/generator/parser/packageParser.js +26 -1
- package/out/src/generator/sdProcessor.js +4 -0
- package/out/src/main.js +22 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -233,7 +233,10 @@ Options:
|
|
|
233
233
|
--no-client Skip FHIR client generation (client generated by default)
|
|
234
234
|
--schema <format> Generate schema files alongside outputs (supported: zod)
|
|
235
235
|
--dicomweb Generate DICOMweb helpers typed to ImagingStudy profiles in the IG
|
|
236
|
+
--prefab Generate Prefab UI render functions per profile (@maxhealth.tech/prefab)
|
|
237
|
+
--prefab-styles <path> Path to styles module (.ts/.js) copied into the generated prefab/ folder
|
|
236
238
|
--recursive (update only) Recursively search subdirectories for lib/ folders
|
|
239
|
+
--force (update only) Force regeneration even if version and flags haven't changed
|
|
237
240
|
--outDir <dir> Output directory (alias for second positional argument)
|
|
238
241
|
--fhir-version <ver> FHIR version to target: r4, r4b, or r5 (auto-detected from package if omitted)
|
|
239
242
|
--package <pkg[@version]> Download FHIR package from registry and process it (latest if no version)
|
|
@@ -105,9 +105,10 @@ function npmInstall(packagePath) {
|
|
|
105
105
|
}
|
|
106
106
|
const { args } = result;
|
|
107
107
|
console.log(`Installing package with ${pmName}...`);
|
|
108
|
+
// shell required on Windows for .cmd wrappers; disabled on Unix to prevent injection
|
|
108
109
|
const child = spawn(pm.cmd, args, {
|
|
109
110
|
stdio: 'inherit',
|
|
110
|
-
shell:
|
|
111
|
+
shell: process.platform === 'win32'
|
|
111
112
|
});
|
|
112
113
|
child.on('exit', (code) => {
|
|
113
114
|
if (code === 0) {
|
|
@@ -13,14 +13,14 @@ import fs from 'fs';
|
|
|
13
13
|
import os from 'os';
|
|
14
14
|
import path from 'path';
|
|
15
15
|
import crypto from 'crypto';
|
|
16
|
-
import { generateIntoPackageDirect, resetFetchFailureTracking } from '../generator/index.js';
|
|
16
|
+
import { generateIntoPackageDirect, resetFetchFailureTracking, GENERATOR_VERSION } from '../generator/index.js';
|
|
17
17
|
import { getFhirPackagesCacheDir } from '../generator/core/cacheConfig.js';
|
|
18
18
|
import { resetDiagnostics, buildQualityReport, formatReportSummary } from '../generator/core/sdDiagnostics.js';
|
|
19
19
|
import { detectPackageManager } from './installCommand.js';
|
|
20
20
|
import { spawn } from 'child_process';
|
|
21
21
|
// ── Public entry point ──────────────────────────────────────────────────────
|
|
22
22
|
export async function handleUpdateCommand(opts) {
|
|
23
|
-
const { packageFilter, recursive, generationFlags, registry, downloadPackage, confirm } = opts;
|
|
23
|
+
const { packageFilter, recursive, force, generationFlags, registry, downloadPackage, confirm } = opts;
|
|
24
24
|
const startDir = process.cwd();
|
|
25
25
|
// 1. Discover tgz files
|
|
26
26
|
const libDirs = discoverLibDirs(startDir, recursive ?? false);
|
|
@@ -55,11 +55,17 @@ export async function handleUpdateCommand(opts) {
|
|
|
55
55
|
for (const t of targets)
|
|
56
56
|
console.log(` ${t.igName}@${t.igVersion} → ${path.relative(startDir, t.tgzPath)}`);
|
|
57
57
|
console.log('');
|
|
58
|
-
// 4. Regenerate each package
|
|
58
|
+
// 4. Regenerate each package (skip if already up to date)
|
|
59
59
|
let updated = 0;
|
|
60
|
+
let skipped = 0;
|
|
60
61
|
let failed = 0;
|
|
61
62
|
const updatedPackages = [];
|
|
62
63
|
for (const target of targets) {
|
|
64
|
+
if (!force && isUpToDate(target, generationFlags)) {
|
|
65
|
+
skipped++;
|
|
66
|
+
console.log(`✓ ${target.igName}@${target.igVersion} — already up to date (babelfhir-ts ${target.generatorVersion})`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
63
69
|
try {
|
|
64
70
|
await regeneratePackage(target, generationFlags, registry, downloadPackage);
|
|
65
71
|
updatedPackages.push({ generatedName: target.generatedName, tgzPath: target.tgzPath });
|
|
@@ -70,25 +76,34 @@ export async function handleUpdateCommand(opts) {
|
|
|
70
76
|
console.error(`✗ Failed to update ${target.igName}@${target.igVersion}: ${err.message}`);
|
|
71
77
|
}
|
|
72
78
|
}
|
|
73
|
-
// 5.
|
|
79
|
+
// 5. Remove stale node_modules/<pkg> so bun re-extracts from the updated tgz
|
|
74
80
|
if (updatedPackages.length > 0) {
|
|
75
|
-
|
|
81
|
+
removeStaleNodeModules(updatedPackages.map((p) => p.generatedName));
|
|
76
82
|
}
|
|
77
|
-
//
|
|
83
|
+
// 6. Patch bun.lock integrity hashes BEFORE install so bun sees matching hashes
|
|
78
84
|
if (updatedPackages.length > 0) {
|
|
79
|
-
|
|
85
|
+
patchBunLockIntegrity(updatedPackages);
|
|
80
86
|
}
|
|
81
|
-
//
|
|
87
|
+
// 7. Single npm install at the end
|
|
82
88
|
if (updated > 0) {
|
|
83
89
|
console.log('\nReinstalling dependencies...');
|
|
84
90
|
await runInstall();
|
|
85
91
|
}
|
|
86
|
-
//
|
|
92
|
+
// 8. Re-patch bun.lock integrity after install (bun may recompute hashes)
|
|
93
|
+
if (updatedPackages.length > 0) {
|
|
94
|
+
patchBunLockIntegrity(updatedPackages);
|
|
95
|
+
}
|
|
96
|
+
// 9. Summary
|
|
87
97
|
console.log('');
|
|
88
98
|
if (updated > 0)
|
|
89
99
|
console.log(`✓ Updated ${updated} package${updated === 1 ? '' : 's'}`);
|
|
100
|
+
if (skipped > 0)
|
|
101
|
+
console.log(`✓ ${skipped} package${skipped === 1 ? '' : 's'} already up to date`);
|
|
90
102
|
if (failed > 0)
|
|
91
103
|
console.error(`✗ ${failed} package${failed === 1 ? '' : 's'} failed`);
|
|
104
|
+
if (skipped > 0 && updated === 0 && failed === 0) {
|
|
105
|
+
console.log(' Use --force to regenerate anyway.');
|
|
106
|
+
}
|
|
92
107
|
return {};
|
|
93
108
|
}
|
|
94
109
|
// ── Install redirect prompt ─────────────────────────────────────────────────
|
|
@@ -200,10 +215,15 @@ async function readTgzMetadata(tgzPath, listFn) {
|
|
|
200
215
|
igVersion,
|
|
201
216
|
...(pkg?.fhir?.txServer && { txServer: pkg.fhir.txServer }),
|
|
202
217
|
...(pkg?.fhir?.displayLanguage && { displayLanguage: pkg.fhir.displayLanguage }),
|
|
218
|
+
...(pkg?.fhir?.fhirVersion && { fhirVersion: pkg.fhir.fhirVersion }),
|
|
203
219
|
...(pkg?.fhir?.dicomweb && { dicomweb: true }),
|
|
204
220
|
...(pkg?.fhir?.noClient && { noClient: true }),
|
|
205
221
|
...(pkg?.fhir?.noClasses && { noClasses: true }),
|
|
206
222
|
...(pkg?.fhir?.schema && { schema: pkg.fhir.schema }),
|
|
223
|
+
// Extract generator version from devDependencies (e.g. "^1.4.2" → "1.4.2")
|
|
224
|
+
...(pkg?.devDependencies?.['babelfhir-ts'] && {
|
|
225
|
+
generatorVersion: pkg.devDependencies['babelfhir-ts'].replace(/^[^\d]*/, ''),
|
|
226
|
+
}),
|
|
207
227
|
};
|
|
208
228
|
}
|
|
209
229
|
catch {
|
|
@@ -222,6 +242,32 @@ function filterBySpec(all, spec) {
|
|
|
222
242
|
// No version — match by name only
|
|
223
243
|
return all.filter(t => t.igName === spec);
|
|
224
244
|
}
|
|
245
|
+
// ── Up-to-date check ────────────────────────────────────────────────────────
|
|
246
|
+
/**
|
|
247
|
+
* Check whether a package was already generated with the current babelfhir-ts
|
|
248
|
+
* version and the same effective flags. Returns true if regeneration can be skipped.
|
|
249
|
+
*/
|
|
250
|
+
function isUpToDate(target, cliFlags) {
|
|
251
|
+
// No stored version → was generated before this feature existed → regenerate
|
|
252
|
+
if (!target.generatorVersion)
|
|
253
|
+
return false;
|
|
254
|
+
// Compare generator versions (strip pre-release suffixes for comparison: 1.4.2-dev.xxx → 1.4.2)
|
|
255
|
+
const storedMajorMinorPatch = target.generatorVersion.split('-')[0];
|
|
256
|
+
const currentMajorMinorPatch = GENERATOR_VERSION.split('-')[0];
|
|
257
|
+
if (storedMajorMinorPatch !== currentMajorMinorPatch)
|
|
258
|
+
return false;
|
|
259
|
+
// Compare generation flags that affect output
|
|
260
|
+
const flagKeys = ['txServer', 'displayLanguage', 'fhirVersion', 'dicomweb', 'noClient', 'noClasses', 'schema'];
|
|
261
|
+
for (const key of flagKeys) {
|
|
262
|
+
const stored = target[key];
|
|
263
|
+
const cli = cliFlags[key];
|
|
264
|
+
// CLI flag explicitly set and differs from stored → needs regeneration
|
|
265
|
+
if (cli !== undefined && cli !== null && cli !== false && cli !== '' && String(cli) !== String(stored ?? '')) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
225
271
|
// ── Regeneration ────────────────────────────────────────────────────────────
|
|
226
272
|
async function regeneratePackage(target, generationFlags, registry, downloadPackage) {
|
|
227
273
|
const spec = `${target.igName}@${target.igVersion}`;
|
|
@@ -238,6 +284,10 @@ async function regeneratePackage(target, generationFlags, registry, downloadPack
|
|
|
238
284
|
mergedFlags.displayLanguage = target.displayLanguage;
|
|
239
285
|
console.log(` Using display-language from package metadata: ${target.displayLanguage}`);
|
|
240
286
|
}
|
|
287
|
+
if (target.fhirVersion && !mergedFlags.fhirVersion) {
|
|
288
|
+
mergedFlags.fhirVersion = target.fhirVersion;
|
|
289
|
+
console.log(` Using fhir-version from package metadata: ${target.fhirVersion}`);
|
|
290
|
+
}
|
|
241
291
|
if (target.dicomweb && mergedFlags.dicomweb === undefined)
|
|
242
292
|
mergedFlags.dicomweb = true;
|
|
243
293
|
if (target.noClient && mergedFlags.noClient === undefined)
|
|
@@ -303,41 +353,46 @@ export function patchBunLockIntegrity(updatedPackages) {
|
|
|
303
353
|
const pm = detectPackageManager();
|
|
304
354
|
if (!pm.cmd.includes('bun'))
|
|
305
355
|
return;
|
|
306
|
-
//
|
|
307
|
-
|
|
356
|
+
// Collect ALL bun.lock files walking up (monorepos may have one at the
|
|
357
|
+
// workspace root AND one in a nested app directory).
|
|
358
|
+
const lockPaths = [];
|
|
308
359
|
let dir = process.cwd();
|
|
309
360
|
while (true) {
|
|
310
361
|
const candidate = path.join(dir, 'bun.lock');
|
|
311
|
-
if (fs.existsSync(candidate))
|
|
312
|
-
|
|
313
|
-
break;
|
|
314
|
-
}
|
|
362
|
+
if (fs.existsSync(candidate))
|
|
363
|
+
lockPaths.push(candidate);
|
|
315
364
|
const parent = path.dirname(dir);
|
|
316
365
|
if (parent === dir)
|
|
317
366
|
break;
|
|
318
367
|
dir = parent;
|
|
319
368
|
}
|
|
320
|
-
if (
|
|
369
|
+
if (lockPaths.length === 0)
|
|
321
370
|
return;
|
|
322
|
-
let
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
371
|
+
let totalPatched = 0;
|
|
372
|
+
for (const lockPath of lockPaths) {
|
|
373
|
+
let lockContent = fs.readFileSync(lockPath, 'utf8');
|
|
374
|
+
let patched = 0;
|
|
375
|
+
for (const { generatedName, tgzPath } of updatedPackages) {
|
|
376
|
+
// Compute new SHA-512 integrity hash
|
|
377
|
+
const tgzBytes = fs.readFileSync(tgzPath);
|
|
378
|
+
const hash = crypto.createHash('sha512').update(tgzBytes).digest('base64');
|
|
379
|
+
const newIntegrity = `sha512-${hash}`;
|
|
380
|
+
// Match entries with existing integrity hashes and replace them:
|
|
381
|
+
// "hl7.fhir.uv.ips-generated": [..., "sha512-oldHash=="],
|
|
382
|
+
const pattern = new RegExp(`("(?:[^"]*\\/)?${escapeRegExp(generatedName)}"\\s*:\\s*\\[.*?)(sha512-[A-Za-z0-9+/]+=*)`, 'g');
|
|
383
|
+
lockContent = lockContent.replace(pattern, (_, prefix, _oldHash) => {
|
|
384
|
+
patched++;
|
|
385
|
+
return `${prefix}${newIntegrity}`;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
if (patched > 0) {
|
|
389
|
+
fs.writeFileSync(lockPath, lockContent, 'utf8');
|
|
390
|
+
totalPatched += patched;
|
|
391
|
+
console.log(`Patched ${patched} integrity hash${patched === 1 ? '' : 'es'} in ${path.relative(process.cwd(), lockPath) || 'bun.lock'}`);
|
|
392
|
+
}
|
|
337
393
|
}
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
console.log(`Patched ${patched} integrity hash${patched === 1 ? '' : 'es'} in bun.lock`);
|
|
394
|
+
if (totalPatched === 0) {
|
|
395
|
+
console.log('No integrity hashes found to patch in bun.lock');
|
|
341
396
|
}
|
|
342
397
|
}
|
|
343
398
|
function escapeRegExp(s) {
|
|
@@ -437,7 +492,8 @@ function runInstall() {
|
|
|
437
492
|
const pm = detectPackageManager();
|
|
438
493
|
const pmName = path.basename(pm.cmd).replace(/\.(cmd|exe)$/, '');
|
|
439
494
|
console.log(`Running ${pmName} install...`);
|
|
440
|
-
|
|
495
|
+
// shell required on Windows for .cmd wrappers; disabled on Unix to prevent injection
|
|
496
|
+
const child = spawn(pm.cmd, ['install'], { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
441
497
|
child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`${pmName} install failed (exit ${code})`)));
|
|
442
498
|
child.on('error', (err) => reject(new Error(`Failed to run ${pmName}: ${err.message}`)));
|
|
443
499
|
});
|
|
@@ -5,6 +5,37 @@
|
|
|
5
5
|
import http from "http";
|
|
6
6
|
import https from "https";
|
|
7
7
|
import { USER_AGENT, FETCH_TIMEOUT_MS } from './constants.js';
|
|
8
|
+
/** Maximum number of HTTP redirects to follow before aborting. */
|
|
9
|
+
const MAX_REDIRECTS = 10;
|
|
10
|
+
/**
|
|
11
|
+
* Validate that a URL uses an allowed scheme (http/https) and does not target
|
|
12
|
+
* private/internal network addresses (SSRF mitigation).
|
|
13
|
+
*/
|
|
14
|
+
export function assertSafeUrl(url) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = new URL(url);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
21
|
+
}
|
|
22
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
23
|
+
throw new Error(`Blocked request to non-HTTP URL: ${url}`);
|
|
24
|
+
}
|
|
25
|
+
const host = parsed.hostname.toLowerCase();
|
|
26
|
+
if (host === 'localhost' ||
|
|
27
|
+
host === '127.0.0.1' ||
|
|
28
|
+
host === '::1' ||
|
|
29
|
+
host === '0.0.0.0' ||
|
|
30
|
+
host.startsWith('169.254.') ||
|
|
31
|
+
host.startsWith('10.') ||
|
|
32
|
+
host.startsWith('192.168.') ||
|
|
33
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
|
34
|
+
host.endsWith('.internal') ||
|
|
35
|
+
host.endsWith('.local')) {
|
|
36
|
+
throw new Error(`Blocked request to private/internal address: ${host}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
8
39
|
/**
|
|
9
40
|
* Default headers for FHIR API requests
|
|
10
41
|
*/
|
|
@@ -84,15 +115,20 @@ export async function fetchArrayBuffer(url, options = {}) {
|
|
|
84
115
|
* Fetch with native https module for streaming (used by downloadFile)
|
|
85
116
|
* This provides better control for large file downloads
|
|
86
117
|
*/
|
|
87
|
-
export function fetchStream(url) {
|
|
118
|
+
export function fetchStream(url, redirectCount = 0) {
|
|
119
|
+
assertSafeUrl(url);
|
|
88
120
|
return new Promise((resolve, reject) => {
|
|
89
121
|
const client = url.startsWith('https') ? https : http;
|
|
90
122
|
client.get(url, {
|
|
91
123
|
headers: FHIR_HEADERS
|
|
92
124
|
}, (response) => {
|
|
93
|
-
// Handle redirects
|
|
125
|
+
// Handle redirects with depth limit
|
|
94
126
|
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
95
|
-
|
|
127
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
128
|
+
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
fetchStream(response.headers.location, redirectCount + 1).then(resolve).catch(reject);
|
|
96
132
|
return;
|
|
97
133
|
}
|
|
98
134
|
if (response.statusCode !== 200) {
|
|
@@ -107,7 +143,8 @@ export function fetchStream(url) {
|
|
|
107
143
|
* Fetch JSON using native https module with timeout support
|
|
108
144
|
* Used for fallback URLs where we need more control
|
|
109
145
|
*/
|
|
110
|
-
export function fetchJsonNative(url, options = {}) {
|
|
146
|
+
export function fetchJsonNative(url, options = {}, redirectCount = 0) {
|
|
147
|
+
assertSafeUrl(url);
|
|
111
148
|
return new Promise((resolve, reject) => {
|
|
112
149
|
const client = url.startsWith('https') ? https : http;
|
|
113
150
|
const req = client.get(url, {
|
|
@@ -117,9 +154,13 @@ export function fetchJsonNative(url, options = {}) {
|
|
|
117
154
|
},
|
|
118
155
|
timeout: options.timeout || FETCH_TIMEOUT_MS
|
|
119
156
|
}, (res) => {
|
|
120
|
-
// Handle redirects
|
|
157
|
+
// Handle redirects with depth limit
|
|
121
158
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
122
|
-
|
|
159
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
160
|
+
reject(new Error(`Too many redirects (max ${MAX_REDIRECTS})`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
fetchJsonNative(res.headers.location, options, redirectCount + 1).then(resolve).catch(reject);
|
|
123
164
|
return;
|
|
124
165
|
}
|
|
125
166
|
if (res.statusCode !== 200) {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefab UI emitter — writes per-profile render functions and the shared
|
|
3
|
+
* `prefab/` runtime files (fhirFormat.ts, styles.ts, index.ts).
|
|
4
|
+
*
|
|
5
|
+
* Called twice per generation pipeline:
|
|
6
|
+
* - {@link writePrefabProfile} once per processed profile during sdProcessor emission
|
|
7
|
+
* - {@link finalizePrefabOutput} once after all profiles, to drop shared files + barrel
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { generatePrefabRenderer } from './prefabRenderer.js';
|
|
12
|
+
import { FHIR_FORMAT_TS, DEFAULT_STYLES_TS, REGISTER_FHIR_PIPES_TS, buildPrefabIndexTs, } from './prefabRuntimeAssets.js';
|
|
13
|
+
import { logger } from '../../../logger.js';
|
|
14
|
+
const log = logger.withTag('prefab');
|
|
15
|
+
/** In-memory registry of profiles for which we've written renderer files. outputDir → (interfaceName → resourceType) */
|
|
16
|
+
const emittedProfiles = new Map();
|
|
17
|
+
function prefabDirFor(outputDir) {
|
|
18
|
+
return path.join(outputDir, 'prefab');
|
|
19
|
+
}
|
|
20
|
+
function ensurePrefabDir(outputDir) {
|
|
21
|
+
const dir = prefabDirFor(outputDir);
|
|
22
|
+
if (!fs.existsSync(dir))
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Emit `prefab/<InterfaceName>Prefab.ts` for a single profile.
|
|
28
|
+
* Registers the profile so {@link finalizePrefabOutput} can build the barrel.
|
|
29
|
+
*/
|
|
30
|
+
export function writePrefabProfile(outputDir, interfaceName, fields, resourceType) {
|
|
31
|
+
const dir = ensurePrefabDir(outputDir);
|
|
32
|
+
const { code, fieldCount } = generatePrefabRenderer(interfaceName, fields);
|
|
33
|
+
const filePath = path.join(dir, `${interfaceName}Prefab.ts`);
|
|
34
|
+
fs.writeFileSync(filePath, code);
|
|
35
|
+
let map = emittedProfiles.get(outputDir);
|
|
36
|
+
if (!map) {
|
|
37
|
+
map = new Map();
|
|
38
|
+
emittedProfiles.set(outputDir, map);
|
|
39
|
+
}
|
|
40
|
+
map.set(interfaceName, resourceType ?? interfaceName);
|
|
41
|
+
log.debug(`Emitted prefab renderer for ${interfaceName} (${fieldCount} fields)`);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Write shared runtime assets (`fhirFormat.ts`, `styles.ts`, `index.ts`) and
|
|
45
|
+
* the barrel exporting every profile renderer.
|
|
46
|
+
*
|
|
47
|
+
* When `prefabStylesPath` is provided, its contents are copied verbatim as
|
|
48
|
+
* `prefab/styles.ts` so the user's theme travels with the generated package.
|
|
49
|
+
*/
|
|
50
|
+
export function finalizePrefabOutput(outputDir, opts = {}) {
|
|
51
|
+
const profileMap = emittedProfiles.get(outputDir);
|
|
52
|
+
if (!profileMap || profileMap.size === 0) {
|
|
53
|
+
log.debug('finalizePrefabOutput: no profiles emitted, skipping');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const names = [...profileMap.keys()];
|
|
57
|
+
const dir = ensurePrefabDir(outputDir);
|
|
58
|
+
fs.writeFileSync(path.join(dir, 'fhirFormat.ts'), FHIR_FORMAT_TS);
|
|
59
|
+
fs.writeFileSync(path.join(dir, 'registerFhirPipes.ts'), REGISTER_FHIR_PIPES_TS);
|
|
60
|
+
fs.writeFileSync(path.join(dir, 'styles.ts'), resolveStylesContent(opts.prefabStylesPath));
|
|
61
|
+
fs.writeFileSync(path.join(dir, 'index.ts'), buildPrefabIndexTs(names));
|
|
62
|
+
fs.writeFileSync(path.join(dir, 'mcpViews.ts'), buildMcpViewsTs(profileMap));
|
|
63
|
+
log.success(`Generated prefab UI renderers for ${names.length} profile(s)${opts.prefabStylesPath ? ` (using styles from ${opts.prefabStylesPath})` : ''}`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the styles file content.
|
|
67
|
+
* - If `prefabStylesPath` is given and exists, its contents are returned.
|
|
68
|
+
* - Otherwise, the baked-in default is returned.
|
|
69
|
+
*/
|
|
70
|
+
function resolveStylesContent(prefabStylesPath) {
|
|
71
|
+
if (!prefabStylesPath)
|
|
72
|
+
return DEFAULT_STYLES_TS;
|
|
73
|
+
const resolved = path.resolve(prefabStylesPath);
|
|
74
|
+
if (!fs.existsSync(resolved)) {
|
|
75
|
+
log.warn(`--prefab-styles file not found: ${resolved} — falling back to defaults`);
|
|
76
|
+
return DEFAULT_STYLES_TS;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return fs.readFileSync(resolved, 'utf8');
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
log.warn(`Failed to read --prefab-styles file: ${err.message} — using defaults`);
|
|
83
|
+
return DEFAULT_STYLES_TS;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Test / CI helper: reset the in-memory registry between generations. */
|
|
87
|
+
export function resetPrefabState() {
|
|
88
|
+
emittedProfiles.clear();
|
|
89
|
+
}
|
|
90
|
+
// ── 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
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Build the `mcpViews.ts` source — a `FhirViewRegistry` that maps FHIR
|
|
108
|
+
* resource types to their generated prefab table/detail components.
|
|
109
|
+
*
|
|
110
|
+
* When multiple profiles target the same base resource type (e.g.
|
|
111
|
+
* CHCorePatient and CHCorePatientEPR both extend Patient), the first
|
|
112
|
+
* one wins. Users can override via programmatic API if needed.
|
|
113
|
+
*/
|
|
114
|
+
function buildMcpViewsTs(profiles) {
|
|
115
|
+
// Group by resource type — first profile per type wins
|
|
116
|
+
const byResourceType = new Map();
|
|
117
|
+
for (const [interfaceName, resourceType] of profiles) {
|
|
118
|
+
if (!byResourceType.has(resourceType)) {
|
|
119
|
+
byResourceType.set(resourceType, interfaceName);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const imports = [];
|
|
123
|
+
const tableEntries = [];
|
|
124
|
+
const detailEntries = [];
|
|
125
|
+
for (const [resourceType, interfaceName] of byResourceType) {
|
|
126
|
+
const varBase = lowerFirst(interfaceName);
|
|
127
|
+
imports.push(`import { ${varBase}Table, ${varBase}Detail } from './${interfaceName}Prefab.js';`);
|
|
128
|
+
tableEntries.push(` ${JSON.stringify(resourceType)}: (resources) => ${varBase}Table(resources as any),`);
|
|
129
|
+
detailEntries.push(` ${JSON.stringify(resourceType)}: (resource) => ${varBase}Detail(resource as any),`);
|
|
130
|
+
}
|
|
131
|
+
return `/**
|
|
132
|
+
* AUTO-GENERATED by babelfhir-ts --prefab — do not edit.
|
|
133
|
+
*
|
|
134
|
+
* FhirViewRegistry mapping FHIR resource types to profile-specific
|
|
135
|
+
* prefab components. Pass to \`createFhirMcpServer({ views })\` for
|
|
136
|
+
* rich, profile-aware UIs instead of generic auto-layout.
|
|
137
|
+
*
|
|
138
|
+
* @see https://babelfhir-ts.dev/generated-code
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
import type { FhirViewRegistry } from '@babelfhir-ts/mcp';
|
|
142
|
+
${imports.join('\n')}
|
|
143
|
+
|
|
144
|
+
const TABLE_MAP: Record<string, (resources: unknown[]) => unknown> = {
|
|
145
|
+
${tableEntries.join('\n')}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const DETAIL_MAP: Record<string, (resource: unknown) => unknown> = {
|
|
149
|
+
${detailEntries.join('\n')}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const views: FhirViewRegistry = {
|
|
153
|
+
table: (type, resources) => TABLE_MAP[type]?.(resources),
|
|
154
|
+
detail: (resource) => {
|
|
155
|
+
const type = (resource as { resourceType?: string }).resourceType;
|
|
156
|
+
return type ? DETAIL_MAP[type]?.(resource) : undefined;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
`;
|
|
160
|
+
}
|