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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@angular/compiler": "catalog:",
"@angular/core": "catalog:",
"@angular/forms": "catalog:",
"@angular/localize": "catalog:",
"@angular/material": "catalog:",
"@angular/platform-browser": "catalog:",
"@angular/platform-browser-dynamic": "catalog:",
Expand Down
2 changes: 2 additions & 0 deletions packages/platform/src/lib/platform-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { injectHTMLPlugin } from './ssr/inject-html-plugin.js';
import { serverModePlugin } from '../server-mode-plugin.js';
import { routeGenerationPlugin } from './route-generation-plugin.js';
import { resolveStylePipelinePlugins } from './style-pipeline.js';
import { i18nComponentRegistryPlugin } from './i18n-component-registry-plugin.js';

// Bridge Plugin types from external @analogjs packages that resolve a different vite instance
function externalPlugins(plugins: unknown): Plugin[] {
Expand Down Expand Up @@ -135,6 +136,7 @@ export function platformPlugin(opts: Options = {}): Plugin[] {
},
}),
)),
...(platformOptions.i18n ? [i18nComponentRegistryPlugin()] : []),
...serverModePlugin(),
...clearClientPageEndpointsPlugin(),
];
Expand Down
3 changes: 3 additions & 0 deletions packages/router/server/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
serverComponentRequest,
renderServerComponent,
} from './server-component-render';
import { ɵresetI18nComponentDefCache } from '@analogjs/router';

