Skip to content

Commit

Permalink
fix(@angular-devkit/build-angular): augment base HREF when localizing
Browse files Browse the repository at this point in the history
All locale i18n options now support an object form which allows a base HREF to be defined for the locale.  Each locale can now optionally define a custom base HREF that will be combined with the base HREF defined for the build configuration.  By default if the shorthand form for the locale is used or the field is not present in the longhand form, the locale code will be used as the base HREF.  To disable automatic augmentation a base HREF value of an empty string (`""`) can be used.  This will prevent anything from being added to the existing base HREF.

For common scenarios, the shorthand form will result in the preferred and recommended outcome of each built locale variant of the application containing a defined base HREF  containing the locale code.
  • Loading branch information
clydin committed Dec 2, 2019
1 parent 1d79b28 commit 78217d9
Show file tree
Hide file tree
Showing 10 changed files with 294 additions and 39 deletions.
50 changes: 45 additions & 5 deletions packages/angular/cli/lib/config/schema.json
Expand Up @@ -376,17 +376,57 @@
"type": "object",
"properties": {
"sourceLocale": {
"type": "string",
"description": "Specifies the source language of the application.",
"default": "en-US"
"oneOf": [
{
"type": "string",
"description": "Specifies the source locale of the application.",
"default": "en-US",
"pattern": "^[a-z]{2}(-[a-zA-Z]{2,})?$"
},
{
"type": "object",
"description": "Localization options to use for the source locale",
"properties": {
"code": {
"type": "string",
"description": "Specifies the locale code of the source locale",
"pattern": "^[a-z]{2}(-[a-zA-Z]{2,})?$"
},
"baseHref": {
"type": "string",
"description": "HTML base HREF to use for the locale (defaults to the locale code)"
}
},
"additionalProperties": false
}
]
},
"locales": {
"type": "object",
"additionalProperties": false,
"patternProperties": {
"^[a-z]{2}(-[a-zA-Z]{2,})?$": {
"type": "string",
"description": "Localization file to use for i18n"
"oneOf": [
{
"type": "string",
"description": "Localization file to use for i18n"
},
{
"type": "object",
"description": "Localization options to use for the locale",
"properties": {
"translation": {
"type": "string",
"description": "Localization file to use for i18n"
},
"baseHref": {
"type": "string",
"description": "HTML base HREF to use for the locale (defaults to the locale code)"
}
},
"additionalProperties": false
}
]
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion packages/angular_devkit/build_angular/src/browser/index.ts
Expand Up @@ -673,6 +673,16 @@ export function buildWebpackBrowser(

if (options.index) {
for (const [locale, outputPath] of outputPaths.entries()) {
let localeBaseHref;
if (i18n.locales[locale] && i18n.locales[locale].baseHref !== '') {
localeBaseHref = path.posix.join(
options.baseHref || '',
i18n.locales[locale].baseHref === undefined
? `/${locale}/`
: i18n.locales[locale].baseHref,
);
}

try {
await generateIndex(
outputPath,
Expand All @@ -684,6 +694,7 @@ export function buildWebpackBrowser(
transforms.indexHtml,
// i18nLocale is used when Ivy is disabled
locale || options.i18nLocale,
localeBaseHref || options.baseHref,
);
} catch (err) {
return { success: false, error: mapErrorToMessage(err) };
Expand Down Expand Up @@ -734,6 +745,7 @@ function generateIndex(
moduleFiles: EmittedFiles[] | undefined,
transformer?: IndexHtmlTransform,
locale?: string,
baseHref?: string,
): Promise<void> {
const host = new NodeJsSyncHost();

Expand All @@ -744,7 +756,7 @@ function generateIndex(
files,
noModuleFiles,
moduleFiles,
baseHref: options.baseHref,
baseHref,
deployUrl: options.deployUrl,
sri: options.subresourceIntegrity,
scripts: options.scripts,
Expand Down
73 changes: 55 additions & 18 deletions packages/angular_devkit/build_angular/src/utils/i18n-options.ts
Expand Up @@ -21,7 +21,14 @@ export interface I18nOptions {
sourceLocale: string;
locales: Record<
string,
{ file: string; format?: string; translation?: unknown; dataPath?: string, integrity?: string }
{
file: string;
format?: string;
translation?: unknown;
dataPath?: string;
integrity?: string;
baseHref?: string;
}
>;
flatOutput?: boolean;
readonly shouldInline: boolean;
Expand All @@ -32,49 +39,79 @@ export function createI18nOptions(
metadata: json.JsonObject,
inline?: boolean | string[],
): I18nOptions {
if (
metadata.i18n !== undefined &&
(typeof metadata.i18n !== 'object' || !metadata.i18n || Array.isArray(metadata.i18n))
) {
if (metadata.i18n !== undefined && !json.isJsonObject(metadata.i18n)) {
throw new Error('Project i18n field is malformed. Expected an object.');
}
metadata = metadata.i18n || {};

if (metadata.sourceLocale !== undefined && typeof metadata.sourceLocale !== 'string') {
throw new Error('Project i18n sourceLocale field is malformed. Expected a string.');
}

const i18n: I18nOptions = {
inlineLocales: new Set<string>(),
// en-US is the default locale added to Angular applications (https://angular.io/guide/i18n#i18n-pipes)
sourceLocale: metadata.sourceLocale || 'en-US',
sourceLocale: 'en-US',
locales: {},
get shouldInline() {
return this.inlineLocales.size > 0;
},
};

if (
metadata.locales !== undefined &&
(!metadata.locales || typeof metadata.locales !== 'object' || Array.isArray(metadata.locales))
) {
let rawSourceLocale;
let rawSourceLocaleBaseHref;
if (json.isJsonObject(metadata.sourceLocale)) {
rawSourceLocale = metadata.sourceLocale.code;
if (metadata.sourceLocale.baseHref !== undefined && typeof metadata.sourceLocale.baseHref !== 'string') {
throw new Error('Project i18n sourceLocale baseHref field is malformed. Expected a string.');
}
rawSourceLocaleBaseHref = metadata.sourceLocale.baseHref;
} else {
rawSourceLocale = metadata.sourceLocale;
}

if (rawSourceLocale !== undefined) {
if (typeof rawSourceLocale !== 'string') {
throw new Error('Project i18n sourceLocale field is malformed. Expected a string.');
}

i18n.sourceLocale = rawSourceLocale;
}

i18n.locales[i18n.sourceLocale] = {
file: '',
baseHref: rawSourceLocaleBaseHref,
};

if (metadata.locales !== undefined && !json.isJsonObject(metadata.locales)) {
throw new Error('Project i18n locales field is malformed. Expected an object.');
} else if (metadata.locales) {
for (const [locale, translationFile] of Object.entries(metadata.locales)) {
if (typeof translationFile !== 'string') {
for (const [locale, options] of Object.entries(metadata.locales)) {
let translationFile;
let baseHref;
if (json.isJsonObject(options)) {
if (typeof options.translation !== 'string') {
throw new Error(
`Project i18n locales translation field value for '${locale}' is malformed. Expected a string.`,
);
}
translationFile = options.translation;
if (typeof options.baseHref === 'string') {
baseHref = options.baseHref;
}
} else if (typeof options !== 'string') {
throw new Error(
`Project i18n locales field value for '${locale}' is malformed. Expected a string.`,
`Project i18n locales field value for '${locale}' is malformed. Expected a string or object.`,
);
} else {
translationFile = options;
}

if (locale === i18n.sourceLocale) {
throw new Error(
`An i18n locale identifier ('${locale}') cannot both be a source locale and provide a translation.`,
`An i18n locale ('${locale}') cannot both be a source locale and provide a translation.`,
);
}

i18n.locales[locale] = {
file: translationFile,
baseHref,
};
}
}
Expand Down
Expand Up @@ -131,17 +131,57 @@
"type": "object",
"properties": {
"sourceLocale": {
"type": "string",
"description": "Specifies the source language of the application.",
"default": "en-US"
"oneOf": [
{
"type": "string",
"description": "Specifies the source locale of the application.",
"default": "en-US",
"pattern": "^[a-z]{2}(-[a-zA-Z]{2,})?$"
},
{
"type": "object",
"description": "Localization options to use for the source locale",
"properties": {
"code": {
"type": "string",
"description": "Specifies the locale code of the source locale",
"pattern": "^[a-z]{2}(-[a-zA-Z]{2,})?$"
},
"baseHref": {
"type": "string",
"description": "HTML base HREF to use for the locale (defaults to the locale code)"
}
},
"additionalProperties": false
}
]
},
"locales": {
"type": "object",
"additionalProperties": false,
"patternProperties": {
"^[a-z]{2}(-[a-zA-Z]{2,})?$": {
"type": "string",
"description": "Localization file to use for i18n."
"oneOf": [
{
"type": "string",
"description": "Localization file to use for i18n"
},
{
"type": "object",
"description": "Localization options to use for the locale",
"properties": {
"translation": {
"type": "string",
"description": "Localization file to use for i18n"
},
"baseHref": {
"type": "string",
"description": "HTML base HREF to use for the locale (defaults to the locale code)"
}
},
"additionalProperties": false
}
]
}
}
}
Expand Down
103 changes: 103 additions & 0 deletions tests/legacy-cli/e2e/tests/i18n/ivy-localize-basehref.ts
@@ -0,0 +1,103 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { expectFileToMatch } from '../../utils/fs';
import { ng } from '../../utils/process';
import { updateJsonFile } from '../../utils/project';
import { externalServer, langTranslations, setupI18nConfig } from './legacy';

const baseHrefs = {
'en-US': '/en/',
fr: '/fr-FR/',
de: '',
};

export default async function() {
// Setup i18n tests and config.
await setupI18nConfig(true);

// Update angular.json
await updateJsonFile('angular.json', workspaceJson => {
const appProject = workspaceJson.projects['test-project'];
// tslint:disable-next-line: no-any
const i18n: Record<string, any> = appProject.i18n;

i18n.sourceLocale = {
baseHref: baseHrefs['en-US'],
};

i18n.locales['fr'] = {
translation: i18n.locales['fr'],
baseHref: baseHrefs['fr'],
};

i18n.locales['de'] = {
translation: i18n.locales['de'],
baseHref: baseHrefs['de'],
};
});

// Build each locale and verify the output.
await ng('build');
for (const { lang, outputPath } of langTranslations) {
if (baseHrefs[lang] === undefined) {
throw new Error('Invalid E2E test setup: unexpected locale ' + lang);
}

// Verify the HTML lang attribute is present
await expectFileToMatch(`${outputPath}/index.html`, `lang="${lang}"`);

// Verify the HTML base HREF attribute is present
await expectFileToMatch(`${outputPath}/index.html`, `href="${baseHrefs[lang] || '/'}"`);

// Execute Application E2E tests with dev server
await ng('e2e', `--configuration=${lang}`, '--port=0');

// Execute Application E2E tests for a production build without dev server
const server = externalServer(outputPath, baseHrefs[lang] || '/');
try {
await ng(
'e2e',
`--configuration=${lang}`,
'--devServerTarget=',
`--baseUrl=http://localhost:4200${baseHrefs[lang] || '/'}`,
);
} finally {
server.close();
}
}

// Update angular.json
await updateJsonFile('angular.json', workspaceJson => {
const appArchitect = workspaceJson.projects['test-project'].architect;

appArchitect['build'].options.baseHref = '/test/';
});

// Build each locale and verify the output.
await ng('build');
for (const { lang, outputPath } of langTranslations) {
// Verify the HTML base HREF attribute is present
await expectFileToMatch(`${outputPath}/index.html`, `href="/test${baseHrefs[lang] || '/'}"`);

// Execute Application E2E tests with dev server
await ng('e2e', `--configuration=${lang}`, '--port=0');

// Execute Application E2E tests for a production build without dev server
const server = externalServer(outputPath, '/test' + (baseHrefs[lang] || '/'));
try {
await ng(
'e2e',
`--configuration=${lang}`,
'--devServerTarget=',
`--baseUrl=http://localhost:4200/test${baseHrefs[lang] || '/'}`,
);
} finally {
server.close();
}
}
}

0 comments on commit 78217d9

Please sign in to comment.