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 `urlSegment` option, which simplifies the i18n process, particularly in static site generation (SSG) and server-side rendering (SSR). When the `urlSegment` option is used, the `baseHref` is ignored. Instead, the `urlSegment` serves as both the base href and the name of the directory containing the localized version of the app.

### Configuration Example

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

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

### Example Directory Structure

The following tree structure demonstrates how the `urlSegment` 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`
```
Closes angular#16997 and closes angular#28967
  • Loading branch information
alan-agius4 committed Dec 2, 2024
1 parent e4448bb commit b1f7409
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 32 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 urlSegment
const urlSegment = i18nOptions.locales[locale]?.urlSegment ?? locale;
if (i18nOptions.flatOutput !== true) {
localeOutputFiles.forEach((file) => {
file.path = join(locale, file.path);
file.path = join(urlSegment, file.path);
});

for (const assetFile of [...executionResult.assetFiles, ...additionalAssets]) {
updatedAssetFiles.push({
source: assetFile.source,
destination: join(locale, assetFile.destination),
destination: join(urlSegment, 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
11 changes: 7 additions & 4 deletions packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,17 +643,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.urlSegment ?? localeData.baseHref;

return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix ?? locale) : undefined;
}
43 changes: 39 additions & 4 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;
urlSegment?: string;
}

export interface I18nOptions {
Expand Down Expand Up @@ -64,6 +65,16 @@ function ensureString(value: unknown, name: string): asserts value is string {
}
}

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

if (!/^[\w-]+$/.test(value)) {
throw new Error(
`Project ${name} field is malformed. Expected to match pattern: '/^[\\w-]+$/'.`,
);
}
}

export function createI18nOptions(
projectMetadata: { i18n?: unknown },
inline?: boolean | string[],
Expand All @@ -82,8 +93,9 @@ export function createI18nOptions(
},
};

let rawSourceLocale;
let rawSourceLocaleBaseHref;
let rawSourceLocale: string | undefined;
let rawSourceLocaleBaseHref: string | undefined;
let rawUrlSegment: string | undefined;
if (typeof metadata.sourceLocale === 'string') {
rawSourceLocale = metadata.sourceLocale;
} else if (metadata.sourceLocale !== undefined) {
Expand All @@ -98,6 +110,15 @@ export function createI18nOptions(
ensureString(metadata.sourceLocale.baseHref, 'i18n sourceLocale baseHref');
rawSourceLocaleBaseHref = metadata.sourceLocale.baseHref;
}

if (metadata.sourceLocale.urlSegment !== undefined) {
ensureValidateUrlSegment(metadata.sourceLocale.urlSegment, 'i18n sourceLocale urlSegment');
rawUrlSegment = metadata.sourceLocale.urlSegment;
}

if (rawUrlSegment && rawSourceLocaleBaseHref) {
throw new Error(`i18n sourceLocale urlSegment and baseHref cannot be used togather.`);
}
}

if (rawSourceLocale !== undefined) {
Expand All @@ -108,21 +129,35 @@ export function createI18nOptions(
i18n.locales[i18n.sourceLocale] = {
files: [],
baseHref: rawSourceLocaleBaseHref,
urlSegment: rawUrlSegment,
};

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 urlSegment: 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`);
baseHref = options.baseHref;
}

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

if (urlSegment && baseHref) {
throw new Error(
`i18n locales ${locale} urlSegment and baseHref cannot be used togather.`,
);
}
} else {
translationFiles = normalizeTranslationFileOption(options, locale, true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function generateAngularServerAppEngineManifest(
const importPath =
'./' + (i18nOptions.flatOutput ? '' : locale + '/') + MAIN_SERVER_OUTPUT_FILENAME;

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

// Remove leading and trailing slashes.
const start = localeWithBaseHref[0] === '/' ? 1 : 0;
Expand Down
66 changes: 57 additions & 9 deletions packages/angular/cli/lib/config/workspace-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -275,18 +275,42 @@
},
{
"type": "object",
"description": "Localization options to use for the source locale",
"description": "Localization options to use for the source locale.",
"properties": {
"code": {
"type": "string",
"description": "Specifies the locale code of the source locale",
"description": "Specifies the locale code of the source locale.",
"pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$"
},
"baseHref": {
"type": "string",
"description": "HTML base HREF to use for the locale (defaults to the locale code)"
"description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided."
},
"urlSegment": {
"type": "string",
"description": "Defines the URL segment for accessing this locale. It serves as the HTML base HREF and the directory name for the output. Defaults to the locale code if not specified.",
"pattern": "^[\\w-]+$"
}
},
"anyOf": [
{
"required": ["urlSegment"],
"not": {
"required": ["baseHref"]
}
},
{
"required": ["baseHref"],
"not": {
"required": ["urlSegment"]
}
},
{
"not": {
"required": ["baseHref", "urlSegment"]
}
}
],
"additionalProperties": false
}
]
Expand All @@ -299,29 +323,29 @@
"oneOf": [
{
"type": "string",
"description": "Localization file to use for i18n"
"description": "Localization file to use for i18n."
},
{
"type": "array",
"description": "Localization files to use for i18n",
"description": "Localization files to use for i18n.",
"items": {
"type": "string",
"uniqueItems": true
}
},
{
"type": "object",
"description": "Localization options to use for the locale",
"description": "Localization options to use for the locale.",
"properties": {
"translation": {
"oneOf": [
{
"type": "string",
"description": "Localization file to use for i18n"
"description": "Localization file to use for i18n."
},
{
"type": "array",
"description": "Localization files to use for i18n",
"description": "Localization files to use for i18n.",
"items": {
"type": "string",
"uniqueItems": true
Expand All @@ -331,9 +355,33 @@
},
"baseHref": {
"type": "string",
"description": "HTML base HREF to use for the locale (defaults to the locale code)"
"description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided."
},
"urlSegment": {
"type": "string",
"description": "Defines the URL segment for accessing this locale. It serves as the HTML base HREF and the directory name for the output. Defaults to the locale code if not specified.",
"pattern": "^[\\w-]+$"
}
},
"anyOf": [
{
"required": ["urlSegment"],
"not": {
"required": ["baseHref"]
}
},
{
"required": ["baseHref"],
"not": {
"required": ["urlSegment"]
}
},
{
"not": {
"required": ["baseHref", "urlSegment"]
}
}
],
"additionalProperties": false
}
]
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/ssr/src/app-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,6 @@ export class AngularAppEngine {

const potentialLocale = getPotentialLocaleIdFromUrl(url, basePath);

return this.getEntryPointExports(potentialLocale);
return this.getEntryPointExports(potentialLocale) ?? this.getEntryPointExports('');
}
}
Loading

0 comments on commit b1f7409

Please sign in to comment.