Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion libs/native-federation/src/builders/build/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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/<code>` 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),
Expand Down
232 changes: 232 additions & 0 deletions libs/native-federation/src/utils/i18n.spec.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading
Loading