Skip to content

feat(@angular/build): add support for customizing URL segments with i18n #29011

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 7, 2024
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
26 changes: 13 additions & 13 deletions packages/angular/build/src/builders/application/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,14 @@ export async function inlineI18n(
warnings: string[];
prerenderedRoutes: PrerenderedRoutesRecord;
}> {
const { i18nOptions, optimizationOptions, baseHref } = options;

// Create the multi-threaded inliner with common options and the files generated from the build.
const inliner = new I18nInliner(
{
missingTranslation: options.i18nOptions.missingTranslationBehavior ?? 'warning',
missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning',
outputFiles: executionResult.outputFiles,
shouldOptimize: options.optimizationOptions.scripts,
shouldOptimize: optimizationOptions.scripts,
},
maxWorkers,
);
Expand All @@ -60,19 +62,16 @@ export async function inlineI18n(
const updatedOutputFiles = [];
const updatedAssetFiles = [];
try {
for (const locale of options.i18nOptions.inlineLocales) {
for (const locale of i18nOptions.inlineLocales) {
// A locale specific set of files is returned from the inliner.
const localeInlineResult = await inliner.inlineForLocale(
locale,
options.i18nOptions.locales[locale].translation,
i18nOptions.locales[locale].translation,
);
const localeOutputFiles = localeInlineResult.outputFiles;
inlineResult.errors.push(...localeInlineResult.errors);
inlineResult.warnings.push(...localeInlineResult.warnings);

const baseHref =
getLocaleBaseHref(options.baseHref, options.i18nOptions, locale) ?? options.baseHref;

const {
errors,
warnings,
Expand All @@ -82,7 +81,7 @@ export async function inlineI18n(
} = await executePostBundleSteps(
{
...options,
baseHref,
baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref,
},
localeOutputFiles,
executionResult.assetFiles,
Expand All @@ -94,16 +93,17 @@ export async function inlineI18n(
inlineResult.errors.push(...errors);
inlineResult.warnings.push(...warnings);

// Update directory with locale base
if (options.i18nOptions.flatOutput !== true) {
// Update directory with locale base or subPath
const subPath = i18nOptions.locales[locale].subPath;
if (i18nOptions.flatOutput !== true) {
localeOutputFiles.forEach((file) => {
file.path = join(locale, file.path);
file.path = join(subPath, file.path);
});

for (const assetFile of [...executionResult.assetFiles, ...additionalAssets]) {
updatedAssetFiles.push({
source: assetFile.source,
destination: join(locale, assetFile.destination),
destination: join(subPath, assetFile.destination),
});
}
} else {
Expand All @@ -128,7 +128,7 @@ export async function inlineI18n(
];

// Assets are only changed if not using the flat output option
if (options.i18nOptions.flatOutput !== true) {
if (!i18nOptions.flatOutput) {
executionResult.assetFiles = updatedAssetFiles;
}

Expand Down
13 changes: 8 additions & 5 deletions packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export async function normalizeOptions(
const i18nOptions: I18nOptions & {
duplicateTranslationBehavior?: I18NTranslation;
missingTranslationBehavior?: I18NTranslation;
} = createI18nOptions(projectMetadata, options.localize);
} = createI18nOptions(projectMetadata, options.localize, context.logger);
i18nOptions.duplicateTranslationBehavior = options.i18nDuplicateTranslation;
i18nOptions.missingTranslationBehavior = options.i18nMissingTranslation;
if (options.forceI18nFlatOutput) {
Expand Down Expand Up @@ -645,17 +645,20 @@ function normalizeGlobalEntries(
}

export function getLocaleBaseHref(
baseHref: string | undefined,
baseHref: string | undefined = '',
i18n: NormalizedApplicationBuildOptions['i18nOptions'],
locale: string,
): string | undefined {
if (i18n.flatOutput) {
return undefined;
}

if (i18n.locales[locale] && i18n.locales[locale].baseHref !== '') {
return urlJoin(baseHref || '', i18n.locales[locale].baseHref ?? `/${locale}/`);
const localeData = i18n.locales[locale];
if (!localeData) {
return undefined;
}

return undefined;
const baseHrefSuffix = localeData.baseHref ?? localeData.subPath + '/';

return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix) : undefined;
}
3 changes: 1 addition & 2 deletions packages/angular/build/src/builders/extract-i18n/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ export async function normalizeOptions(
// Target specifier defaults to the current project's build target with no specified configuration
const buildTargetSpecifier = options.buildTarget ?? ':';
const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build');

const i18nOptions = createI18nOptions(projectMetadata);
const i18nOptions = createI18nOptions(projectMetadata, /** inline */ false, context.logger);

// Normalize xliff format extensions
let format = options.format;
Expand Down
89 changes: 79 additions & 10 deletions packages/angular/build/src/utils/i18n-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface LocaleDescription {
translation?: Record<string, unknown>;
dataPath?: string;
baseHref?: string;
subPath: string;
}

export interface I18nOptions {
Expand Down Expand Up @@ -54,19 +55,31 @@ function normalizeTranslationFileOption(

function ensureObject(value: unknown, name: string): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`Project ${name} field is malformed. Expected an object.`);
throw new Error(`Project field '${name}' is malformed. Expected an object.`);
}
}

function ensureString(value: unknown, name: string): asserts value is string {
if (typeof value !== 'string') {
throw new Error(`Project ${name} field is malformed. Expected a string.`);
throw new Error(`Project field '${name}' is malformed. Expected a string.`);
}
}

function ensureValidsubPath(value: unknown, name: string): asserts value is string {
ensureString(value, name);

if (!/^[\w-]*$/.test(value)) {
throw new Error(
`Project field '${name}' is invalid. It can only contain letters, numbers, hyphens, and underscores.`,
);
}
}
export function createI18nOptions(
projectMetadata: { i18n?: unknown },
inline?: boolean | string[],
logger?: {
warn(message: string): void;
},
): I18nOptions {
const { i18n: metadata = {} } = projectMetadata;

Expand All @@ -82,22 +95,41 @@ export function createI18nOptions(
},
};

let rawSourceLocale;
let rawSourceLocaleBaseHref;
let rawSourceLocale: string | undefined;
let rawSourceLocaleBaseHref: string | undefined;
let rawsubPath: string | undefined;
if (typeof metadata.sourceLocale === 'string') {
rawSourceLocale = metadata.sourceLocale;
} else if (metadata.sourceLocale !== undefined) {
ensureObject(metadata.sourceLocale, 'i18n sourceLocale');
ensureObject(metadata.sourceLocale, 'i18n.sourceLocale');

if (metadata.sourceLocale.code !== undefined) {
ensureString(metadata.sourceLocale.code, 'i18n sourceLocale code');
ensureString(metadata.sourceLocale.code, 'i18n.sourceLocale.code');
rawSourceLocale = metadata.sourceLocale.code;
}

if (metadata.sourceLocale.baseHref !== undefined) {
ensureString(metadata.sourceLocale.baseHref, 'i18n sourceLocale baseHref');
ensureString(metadata.sourceLocale.baseHref, 'i18n.sourceLocale.baseHref');
logger?.warn(
`The 'baseHref' field under 'i18n.sourceLocale' is deprecated and will be removed in future versions. ` +
`Please use 'subPath' instead.\nNote: 'subPath' defines the URL segment for the locale, acting ` +
`as both the HTML base HREF and the directory name for output.\nBy default, ` +
`if not specified, 'subPath' uses the locale code.`,
);

rawSourceLocaleBaseHref = metadata.sourceLocale.baseHref;
}

if (metadata.sourceLocale.subPath !== undefined) {
ensureValidsubPath(metadata.sourceLocale.subPath, 'i18n.sourceLocale.subPath');
rawsubPath = metadata.sourceLocale.subPath;
}

if (rawsubPath !== undefined && rawSourceLocaleBaseHref !== undefined) {
throw new Error(
`'i18n.sourceLocale.subPath' and 'i18n.sourceLocale.baseHref' cannot be used together.`,
);
}
}

if (rawSourceLocale !== undefined) {
Expand All @@ -108,21 +140,41 @@ export function createI18nOptions(
i18n.locales[i18n.sourceLocale] = {
files: [],
baseHref: rawSourceLocaleBaseHref,
subPath: rawsubPath ?? i18n.sourceLocale,
};

if (metadata.locales !== undefined) {
ensureObject(metadata.locales, 'i18n locales');

for (const [locale, options] of Object.entries(metadata.locales)) {
let translationFiles;
let baseHref;
let translationFiles: string[] | undefined;
let baseHref: string | undefined;
let subPath: string | undefined;

if (options && typeof options === 'object' && 'translation' in options) {
translationFiles = normalizeTranslationFileOption(options.translation, locale, false);

if ('baseHref' in options) {
ensureString(options.baseHref, `i18n locales ${locale} baseHref`);
ensureString(options.baseHref, `i18n.locales.${locale}.baseHref`);
logger?.warn(
`The 'baseHref' field under 'i18n.locales.${locale}' is deprecated and will be removed in future versions. ` +
`Please use 'subPath' instead.\nNote: 'subPath' defines the URL segment for the locale, acting ` +
`as both the HTML base HREF and the directory name for output.\nBy default, ` +
`if not specified, 'subPath' uses the locale code.`,
);
baseHref = options.baseHref;
}

if ('subPath' in options) {
ensureString(options.subPath, `i18n.locales.${locale}.subPath`);
subPath = options.subPath;
}

if (subPath !== undefined && baseHref !== undefined) {
throw new Error(
`'i18n.locales.${locale}.subPath' and 'i18n.locales.${locale}.baseHref' cannot be used together.`,
);
}
} else {
translationFiles = normalizeTranslationFileOption(options, locale, true);
}
Expand All @@ -136,10 +188,27 @@ export function createI18nOptions(
i18n.locales[locale] = {
files: translationFiles.map((file) => ({ path: file })),
baseHref,
subPath: subPath ?? locale,
};
}
}

// Check that subPaths are unique.
const localesData = Object.entries(i18n.locales);
for (let i = 0; i < localesData.length; i++) {
const [localeA, { subPath: subPathA }] = localesData[i];

for (let j = i + 1; j < localesData.length; j++) {
const [localeB, { subPath: subPathB }] = localesData[j];

if (subPathA === subPathB) {
throw new Error(
`Invalid i18n configuration: Locales '${localeA}' and '${localeB}' cannot have the same subPath: '${subPathB}'.`,
);
}
}
}

if (inline === true) {
i18n.inlineLocales.add(i18n.sourceLocale);
Object.keys(i18n.locales).forEach((locale) => i18n.inlineLocales.add(locale));
Expand Down
22 changes: 5 additions & 17 deletions packages/angular/build/src/utils/server-rendering/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@
*/

import { extname } from 'node:path';
import {
NormalizedApplicationBuildOptions,
getLocaleBaseHref,
} from '../../builders/application/options';
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
import { type BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
import { createOutputFile } from '../../tools/esbuild/utils';

Expand Down Expand Up @@ -56,20 +53,11 @@ export function generateAngularServerAppEngineManifest(
baseHref: string | undefined,
): string {
const entryPoints: Record<string, string> = {};

if (i18nOptions.shouldInline) {
if (i18nOptions.shouldInline && !i18nOptions.flatOutput) {
for (const locale of i18nOptions.inlineLocales) {
const importPath =
'./' + (i18nOptions.flatOutput ? '' : locale + '/') + MAIN_SERVER_OUTPUT_FILENAME;

let localeWithBaseHref = getLocaleBaseHref('', i18nOptions, locale) || '/';

// Remove leading and trailing slashes.
const start = localeWithBaseHref[0] === '/' ? 1 : 0;
const end = localeWithBaseHref[localeWithBaseHref.length - 1] === '/' ? -1 : undefined;
localeWithBaseHref = localeWithBaseHref.slice(start, end);

entryPoints[localeWithBaseHref] = `() => import('${importPath}')`;
const { subPath } = i18nOptions.locales[locale];
const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`;
entryPoints[subPath] = `() => import('./${importPath}')`;
}
} else {
entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ async function renderPages(
const appShellRouteWithLeadingSlash = appShellRoute && addLeadingSlash(appShellRoute);
const baseHrefWithLeadingSlash = addLeadingSlash(baseHref);

for (const { route, redirectTo, renderMode } of serializableRouteTreeNode) {
for (const { route, redirectTo } of serializableRouteTreeNode) {
// Remove the base href from the file output path.
const routeWithoutBaseHref = addTrailingSlash(route).startsWith(baseHrefWithLeadingSlash)
? addLeadingSlash(route.slice(baseHrefWithLeadingSlash.length))
Expand Down
Loading
Loading