diff --git a/packages/angular/cli/lib/config/schema.json b/packages/angular/cli/lib/config/schema.json index d2a5a6c3d0b8..cb1bfc431689 100644 --- a/packages/angular/cli/lib/config/schema.json +++ b/packages/angular/cli/lib/config/schema.json @@ -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 + } + ] } } } diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts index 30cd6a8020ae..eaf1dd66651c 100644 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/browser/index.ts @@ -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, @@ -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) }; @@ -734,6 +745,7 @@ function generateIndex( moduleFiles: EmittedFiles[] | undefined, transformer?: IndexHtmlTransform, locale?: string, + baseHref?: string, ): Promise { const host = new NodeJsSyncHost(); @@ -744,7 +756,7 @@ function generateIndex( files, noModuleFiles, moduleFiles, - baseHref: options.baseHref, + baseHref, deployUrl: options.deployUrl, sri: options.subresourceIntegrity, scripts: options.scripts, diff --git a/packages/angular_devkit/build_angular/src/utils/i18n-options.ts b/packages/angular_devkit/build_angular/src/utils/i18n-options.ts index ef7bed45c931..37edb09f4795 100644 --- a/packages/angular_devkit/build_angular/src/utils/i18n-options.ts +++ b/packages/angular_devkit/build_angular/src/utils/i18n-options.ts @@ -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; @@ -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(), // 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, }; } } diff --git a/packages/angular_devkit/core/src/experimental/workspace/workspace-schema.json b/packages/angular_devkit/core/src/experimental/workspace/workspace-schema.json index de7b5abec537..c03dca716f93 100644 --- a/packages/angular_devkit/core/src/experimental/workspace/workspace-schema.json +++ b/packages/angular_devkit/core/src/experimental/workspace/workspace-schema.json @@ -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 + } + ] } } } diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-basehref.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-basehref.ts new file mode 100644 index 000000000000..51ad75da4699 --- /dev/null +++ b/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 = 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(); + } + } +} diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-dl-xliff2.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-dl-xliff2.ts index cc45fdc6202b..28225d45a583 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-dl-xliff2.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-dl-xliff2.ts @@ -34,6 +34,9 @@ export async function executeTest() { // 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="/${lang}/"`); + // Verify the locale data is registered using the global files await expectFileToMatch(`${outputPath}/vendor-es5.js`, '.ng.common.locales'); await expectFileToMatch(`${outputPath}/vendor-es2015.js`, '.ng.common.locales'); @@ -42,9 +45,14 @@ export async function executeTest() { await ng('e2e', `--configuration=${lang}`, '--port=0'); // Execute Application E2E tests for a production build without dev server - const server = externalServer(outputPath); + const server = externalServer(outputPath, `/${lang}/`); try { - await ng('e2e', `--configuration=${lang}`, '--devServerTarget='); + await ng( + 'e2e', + `--configuration=${lang}`, + '--devServerTarget=', + `--baseUrl=http://localhost:4200/${lang}/`, + ); } finally { server.close(); } diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts index 4e3901f4cd13..a0d9148277f4 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es2015.ts @@ -29,9 +29,14 @@ export default async function() { await ng('e2e', `--configuration=${lang}`, '--port=0'); // Execute Application E2E tests for a production build without dev server - const server = externalServer(outputPath); + const server = externalServer(outputPath, `/${lang}/`); try { - await ng('e2e', `--configuration=${lang}`, '--devServerTarget='); + await ng( + 'e2e', + `--configuration=${lang}`, + '--devServerTarget=', + `--baseUrl=http://localhost:4200/${lang}/`, + ); } finally { server.close(); } diff --git a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es5.ts b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es5.ts index eee1e7251827..656db6ec58f6 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es5.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ivy-localize-es5.ts @@ -29,9 +29,14 @@ export default async function() { await ng('e2e', `--configuration=${lang}`, '--port=0'); // Execute Application E2E tests for a production build without dev server - const server = externalServer(outputPath); + const server = externalServer(outputPath, `/${lang}/`); try { - await ng('e2e', `--configuration=${lang}`, '--devServerTarget='); + await ng( + 'e2e', + `--configuration=${lang}`, + '--devServerTarget=', + `--baseUrl=http://localhost:4200/${lang}/`, + ); } finally { server.close(); } diff --git a/tests/legacy-cli/e2e/tests/i18n/legacy.ts b/tests/legacy-cli/e2e/tests/i18n/legacy.ts index ff02cbba1b0c..d40d900421c4 100644 --- a/tests/legacy-cli/e2e/tests/i18n/legacy.ts +++ b/tests/legacy-cli/e2e/tests/i18n/legacy.ts @@ -56,9 +56,9 @@ export const langTranslations = [ ]; export const sourceLocale = langTranslations[0].lang; -export const externalServer = (outputPath: string) => { +export const externalServer = (outputPath: string, baseUrl = '/') => { const app = express(); - app.use(express.static(resolve(outputPath))); + app.use(baseUrl, express.static(resolve(outputPath))); // call .close() on the return value to close the server. return app.listen(4200, 'localhost'); diff --git a/tests/legacy-cli/e2e/tests/i18n/ve-localize-es2015.ts b/tests/legacy-cli/e2e/tests/i18n/ve-localize-es2015.ts index c02c411454b9..91194763444a 100644 --- a/tests/legacy-cli/e2e/tests/i18n/ve-localize-es2015.ts +++ b/tests/legacy-cli/e2e/tests/i18n/ve-localize-es2015.ts @@ -44,9 +44,14 @@ export default async function() { await ng('e2e', `--configuration=${lang}`, '--port=0'); // Execute Application E2E tests for a production build without dev server - const server = externalServer(outputPath); + const server = externalServer(outputPath, `/${lang}/`); try { - await ng('e2e', `--configuration=${lang}`, '--devServerTarget='); + await ng( + 'e2e', + `--configuration=${lang}`, + '--devServerTarget=', + `--baseUrl=http://localhost:4200/${lang}/`, + ); } finally { server.close(); }