diff --git a/libs/native-federation/src/builders/build/builder.ts b/libs/native-federation/src/builders/build/builder.ts index ba9ce036..e1eefa30 100644 --- a/libs/native-federation/src/builders/build/builder.ts +++ b/libs/native-federation/src/builders/build/builder.ts @@ -43,7 +43,11 @@ import { } from '../../utils/mem-resuts'; import { FederationInfo } from '@softarc/native-federation-runtime'; import { PluginBuild } from 'esbuild'; -import { getI18nConfig, translateFederationArtefacts } from '../../utils/i18n'; +import { + getI18nConfig, + registerAngularLocaleDataInFederationConfig, + translateFederationArtefacts, +} from '../../utils/i18n'; import { RebuildHubs } from '../../utils/rebuild-events'; import { createSharedMappingsPlugin } from '../../utils/shared-mappings-plugin'; import { updateScriptTags } from '../../utils/updateIndexHtml'; @@ -235,6 +239,20 @@ export async function* runBuilder( const config = await loadFederationConfig(fedOptions); logger.measure(start, 'To load the federation config.'); + // When `i18n.sourceLocale` (or an inline locale) resolves to a non-English + // code, Angular's application builder emits bare specifiers of the form + // `@angular/common/locales/global/` that normally rely on vite's + // dep-prebundling at dev time. Native Federation replaces that resolution + // layer, so we have to surface those locale data files through the + // federation's importmap explicitly. + const inlineLocaleFilter = Array.isArray(localeFilter) ? localeFilter : []; + registerAngularLocaleDataInFederationConfig( + config, + i18n, + context.workspaceRoot, + inlineLocaleFilter, + ); + const externals = getExternals(config); const plugins = [ createSharedMappingsPlugin(config.sharedMappings), diff --git a/libs/native-federation/src/utils/i18n.spec.ts b/libs/native-federation/src/utils/i18n.spec.ts new file mode 100644 index 00000000..3b567034 --- /dev/null +++ b/libs/native-federation/src/utils/i18n.spec.ts @@ -0,0 +1,232 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// The full `@softarc/native-federation/build` barrel pulls in `chalk` (ESM-only), +// which jest cannot parse in the default config. We only need a minimal logger +// surface in i18n.ts, so stub the barrel here. +jest.mock('@softarc/native-federation/build', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + verbose: jest.fn(), + notice: jest.fn(), + measure: jest.fn(), + }, +})); + +import type { NormalizedFederationConfig } from '@softarc/native-federation/build'; + +import { + I18nConfig, + registerAngularLocaleDataInFederationConfig, + resolveAngularLocaleData, +} from './i18n'; + +function makeFakeWorkspace(locales: string[], version = '17.3.0'): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-i18n-spec-')); + const globalDir = path.join( + root, + 'node_modules', + '@angular', + 'common', + 'locales', + 'global', + ); + fs.mkdirSync(globalDir, { recursive: true }); + for (const locale of locales) { + fs.writeFileSync(path.join(globalDir, `${locale}.js`), '/* fake */'); + } + fs.writeFileSync( + path.join(root, 'node_modules', '@angular', 'common', 'package.json'), + JSON.stringify({ name: '@angular/common', version }), + ); + return root; +} + +function emptyConfig(): NormalizedFederationConfig { + return { + name: 'test', + exposes: {}, + shared: {}, + sharedMappings: [], + skip: { strings: new Set(), functions: [], regexps: [] }, + externals: [], + features: { mappingVersion: false, ignoreUnusedDeps: false }, + }; +} + +describe('resolveAngularLocaleData', () => { + it('returns null for built-in english locales', () => { + const root = makeFakeWorkspace(['de-CH']); + expect(resolveAngularLocaleData('en', root)).toBeNull(); + expect(resolveAngularLocaleData('en-US', root)).toBeNull(); + }); + + it('resolves an exact match', () => { + const root = makeFakeWorkspace(['de-CH'], '17.3.0'); + const result = resolveAngularLocaleData('de-CH', root); + expect(result).not.toBeNull(); + expect(result!.packageName).toBe('@angular/common/locales/global/de-CH'); + expect(result!.matchedCode).toBe('de-CH'); + expect(result!.entryPoint).toBe( + 'node_modules/@angular/common/locales/global/de-CH.js', + ); + expect(result!.version).toBe('17.3.0'); + }); + + it('falls back to a shorter locale tag when the exact code is missing', () => { + // Mirrors angular's i18n-locale-plugin behaviour: de-XYZ → de + const root = makeFakeWorkspace(['de']); + const result = resolveAngularLocaleData('de-XYZ', root); + expect(result).not.toBeNull(); + expect(result!.matchedCode).toBe('de'); + expect(result!.packageName).toBe('@angular/common/locales/global/de'); + }); + + it('returns null when the locale cannot be resolved at all', () => { + const root = makeFakeWorkspace([]); + expect(resolveAngularLocaleData('de-CH', root)).toBeNull(); + }); +}); + +describe('registerAngularLocaleDataInFederationConfig', () => { + it('does nothing when i18n is undefined', () => { + const root = makeFakeWorkspace(['de-CH']); + const config = emptyConfig(); + const registered = registerAngularLocaleDataInFederationConfig( + config, + undefined, + root, + ); + expect(registered).toEqual([]); + expect(Object.keys(config.shared)).toEqual([]); + }); + + it('does nothing for a default en-US source locale', () => { + const root = makeFakeWorkspace(['de-CH']); + const config = emptyConfig(); + const i18n: I18nConfig = { sourceLocale: 'en-US', locales: {} }; + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ); + expect(registered).toEqual([]); + expect(Object.keys(config.shared)).toEqual([]); + }); + + it('registers a shared entry for an object-form non-english sourceLocale', () => { + const root = makeFakeWorkspace(['de-CH']); + const config = emptyConfig(); + const i18n: I18nConfig = { + sourceLocale: { code: 'de-CH', baseHref: '/de/' }, + locales: {}, + }; + + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ); + + expect(registered).toEqual(['@angular/common/locales/global/de-CH']); + const entry = config.shared['@angular/common/locales/global/de-CH']; + expect(entry).toBeDefined(); + expect(entry.platform).toBe('browser'); + expect(entry.build).toBe('default'); + expect(entry.packageInfo?.entryPoint).toBe( + 'node_modules/@angular/common/locales/global/de-CH.js', + ); + }); + + it('also handles the string-form sourceLocale (regression: bug is not specific to object form)', () => { + const root = makeFakeWorkspace(['fr-CH']); + const config = emptyConfig(); + const i18n: I18nConfig = { sourceLocale: 'fr-CH', locales: {} }; + + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ); + + expect(registered).toEqual(['@angular/common/locales/global/fr-CH']); + }); + + it('registers inline locales requested via the dev-server locale filter', () => { + const root = makeFakeWorkspace(['de-CH', 'fr-CH']); + const config = emptyConfig(); + const i18n: I18nConfig = { + sourceLocale: { code: 'de-CH' }, + locales: { 'fr-CH': { translation: 'messages.fr-CH.xlf' } }, + }; + + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ['fr-CH'], + ); + + expect(new Set(registered)).toEqual( + new Set([ + '@angular/common/locales/global/de-CH', + '@angular/common/locales/global/fr-CH', + ]), + ); + }); + + it('does not overwrite an entry the user already configured', () => { + const root = makeFakeWorkspace(['de-CH']); + const config = emptyConfig(); + const userEntry = { + singleton: true, + strictVersion: true, + requiredVersion: 'auto', + platform: 'browser' as const, + build: 'default' as const, + packageInfo: { + entryPoint: 'custom/path.js', + version: '0.0.0', + esm: true, + }, + }; + config.shared['@angular/common/locales/global/de-CH'] = userEntry; + const i18n: I18nConfig = { + sourceLocale: { code: 'de-CH' }, + locales: {}, + }; + + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ); + + expect(registered).toEqual([]); + expect(config.shared['@angular/common/locales/global/de-CH']).toBe( + userEntry, + ); + }); + + it('skips locales that cannot be resolved on disk but still processes the rest', () => { + const root = makeFakeWorkspace(['de-CH']); + const config = emptyConfig(); + const i18n: I18nConfig = { + sourceLocale: { code: 'de-CH' }, + locales: {}, + }; + + const registered = registerAngularLocaleDataInFederationConfig( + config, + i18n, + root, + ['xx-YY'], // not present on disk + ); + + expect(registered).toEqual(['@angular/common/locales/global/de-CH']); + }); +}); diff --git a/libs/native-federation/src/utils/i18n.ts b/libs/native-federation/src/utils/i18n.ts index 763dd085..09ba24ec 100644 --- a/libs/native-federation/src/utils/i18n.ts +++ b/libs/native-federation/src/utils/i18n.ts @@ -1,5 +1,8 @@ import { BuilderContext } from '@angular-devkit/architect'; -import { logger } from '@softarc/native-federation/build'; +import { + logger, + NormalizedFederationConfig, +} from '@softarc/native-federation/build'; import { execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; @@ -132,3 +135,164 @@ function ensureDistFolders(locales: string[], outputPath: string) { fs.mkdirSync(localePath, { recursive: true }); } } + +const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +// Angular's framework ships `en`/`en-US` data inline; the locale-data plugin +// short-circuits these and never emits a bare specifier for them. +// See: @angular/build/src/tools/esbuild/i18n-locale-plugin.ts +function isBuiltInEnglishLocale(code: string): boolean { + return code === 'en' || code === 'en-US'; +} + +export type ResolvedLocaleData = { + /** Bare specifier emitted by Angular, e.g. "@angular/common/locales/global/de-CH" */ + packageName: string; + /** Path to the locale data file, relative to the workspace root */ + entryPoint: string; + /** The locale tag that actually matched on disk (may be a prefix of the request) */ + matchedCode: string; + /** Version of @angular/common (for cache busting / packageInfo) */ + version: string; +}; + +/** + * Resolves the `@angular/common/locales/global/` file on disk, mirroring + * Angular's own progressive locale-tag fallback (de-CH → de). + */ +export function resolveAngularLocaleData( + code: string, + workspaceRoot: string, +): ResolvedLocaleData | null { + if (!code || isBuiltInEnglishLocale(code)) { + return null; + } + + const angularCommonRoot = path.join( + workspaceRoot, + 'node_modules', + '@angular', + 'common', + ); + let version = '0.0.0'; + const pkgJsonPath = path.join(angularCommonRoot, 'package.json'); + if (fs.existsSync(pkgJsonPath)) { + try { + version = + JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version ?? version; + } catch { + // ignore – fall back to the placeholder version + } + } + + let partial = code; + while (partial) { + for (const ext of ['.js', '.mjs']) { + const rel = path.posix.join( + 'node_modules', + '@angular', + 'common', + 'locales', + 'global', + `${partial}${ext}`, + ); + const abs = path.join(workspaceRoot, rel); + if (fs.existsSync(abs)) { + return { + packageName: `${LOCALE_DATA_BASE_MODULE}/${partial}`, + entryPoint: rel, + matchedCode: partial, + version, + }; + } + } + + const parts = partial.split('-'); + if (parts.length <= 1) { + break; + } + partial = parts.slice(0, -1).join('-'); + } + + return null; +} + +/** + * When Angular's `@angular/build:application` builder is configured with a + * non-English `i18n.sourceLocale` (or runs a single inline locale via the dev + * server), its esbuild i18n-locale-plugin emits bare external imports of the + * form `@angular/common/locales/global/` that vite's dep-prebundling is + * expected to resolve at runtime. Native Federation replaces that resolution + * layer with its own importmap, so the bare specifier ends up unresolved in + * the browser. + * + * This helper compensates by injecting the locale data files as shared chunks + * into the federation config *after* normalization (i.e. after `filterShared` + * has already run), so the bundler emits them and `writeImportMap` lists + * them. + * + * Returns the list of package names that were registered, primarily for + * diagnostics and tests. + */ +export function registerAngularLocaleDataInFederationConfig( + config: NormalizedFederationConfig, + i18n: I18nConfig | undefined, + workspaceRoot: string, + inlineLocales: readonly string[] = [], +): string[] { + if (!i18n) { + return []; + } + + const sourceCode = + typeof i18n.sourceLocale === 'string' + ? i18n.sourceLocale + : i18n.sourceLocale?.code; + + const candidates = new Set(); + if (sourceCode) { + candidates.add(sourceCode); + } + for (const loc of inlineLocales) { + candidates.add(loc); + } + + const registered: string[] = []; + + for (const code of candidates) { + if (isBuiltInEnglishLocale(code)) { + continue; + } + + const resolved = resolveAngularLocaleData(code, workspaceRoot); + if (!resolved) { + logger.warn( + `Could not locate '${LOCALE_DATA_BASE_MODULE}/${code}' in node_modules. ` + + `The browser will not be able to resolve this bare specifier at runtime. ` + + `Verify that @angular/common is installed, or share the locale data manually via federation.config.js.`, + ); + continue; + } + + if (config.shared[resolved.packageName]) { + // User has already shared this entry explicitly – leave it alone. + continue; + } + + config.shared[resolved.packageName] = { + singleton: true, + strictVersion: false, + requiredVersion: 'auto', + platform: 'browser', + build: 'default', + packageInfo: { + entryPoint: resolved.entryPoint, + version: resolved.version, + esm: true, + }, + }; + registered.push(resolved.packageName); + } + + return registered; +}