babelfhir-ts 1.4.0 → 1.4.1

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 CHANGED
@@ -240,7 +240,9 @@ Options:
240
240
  --registry <url> FHIR package registry URL (default: https://packages.simplifier.net)
241
241
  --tx-server <url> Terminology server URL for ValueSet expansion (e.g., https://tx.fhir.org/r4)
242
242
  When set, expands ValueSets without explicit codes using $expand operation
243
- --display-language <lang> BCP-47 language for display terms (e.g., de, fr). Passed to $expand displayLanguage
243
+ --display-language <lang> BCP-47 language(s) for display terms (e.g., de or de,fr,en).
244
+ Single value replaces concept displays. Comma-separated values
245
+ also generate a multi-language display map with getDisplay() helper.
244
246
 
245
247
  Examples:
246
248
  babelfhir-ts # Process ./input to ./output
@@ -10,6 +10,7 @@
10
10
  * babelfhir-ts update <package-name@version> # update a single package
11
11
  */
12
12
  import fs from 'fs';
13
+ import os from 'os';
13
14
  import path from 'path';
14
15
  import crypto from 'crypto';
15
16
  import { generateIntoPackageDirect, resetFetchFailureTracking } from '../generator/index.js';
@@ -73,6 +74,10 @@ export async function handleUpdateCommand(opts) {
73
74
  if (updatedPackages.length > 0) {
74
75
  patchBunLockIntegrity(updatedPackages);
75
76
  }
77
+ // 5b. Remove stale node_modules/<pkg> so bun re-extracts from the updated tgz
78
+ if (updatedPackages.length > 0) {
79
+ removeStaleNodeModules(updatedPackages.map((p) => p.generatedName));
80
+ }
76
81
  // 6. Single npm install at the end
77
82
  if (updated > 0) {
78
83
  console.log('\nReinstalling dependencies...');
@@ -298,8 +303,21 @@ export function patchBunLockIntegrity(updatedPackages) {
298
303
  const pm = detectPackageManager();
299
304
  if (!pm.cmd.includes('bun'))
300
305
  return;
301
- const lockPath = path.resolve('bun.lock');
302
- if (!fs.existsSync(lockPath))
306
+ // Walk up to find bun.lock (in monorepos it's at the workspace root, not cwd)
307
+ let lockPath = null;
308
+ let dir = process.cwd();
309
+ while (true) {
310
+ const candidate = path.join(dir, 'bun.lock');
311
+ if (fs.existsSync(candidate)) {
312
+ lockPath = candidate;
313
+ break;
314
+ }
315
+ const parent = path.dirname(dir);
316
+ if (parent === dir)
317
+ break;
318
+ dir = parent;
319
+ }
320
+ if (!lockPath)
303
321
  return;
304
322
  let lockContent = fs.readFileSync(lockPath, 'utf8');
305
323
  let patched = 0;
@@ -348,6 +366,71 @@ function clearBunCacheForPackage(packageName) {
348
366
  dir = parent;
349
367
  }
350
368
  }
369
+ /**
370
+ * Remove node_modules/<pkg> directories, bun cached extractions, and bun global
371
+ * cache entries for each updated package so bun re-extracts from the fresh tgz.
372
+ * Finds the workspace root first, then cleans all node_modules dirs from cwd up.
373
+ */
374
+ function removeStaleNodeModules(packageNames) {
375
+ // Find the workspace root: highest ancestor with a package.json
376
+ let root = process.cwd();
377
+ let search = root;
378
+ while (true) {
379
+ const parent = path.dirname(search);
380
+ if (parent === search)
381
+ break;
382
+ if (fs.existsSync(path.join(parent, 'package.json')))
383
+ root = parent;
384
+ search = parent;
385
+ }
386
+ // Walk from cwd up to (and including) the root, cleaning each node_modules
387
+ let dir = process.cwd();
388
+ while (true) {
389
+ const nm = path.join(dir, 'node_modules');
390
+ for (const name of packageNames) {
391
+ // Remove the symlink / junction / directory itself (lstat detects dangling junctions too)
392
+ const pkgDir = path.join(nm, name);
393
+ let pkgExists = false;
394
+ try {
395
+ fs.lstatSync(pkgDir);
396
+ pkgExists = true;
397
+ }
398
+ catch { /* does not exist */ }
399
+ if (pkgExists) {
400
+ fs.rmSync(pkgDir, { recursive: true, force: true });
401
+ }
402
+ // Remove bun's content-addressed cache entries (node_modules/.bun/<pkg>@*)
403
+ const bunDir = path.join(nm, '.bun');
404
+ if (fs.existsSync(bunDir)) {
405
+ const prefix = `${name}@`;
406
+ try {
407
+ for (const entry of fs.readdirSync(bunDir, { withFileTypes: true })) {
408
+ if (entry.isDirectory() && entry.name.startsWith(prefix)) {
409
+ fs.rmSync(path.join(bunDir, entry.name), { recursive: true, force: true });
410
+ }
411
+ }
412
+ }
413
+ catch { /* ignore read errors */ }
414
+ }
415
+ }
416
+ if (dir === root)
417
+ break;
418
+ const parent = path.dirname(dir);
419
+ if (parent === dir)
420
+ break;
421
+ dir = parent;
422
+ }
423
+ // Clear bun's global install cache (~/.bun/install/cache/<pkg>)
424
+ const bunGlobalCache = path.join(os.homedir(), '.bun', 'install', 'cache');
425
+ if (fs.existsSync(bunGlobalCache)) {
426
+ for (const name of packageNames) {
427
+ const cached = path.join(bunGlobalCache, name);
428
+ if (fs.existsSync(cached)) {
429
+ fs.rmSync(cached, { recursive: true, force: true });
430
+ }
431
+ }
432
+ }
433
+ }
351
434
  // ── Package manager install ─────────────────────────────────────────────────
352
435
  function runInstall() {
353
436
  return new Promise((resolve, reject) => {
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * Generate TypeScript content for a ValueSet
7
7
  */
8
- export function generateValueSetTypeScript(valueSet) {
8
+ export function generateValueSetTypeScript(valueSet, options) {
9
9
  const sanitizedName = sanitizeValueSetName(valueSet.name);
10
10
  const filename = `ValueSet-${sanitizedName}.ts`;
11
11
  // Use the already-computed isSmall flag from vsParser (uses VALUESET_THRESHOLDS.UNION_TYPE)
@@ -88,6 +88,20 @@ export function generateValueSetTypeScript(valueSet) {
88
88
  // For empty ValueSets, add a note
89
89
  content += `/**\n * Note: This ValueSet has no explicitly enumerated codes.\n * It may be defined by filters or external terminology.\n * Runtime validation is not available for this ValueSet.\n */\n`;
90
90
  }
91
+ // Generate multi-language display map if translations are provided
92
+ const translations = options?.displayTranslations;
93
+ if (translations && translations.size > 0) {
94
+ content += `\n/**\n * Multi-language display translations\n * Maps code → language → display string\n */\n`;
95
+ content += `export const ${sanitizedName}Displays: Record<string, Record<string, string>> = {\n`;
96
+ for (const [code, langs] of translations) {
97
+ content += ` ${JSON.stringify(code)}: ${JSON.stringify(langs)},\n`;
98
+ }
99
+ content += `};\n\n`;
100
+ content += `/**\n * Get the display string for a code in a specific language\n */\n`;
101
+ content += `export function get${sanitizedName}Display(code: string, lang: string): string | undefined {\n`;
102
+ content += ` return ${sanitizedName}Displays[code]?.[lang];\n`;
103
+ content += `}\n`;
104
+ }
91
105
  return { filename, content };
92
106
  }
93
107
  /**
@@ -107,8 +107,12 @@ export function collectReferencedDependencyProfiles(mainSDs, dependencySDs) {
107
107
  * Used by generate() and generateIntoPackage() when --tx-server is specified.
108
108
  */
109
109
  export async function expandValueSetsWithTx(valueSets, valueSetCodesMap, structureDefinitions, txServer, displayLanguage) {
110
+ // Parse comma-separated languages: first is primary, multiple trigger display map
111
+ const langs = displayLanguage?.split(',').map(l => l.trim()).filter(Boolean) ?? [];
112
+ const primaryLang = langs[0];
113
+ const multiLangs = langs.length > 1 ? langs : undefined;
110
114
  const cacheDir = getCacheConfig().rootDir;
111
- const txClient = createTxClient(cacheDir, txServer, displayLanguage);
115
+ const txClient = createTxClient(cacheDir, txServer, primaryLang);
112
116
  const bindingUrls = collectValueSetBindingUrls(structureDefinitions);
113
117
  logger.log(`Found ${bindingUrls.size} unique ValueSet bindings in StructureDefinitions`);
114
118
  addPlaceholdersForExternalBindings(valueSets, bindingUrls, getFhirPackagesCacheDir());
@@ -123,6 +127,55 @@ export async function expandValueSetsWithTx(valueSets, valueSetCodesMap, structu
123
127
  }
124
128
  }
125
129
  logger.log(`After tx expansion: ${expanded.size} ValueSets (${Array.from(expanded.values()).filter(vs => vs.concepts.length > 0).length} with codes)`);
130
+ // Multi-language display expansion
131
+ if (multiLangs && multiLangs.length > 0) {
132
+ const multiLangClient = createTxClient(cacheDir, txServer);
133
+ let translatedCount = 0;
134
+ let totalCodes = 0;
135
+ let untranslatedCodes = 0;
136
+ for (const [url, vs] of expanded) {
137
+ if (vs.concepts.length === 0)
138
+ continue;
139
+ const translations = await multiLangClient.expandValueSetMultiLang(url, multiLangs);
140
+ if (translations.size === 0)
141
+ continue;
142
+ // Build canonical display lookup from the primary expansion
143
+ const canonicalDisplay = new Map();
144
+ for (const concept of vs.concepts) {
145
+ if (concept.display)
146
+ canonicalDisplay.set(concept.code, concept.display);
147
+ }
148
+ // Strip languages where the tx server returned the same text as the
149
+ // canonical (English) display — this means no real translation exists.
150
+ for (const [code, langMap] of translations) {
151
+ const baseline = canonicalDisplay.get(code);
152
+ totalCodes++;
153
+ let hasRealTranslation = false;
154
+ for (const lang of Object.keys(langMap)) {
155
+ if (!baseline || langMap[lang] === baseline) {
156
+ delete langMap[lang];
157
+ }
158
+ else {
159
+ hasRealTranslation = true;
160
+ }
161
+ }
162
+ if (!hasRealTranslation) {
163
+ translations.delete(code);
164
+ untranslatedCodes++;
165
+ }
166
+ }
167
+ if (translations.size > 0) {
168
+ vs.displayTranslations = translations;
169
+ translatedCount++;
170
+ }
171
+ }
172
+ if (translatedCount > 0) {
173
+ logger.log(`Generated multi-language display maps for ${translatedCount} ValueSets (${multiLangs.join(', ')})`);
174
+ }
175
+ if (untranslatedCodes > 0) {
176
+ logger.warn(`${untranslatedCodes}/${totalCodes} codes had no translations (tx server returned English for all requested languages)`);
177
+ }
178
+ }
126
179
  return expanded;
127
180
  }
128
181
  /**
@@ -145,7 +198,9 @@ export function emitValueSetFiles(valueSets, outputDir, opts) {
145
198
  if (generatedCount === 0) {
146
199
  ensureDirectoryExists(valueSetOutputDir);
147
200
  }
148
- let { filename, content } = generateValueSetTypeScript(valueSet);
201
+ let { filename, content } = generateValueSetTypeScript(valueSet, {
202
+ displayTranslations: valueSet.displayTranslations,
203
+ });
149
204
  // Handle duplicate filenames by appending a number
150
205
  if (usedFilenames) {
151
206
  const baseFilename = filename.replace('.ts', '');
@@ -652,6 +652,7 @@ export async function generateIntoPackageDirect(packageArchivePath, flags) {
652
652
  ...(originalPkg.canonical && { canonical: originalPkg.canonical }),
653
653
  ...(originalPkg.fhirVersions && { fhirVersions: originalPkg.fhirVersions }),
654
654
  ...(flags?.txServer && { txServer: flags.txServer }),
655
+ ...(flags?.displayLanguage && { displayLanguage: flags.displayLanguage }),
655
656
  ...(flags?.dicomweb && { dicomweb: true }),
656
657
  ...(flags?.noClient && { noClient: true }),
657
658
  ...(flags?.noClasses && { noClasses: true }),
@@ -159,6 +159,56 @@ export class TxClient {
159
159
  }
160
160
  return results;
161
161
  }
162
+ /**
163
+ * Expand a single ValueSet in multiple languages, returning a display map.
164
+ * @returns Map<code, Record<lang, display>>
165
+ */
166
+ async expandValueSetMultiLang(valueSetUrl, languages) {
167
+ const displayMap = new Map();
168
+ for (const lang of languages) {
169
+ const langParam = `&displayLanguage=${encodeURIComponent(lang)}`;
170
+ const expandUrl = `${this.txServer}/ValueSet/$expand?url=${encodeURIComponent(valueSetUrl)}&count=${this.maxCodes}${langParam}`;
171
+ try {
172
+ const controller = new AbortController();
173
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
174
+ const response = await fetch(expandUrl, {
175
+ method: 'GET',
176
+ headers: {
177
+ 'Accept': 'application/fhir+json',
178
+ 'User-Agent': USER_AGENT,
179
+ },
180
+ signal: controller.signal,
181
+ });
182
+ clearTimeout(timeoutId);
183
+ if (!response.ok) {
184
+ log.warn(`Failed to expand ${valueSetUrl} for lang=${lang}: HTTP ${response.status}`);
185
+ continue;
186
+ }
187
+ const valueSet = await response.json();
188
+ if (valueSet.expansion?.contains) {
189
+ for (const item of valueSet.expansion.contains) {
190
+ if (!item.display)
191
+ continue;
192
+ let entry = displayMap.get(item.code);
193
+ if (!entry) {
194
+ entry = {};
195
+ displayMap.set(item.code, entry);
196
+ }
197
+ entry[lang] = item.display;
198
+ }
199
+ }
200
+ }
201
+ catch (err) {
202
+ if (err instanceof Error && err.name === 'AbortError') {
203
+ log.warn(`Timeout expanding ${valueSetUrl} for lang=${lang}`);
204
+ }
205
+ else {
206
+ log.warn(`Error expanding ${valueSetUrl} for lang=${lang}: ${err}`);
207
+ }
208
+ }
209
+ }
210
+ return displayMap;
211
+ }
162
212
  /**
163
213
  * Get statistics about cached expansions
164
214
  */
@@ -129,10 +129,14 @@ export function getUniformSystem(valueSet) {
129
129
  * @returns Updated map with expanded concepts
130
130
  */
131
131
  export async function expandValueSetsFromTx(valueSets, txClient) {
132
- // Find ValueSets that need expansion (have URL but no concepts)
132
+ // Find ValueSets that need expansion:
133
+ // - No concepts yet (always expand)
134
+ // - Has concepts but displayLanguage is set (re-expand to get translated displays)
133
135
  const needsExpansion = [];
134
136
  for (const [url, vs] of valueSets) {
135
- if (vs.concepts.length === 0 && url) {
137
+ if (!url)
138
+ continue;
139
+ if (vs.concepts.length === 0 || txClient.displayLanguage) {
136
140
  needsExpansion.push(url);
137
141
  }
138
142
  }
package/out/src/main.js CHANGED
@@ -64,7 +64,9 @@ function printUsage() {
64
64
  console.log(" --registry <url> FHIR package registry URL (default: https://packages.simplifier.net)");
65
65
  console.log(" --tx-server <url> Terminology server URL for ValueSet expansion (e.g., https://tx.fhir.org/r4)");
66
66
  console.log(" When set, expands ValueSets without explicit codes using $expand operation");
67
- console.log(" --display-language <lang> BCP-47 language for display terms (e.g., de, fr). Passed to $expand displayLanguage");
67
+ console.log(" --display-language <lang> BCP-47 language(s) for display terms (e.g., de or de,fr,en).");
68
+ console.log(" Single value replaces concept displays. Comma-separated values");
69
+ console.log(" also generate a multi-language display map with getDisplay() helper.");
68
70
  console.log("");
69
71
  console.log("Examples:");
70
72
  console.log(" babelfhir-ts # Process ./input to ./output");
@@ -293,7 +295,7 @@ export function parseCliArgs(argv) {
293
295
  i++;
294
296
  continue;
295
297
  }
296
- if (arg === '--display-language') {
298
+ if (arg === '--display-language' || arg === '--display-languages') {
297
299
  const val = argv[i + 1];
298
300
  if (!val || val.startsWith('-'))
299
301
  throw new Error('Missing value for --display-language');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babelfhir-ts",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
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",