if (import.meta.env.PROD) {
enableProdMode();
Expand Down Expand Up @@ -48,6 +49,8 @@ export function render(
return await renderServerComponent(url, serverContext);
}

ɵresetI18nComponentDefCache();

const html = await renderApplication(bootstrap as any, {
document,
url,
Expand Down
10 changes: 10 additions & 0 deletions packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,13 @@ export type {
} from './lib/experimental';
export { injectParams, injectQuery } from './lib/inject-typed-params';
export { injectRouteContext } from './lib/inject-route-context';

// i18n
export {
provideI18n,
I18nConfig,
injectSwitchLocale,
loadTranslationsRuntime,
ɵregisterI18nComponentDef,
ɵresetI18nComponentDefCache,
} from './lib/i18n/provide-i18n';
169 changes: 114 additions & 55 deletions packages/router/src/lib/i18n/provide-i18n.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {
ENVIRONMENT_INITIALIZER,
EnvironmentProviders,
InjectionToken,
Type,
assertInInjectionContext,
inject,
makeEnvironmentProviders,
provideAppInitializer,
} from '@angular/core';
import { LOCALE, injectLocale } from '@analogjs/router/tokens';
import { LOCALE, REQUEST, ServerRequest } from '@analogjs/router/tokens';

declare const ANALOG_I18N_DEFAULT_LOCALE: string;
declare const ANALOG_I18N_LOCALES: string[];
Expand Down Expand Up @@ -47,10 +48,12 @@ export type ResolvedI18nConfig = Required<I18nConfig>;

/**
* Injection token for the resolved i18n configuration.
* Provided by `provideI18n()`.
* Provided by `provideI18n()` and consumed by `injectSwitchLocale()`.
* @internal
*/
export const I18N_CONFIG: InjectionToken<ResolvedI18nConfig> =
new InjectionToken<ResolvedI18nConfig>('@analogjs/router I18n Config');
const I18N_CONFIG = new InjectionToken<ResolvedI18nConfig>(
'@analogjs/router I18n Config',
);

/**
* Resolves the full i18n config by merging explicit values with
Expand Down Expand Up @@ -89,7 +92,8 @@ export function resolveI18nConfig(config: I18nConfig): Required<I18nConfig> {
*
* Works in both SSR and client-only modes. On the client, locale is detected
* from `window.location.pathname`. On the server, locale is detected from
* the request in `provideServerContext()`.
* the request in `provideServerContext()` and provided at the platform level;
* this function does not shadow it.
*
* When the platform plugin is configured with `i18n` in `vite.config.ts`,
* `defaultLocale` and `locales` are injected automatically — only
Expand All @@ -103,27 +107,51 @@ export function resolveI18nConfig(config: I18nConfig): Required<I18nConfig> {
*/
export function provideI18n(config: I18nConfig): EnvironmentProviders {
const resolved = resolveI18nConfig(config);
const detectedLocale = detectClientLocale(resolved);

// Only provide LOCALE at the environment level on the client. On the
// server, the platform-level LOCALE set by `provideServerContext()` is
// authoritative and must not be shadowed by an environment-level provider.
const localeProviders =
typeof window !== 'undefined'
? [{ provide: LOCALE, useValue: detectClientLocale(resolved) }]
: [];

return makeEnvironmentProviders([
{ provide: I18N_CONFIG, useValue: resolved },
{ provide: LOCALE, useValue: detectedLocale },
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useFactory: () => {
// Re-read LOCALE in case the server context overrode it
const locale = injectLocale();
return () => initI18n(resolved, locale ?? undefined);
},
},
...localeProviders,
provideAppInitializer(async () => {
const locale = resolveActiveLocale(resolved);
await initI18n(resolved, locale);
ɵresetI18nComponentDefCache();
}),
]);
}

/**
* Detects the locale on the client from the URL path prefix.
* Returns the default locale on the server or when no match is found.
* Resolves the active locale, preferring the injected `LOCALE` token
* (which on the server reads from the platform-level provider set by
* `provideServerContext()`) and falling back to the request URL,
* `window.location.pathname`, or `defaultLocale`.
*/
function resolveActiveLocale(config: ResolvedI18nConfig): string {
const injected = inject(LOCALE, { optional: true });
if (injected && config.locales.includes(injected)) {
return injected;
}

const req = inject(REQUEST, { optional: true }) as ServerRequest | null;
const pathname =
req?.originalUrl ??
req?.url ??
(typeof window !== 'undefined' ? window.location.pathname : '/');
const first = pathname.split('?')[0].split('/').filter(Boolean)[0];
if (first && config.locales.includes(first)) {
return first;
}

return config.defaultLocale;
}

export function detectClientLocale(config: ResolvedI18nConfig): string {
if (typeof window === 'undefined') {
return config.defaultLocale;
Expand All @@ -140,35 +168,26 @@ export function detectClientLocale(config: ResolvedI18nConfig): string {
return config.defaultLocale;
}

/**
* Loads translations for the given locale and registers them with $localize.
*/
export async function initI18n(
config: ResolvedI18nConfig,
locale?: string,
): Promise<void> {
const activeLocale = locale ?? config.defaultLocale;
await clearTranslationsRuntime();

// Skip loading translations for the source locale
// (source messages are already in the templates)
if (activeLocale === config.locales[0]) {
return;
}

const translations = await config.loader(activeLocale);

if (translations && Object.keys(translations).length > 0) {
loadTranslationsRuntime(translations);
await loadTranslationsRuntime(translations);
}
}

/**
* Loads translations into the global $localize translation map.
* Requires @angular/localize/init to be imported in the application entry point.
*/
export function loadTranslationsRuntime(
export async function loadTranslationsRuntime(
translations: Record<string, string>,
): void {
): Promise<void> {
const $localize = (globalThis as any).$localize;
if (!$localize) {
console.warn(
Expand All @@ -178,26 +197,72 @@ export function loadTranslationsRuntime(
return;
}

$localize.TRANSLATIONS ??= {};
for (const [id, message] of Object.entries(translations)) {
$localize.TRANSLATIONS[id] = message;
try {
const { loadTranslations } = (await import('@angular/localize')) as {
loadTranslations: (t: Record<string, string>) => void;
};
loadTranslations(translations);
} catch {
console.warn(
'[@analogjs/router] Unable to import @angular/localize. ' +
'Install it as a dependency to enable runtime translation loading.',
);
$localize.TRANSLATIONS ??= {};
for (const [id, message] of Object.entries(translations)) {
$localize.TRANSLATIONS[id] = message;
}
}
}

/**
* Returns an injectable function that switches the application locale.
* Reads the configured locales from the I18N_CONFIG token provided
* by `provideI18n()`.
*
* Triggers a full page navigation to the new locale URL so that
* all $localize templates re-evaluate with the correct translations.
*
* Usage:
* ```typescript
* const switchLang = injectSwitchLocale();
* switchLang('fr'); // navigates to /fr/current-path
* ```
*/
/** @internal — exported for tests; not re-exported from the package entry. */
export async function clearTranslationsRuntime(): Promise<void> {
const $localize = (globalThis as any).$localize;
if (!$localize) {
return;
}
try {
const { clearTranslations } = (await import('@angular/localize')) as {
clearTranslations: () => void;
};
clearTranslations();
} catch {
$localize.translate = undefined;
$localize.TRANSLATIONS = {};
}
}

// ---------------------------------------------------------------------------
// Component definition registry
// ---------------------------------------------------------------------------

const componentDefRegistry = new Set<any>();

/** @internal */
export function ɵregisterI18nComponentDef(typeOrDef: Type<any> | any): void {
if (!typeOrDef) return;
const def = (typeOrDef as any).ɵcmp ?? typeOrDef;
if (def && typeof def === 'object' && 'template' in def) {
componentDefRegistry.add(def);
}
}

/** @internal */
export function ɵresetI18nComponentDefCache(): void {
for (const def of componentDefRegistry) {
def.tView = null;
}
}

/** @internal */
export function getI18nComponentDefRegistrySize(): number {
return componentDefRegistry.size;
}

/** @internal */
export function clearI18nComponentDefRegistry(): void {
componentDefRegistry.clear();
}

export function injectSwitchLocale(): (targetLocale: string) => void {
assertInInjectionContext(injectSwitchLocale);
const config = inject(I18N_CONFIG);
Expand All @@ -213,12 +278,6 @@ export function injectSwitchLocale(): (targetLocale: string) => void {
};
}

/**
* Replaces or inserts the locale prefix in a URL path.
*
* - If the path starts with a known locale, it is swapped.
* - If no locale prefix exists, the target locale is prepended.
*/
export function replaceLocaleInPath(
pathname: string,
targetLocale: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('jitPlugin', () => {
);

const plugin = jitPlugin({ inlineStylesExtension: 'css' });
plugin.configResolved?.({} as any);
plugin.configResolved?.({ test: { css: true } } as any);

const encoded = encodeURIComponent(
Buffer.from('.demo { color: red; }').toString('base64'),
Expand All @@ -41,7 +41,7 @@ describe('jitPlugin', () => {
vi.mocked(preprocessCSS).mockRejectedValue(new Error('boom'));

const plugin = jitPlugin({ inlineStylesExtension: 'css' });
plugin.configResolved?.({} as any);
plugin.configResolved?.({ test: { css: true } } as any);

const encoded = encodeURIComponent(
Buffer.from('.demo { color: red; }').toString('base64'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ async function setupLegacyTransformPlugin() {
sys: {
readFile: vi.fn(),
},
ScriptTarget: { Latest: 99 },
readBuilderProgram: vi.fn().mockReturnValue(undefined),
createAbstractBuilder: vi.fn().mockReturnValue(mockBuilder),
createEmitAndSemanticDiagnosticsBuilderProgram: vi
.fn()
.mockReturnValue(mockBuilder),
createIncrementalCompilerHost: vi.fn().mockReturnValue({}),
createPrinter: vi.fn().mockReturnValue({ printNode: vi.fn() }),
createSourceFile: vi.fn().mockReturnValue({}),
}));

vi.doMock('@angular/compiler-cli', () => ({
Expand Down
Loading
Loading