Skip to content
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

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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 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
16 changes: 12 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,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 urlSegment = localeData.urlSegment;
if (urlSegment !== undefined) {
urlSegment += '/';
}

const baseHrefSuffix = urlSegment ?? localeData.baseHref;

return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix ?? locale + '/') : undefined;
}
44 changes: 40 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;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be deprecate baseHref from the application builder?

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 !== undefined && rawSourceLocaleBaseHref !== undefined) {
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 !== undefined && baseHref !== undefined) {
throw new Error(
`i18n locales ${locale} urlSegment and baseHref cannot be used togather.`,
);
}
} else {
translationFiles = normalizeTranslationFileOption(options, locale, true);
}
Expand All @@ -136,6 +171,7 @@ export function createI18nOptions(
i18n.locales[locale] = {
files: translationFiles.map((file) => ({ path: file })),
baseHref,
urlSegment,
};
}
}
Expand Down
23 changes: 6 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,12 @@ export function generateAngularServerAppEngineManifest(
baseHref: string | undefined,
): string {
const entryPointsContent: 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);

entryPointsContent.push(`['${localeWithBaseHref}', () => import('${importPath}')]`);
const { urlSegment = locale } = i18nOptions.locales[locale];
entryPointsContent.push(
`['${urlSegment}', () => import('./${urlSegment ? `${urlSegment}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}')]`,
);
}
} else {
entryPointsContent.push(`['', () => 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
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
Loading