Skip to content

Commit

Permalink
feat(@angular/build): add support for customizing URL segments with i18n
Browse files Browse the repository at this point in the history
Previously, the `baseHref` option under each locale allowed for generating a unique base href for specific locales. However, users were still required to handle file organization manually, and `baseHref` appeared to be primarily designed for this purpose.

This commit introduces a new `subPath` option, which simplifies the i18n process, particularly in static site generation (SSG) and server-side rendering (SSR). When the `subPath` option is used, the `baseHref` is ignored. Instead, the `subPath` serves as both the base href and the name of the directory containing the localized version of the app.

Below is an example configuration showcasing the use of `subPath`:

```json
"i18n": {
  "sourceLocale": {
    "code": "en-US",
    "subPath": ""
  },
  "locales": {
    "fr-BE": {
      "subPath": "fr",
      "translation": "src/i18n/messages.fr-BE.xlf"
    },
    "de-BE": {
      "subPath": "de",
      "translation": "src/i18n/messages.de-BE.xlf"
    }
  }
}
```

The following tree structure demonstrates how the `subPath` organizes localized build output:
```
dist/
├── app/
│   └── browser/  # Default locale, accessible at `/`
│       ├── fr/  # Locale for `fr-BE`, accessible at `/fr`
│       └── de/  # Locale for `de-BE`, accessible at `/de`
```

DEPRECATED: The `baseHref` option under `i18n.locales` and `i18n.sourceLocale` in `angular.json` is deprecated in favor of `subPath`.

The `subPath` defines the URL segment for the locale, serving as both the HTML base HREF and the directory name for output. By default, if not specified, `subPath` will use the locale code.

Closes angular#16997 and closes angular#28967
  • Loading branch information
alan-agius4 committed Dec 6, 2024
1 parent ffad81a commit f936df1
Show file tree
Hide file tree
Showing 15 changed files with 331 additions and 68 deletions.
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 ?? locale;
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
18 changes: 13 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,25 @@ 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;
let subPath = localeData.subPath;
if (subPath !== undefined) {
subPath += '/';
}

const baseHrefSuffix = subPath ?? localeData.baseHref;

return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix ?? locale + '/') : 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
73 changes: 63 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,
};

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,6 +188,7 @@ export function createI18nOptions(
i18n.locales[locale] = {
files: translationFiles.map((file) => ({ path: file })),
baseHref,
subPath,
};
}
}
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 = locale } = 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

0 comments on commit f936df1

Please sign in to comment.