From 712b6a49c6c4cedd1b3328e49115132c8ca0dc98 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Sat, 11 Jul 2026 08:12:28 +0200 Subject: [PATCH] fix: resolve @angular/common/locales bare specifier --- src/builders/build/builder.ts | 17 +- src/builders/build/i18n-locale-data.spec.ts | 239 ++++++++++++++++++++ src/builders/build/i18n.ts | 152 ++++++++++++- 3 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 src/builders/build/i18n-locale-data.spec.ts diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 7b5eb12..c53d4a9 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -48,7 +48,11 @@ import { checkForInvalidImports } from "./../../utils/check-for-invalid-imports. import { federationBuildNotifier } from "./federation-build-notifier.js"; import type { NfBuilderSchema, NfInternalOptions } from "./schema.js"; import { createAngularBuildAdapter } from "../../tools/esbuild/angular-esbuild-adapter.js"; -import { getI18nConfig, translateFederationArtifacts } from "./i18n.js"; +import { + getI18nConfig, + registerAngularLocaleDataInFederationConfig, + translateFederationArtifacts, +} from "./i18n.js"; import { updateScriptTags } from "./update-index-html.js"; const originalWrite = process.stderr.write.bind(process.stderr); @@ -289,6 +293,17 @@ export async function* runBuilder( ); checkForInvalidImports(Object.keys(normalized.config.shared), "externals"); + // Surface Angular's `@angular/common/locales/global/` locale data + // through the federation config for non-English locales. Must run before + // `getExternals` so the entries are marked external and bundled/mapped. + const inlineLocaleFilter = Array.isArray(localeFilter) ? localeFilter : []; + registerAngularLocaleDataInFederationConfig( + normalized.config, + i18n, + context.workspaceRoot, + inlineLocaleFilter, + ); + const activateSsr = nfBuilderOptions.ssr && !nfBuilderOptions.dev; const start = process.hrtime(); diff --git a/src/builders/build/i18n-locale-data.spec.ts b/src/builders/build/i18n-locale-data.spec.ts new file mode 100644 index 0000000..29e394f --- /dev/null +++ b/src/builders/build/i18n-locale-data.spec.ts @@ -0,0 +1,239 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + logger, + type NormalizedFederationConfig, +} from "@softarc/native-federation/internal"; + +import { + registerAngularLocaleDataInFederationConfig, + resolveAngularLocaleData, + type I18nConfig, +} from "./i18n.js"; + +const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => undefined); + +afterEach(() => { + vi.clearAllMocks(); +}); + +afterAll(() => { + warnSpy.mockRestore(); +}); + +function makeFakeWorkspace(locales: string[], version = "22.0.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 { + $type: "classic", + name: "test", + exposes: {}, + shared: {}, + sharedMappings: {}, + skip: { strings: new Set(), functions: [], regexps: [] }, + chunks: false, + externals: [], + features: { + mappingVersion: false, + ignoreUnusedDeps: false, + denseChunking: false, + denseExternals: false, + integrityHashes: false, + }, + } as unknown as NormalizedFederationConfig; +} + +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"], "22.0.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("22.0.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", + chunks: false, + 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("warns and 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"], + ); // xx-YY not present on disk + + expect(registered).toEqual(["@angular/common/locales/global/de-CH"]); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/builders/build/i18n.ts b/src/builders/build/i18n.ts index e61c14b..be62ef1 100644 --- a/src/builders/build/i18n.ts +++ b/src/builders/build/i18n.ts @@ -1,5 +1,9 @@ import type { BuilderContext } from '@angular-devkit/architect'; -import { logger } from '@softarc/native-federation/internal'; +import { + logger, + type NormalizedExternalConfig, + type NormalizedFederationConfig, +} from '@softarc/native-federation/internal'; import { execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; @@ -118,3 +122,149 @@ function ensureDistFolders(locales: string[], outputPath: string) { fs.mkdirSync(localePath, { recursive: true }); } } + +const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global'; + +// `en`/`en-US` data is inlined by Angular and never emitted as a bare specifier. +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 matched on disk (may be a prefix of the request) */ + matchedCode: string; + /** Version of @angular/common */ + version: string; +}; + +/** + * Resolves the `@angular/common/locales/global/` file on disk, mirroring + * Angular's 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 { + // 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; +} + +/** + * With a non-English `i18n.sourceLocale` (or an inline dev-server locale), + * Angular emits bare `@angular/common/locales/global/` imports that rely + * on vite's dep-prebundling. Native Federation's importmap replaces that layer, + * leaving the specifier unresolved in the browser. This injects the locale data + * as shared entries so the bundler emits them and the importmap lists them. + * + * Returns the registered package names, 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]) { + // Already shared explicitly by the user – leave it alone. + continue; + } + + const entry: NormalizedExternalConfig = { + singleton: true, + strictVersion: false, + requiredVersion: resolved.version, + version: resolved.version, + chunks: false, + platform: 'browser', + build: 'default', + packageInfo: { + entryPoint: resolved.entryPoint, + version: resolved.version, + esm: true, + }, + }; + config.shared[resolved.packageName] = entry; + registered.push(resolved.packageName); + } + + return registered; +}