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 +3 -1
- package/out/src/cli/updateCommand.js +85 -2
- package/out/src/generator/emitters/valueset/valueSetGenerator.js +15 -1
- package/out/src/generator/generationHelpers.js +57 -2
- package/out/src/generator/index.js +1 -0
- package/out/src/generator/parser/txClient.js +50 -0
- package/out/src/generator/parser/vsParser.js +6 -2
- package/out/src/main.js +4 -2
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
302
|
-
|
|
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,
|
|
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
|
|
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 (
|
|
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,
|
|
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.
|
|
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",
|