Теперь, когда мы знаем, как генерировать различные индексные файлы во время выполнения с помощью шаблонизаторов Express, мы собираемся создать нужный индексный файл перед его обслуживанием и отказаться от шаблонизаторов. Основным преимуществом этого является отделение от сервера, а также то, что вся обработка HTML происходит под одним капотом. Это также упрощает хостинг на облачных хостах. Для предварительной генерации индексных файлов мы обычно используем бегунок задач типа Gulp или Angular builders.
Найдите код билдера в StackBlitz ./builder
Angular builder
CLI билдеры, предоставляемые Angular, позволяют нам выполнять дополнительные задачи с помощью команды ng run
. Идея очень проста, однако документация очень скудна. Для целей нашего руководства мы хотим создать простой локальный билдер в подпапке и запустить его после сборки. Мы не хотим публиковать его через npm
или делать наш билдер многоразовым.
Я нашел хороший ресурс: Angular CLI under the hood — builders demystified
Строительные блоки
Основными компонентами являются:
- Вложенная папка с собственным
package.json
, которая запускает собственныйtsc
для создания собственной папкиdist
. - Новая цель в основном
angular.json
для запуска задачи, в подпапке
Вот строительные блоки
builders.json
{
"builders": {
"[nameofbuilder]": {
"implementation": "./dist/[build js file here]",
"schema": "[path to schema.json]",
"description": "Custom Builder"
}
}
}
schema.json
// nothing special
{
"$schema": "http://json-schema.org/schema",
"type": "object",
"properties": {
"[optionname]": {
"type": "[optiontype]"
}
}
}
корень angular.json
"projects": {
"[projectName]": {
"architect": {
// new target
"[targetName]": {
// relative path to builder folder
"builder": "./[path-to-folder]:[nameofbuilder]",
"options": {
"[optionname]": "[value]"
}
},
},
},
}
tsconfig.json
Это, вероятно, самая отвратительная часть билдера. Когда мы устанавливаем последнюю версию Angular, мы хотим создать наш локальный билдер, используя те же настройки. Здесь мы расширяем tsconfig.json
в корне приложения, который в версии 14 использует "module": "es2020"
, чтобы это работало, у нас есть два варианта.
- Первый — переопределить его на использование
commonjs
- или передать
type="module"
вpackage.json
builder’а.
Я предпочитаю последний вариант. (Помните, мы собираем не с помощью Angular CLI, а с помощью команды tsc
).
Другая проблема — поведение tsc
по умолчанию при сборке одной папки. Когда мы имеем
builder/singlefolder/index.ts
Сборка вычисляет кратчайший путь:
builder/dist/index.js
Игнорируя структуру папок. Чтобы исправить это, мы всегда явно задаем rootDir
.
{
// extend default Angular tsconfig
"extends": "../tsconfig.json",
"compilerOptions": {
// output into dist folder
"outDir": "dist",
// adding this will force the script to commonjs
// else it will be fed from root config, which is ES2020 in Angular 14
// "module": "commonjs",
// we can also be explicit with
// "module": "es2020",
// explicity set rootDir to generate folder structure after tsc build
"rootDir": "./"
},
// include all builders in sub folders
"include": ["**/*.ts"]
}
package.json
// builder/package.json
{
// add builders key, to point to the builders.json file
"builders": "builders.json",
// that is the only extra dependency we need
// install it in sub folder
"devDependencies": {
"@angular-devkit/architect": "^0.1401.0"
},
// If the tsconfig does not specify "commonjs" and feeds directly
// from Angular ES2020 setting, then this should be added
// this is my preferred solution
"type": "module"
}
Не забудьте исключить node_modules
внутри папки builder в вашем .gitignore
.
Корневой package.json
Команда для запуска:
ng run [projectName]:[targetName]
Итак, в нашем корневом package.json
мы, возможно, захотим создать ярлык и назвать его post[buildtask]
, чтобы он запускался после основной сборки.
// root package
"scripts": {
// example, if this is the build process
"build": "ng build --configuration=production",
// create a new script
"postbuild": "ng run [projectName]:[targetName]"
},
Теперь запуск npm run build
запустит обе сборки.
У нас есть все ингредиенты, давайте смешивать.
Локаль writeindex
builder
Внутри нашей только что созданной папки builder я создал единственный файл locales/index.ts
, который имеет основную структуру билдера, как указано в официальной документации Angular. Запустите tsc
в области видимости этой папки. В результате будет создан builder/dist/locale/index.js
.
Файл builders.json
обновится:
{
"builders": {
"localizeIndex": {
"implementation": "./dist/locales/index.js",
"schema": "./locales/schema.json",
"description": "Generate locales index files"
}
}
}
И наш корневой angular.json
.
"writeindex": {
"builder": "./builder:localizeIndex",
"options": {
// ... TODO
}
},
Цель — сгенерировать новый файл index placeholder, содержащий все языковые инструкции. Затем, после сборки, запустите сборщик Angular. Таким образом, файл placeholder.html
должен быть нашей целью.
Это безопаснее, чем
index.html
, мы не хотим, чтобы он случайно обслуживался, если мы испортили правила перезаписи.
placeholder.html
<!doctype html>
<!-- add $lang to be replaced -->
<html lang="$lang">
<head>
<!-- base href, you have multiple options based on your setup -->
<!-- if URL based app -->
<!-- <base href="/$lang/" /> -->
<!-- if non url based app -->
<!-- <base href="/" /> -->
<!-- for tutorial purposes, produce both options, let angular builder replace it -->
<base href="$basehref" />
<!-- here is an update, no need to rewrite language.js when we can serve exact file -->
<script src="locale/cr-$lang.js" defer></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
<!-- #LTR -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Signika:wght@300..700&display=swap">
<link rel="stylesheet" href="styles.ltr.css">
<!-- #ENDLTR -->
<!-- #RTL -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Tajawal:wght@300;500&display=swap">
<link rel="stylesheet" href="styles.rtl.css">
<!-- #ENDRTL -->
</head>
<body>
<app-root>
<!-- #LTR -->
loading
<!-- #ENDLTR -->
<!-- #RTL -->
انتظر
<!-- #ENDRTL -->
</app-root>
</body>
</html>
Наш конструктор должен создавать index.[lang].html
для всех поддерживаемых языков, но для целей этого руководства я собираюсь заставить его создавать файлы как на основе URL, так и на основе cookie. В реальной жизни у вас обычно есть одно решение.
Наша окончательная схема должна предусматривать «исходный» файл, папку «назначения» и поддерживаемые «языки»:
В locales/schema.json
.
{
"$schema": "http://json-schema.org/schema",
"type": "object",
"properties": {
"source": {
"type": "string"
},
"destination": {
"type": "string"
},
// let's spice this one up a bit and make it an object
"languages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string"},
"isRtl": { "type": "boolean"}
}
}
}
}
}
Мы обновляем наш angular.json
следующим образом:
// root angular.json architect target, update options
"options": {
// which file to replicate
"source": "host/client/placeholder.html",
// where to place it
"destination": "host/index",
// what languages are supported
"languages": [
{"name": "ar", "isRtl": true},
{"name": "en", "isRtl": false},
{"name": "...", "isRtl": ...}
]
}
Также обновим цель сборки, чтобы использовать файл-заполнитель:
"index": "src/placeholder.html",
А теперь начинка: locales/index.ts
.
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
// interface the schema options
interface Options {
source: string;
destination: string;
languages: { name: string; isRtl: boolean }[];
}
// expressions to replace, we can modify and add as many as we want
const reLTR = /<!-- #LTR -->([sS]*?)<!-- #ENDLTR -->/gim;
const reRTL = /<!-- #RTL -->([sS]*?)<!-- #ENDRTL -->/gim;
// replace ae all lang instances
const reLang = /$lang/gim;
// this is extra, for this tutorial to produce both options
const reBase = /$basehref/gim;
export default createBuilder(LocalizeIndex);
function LocalizeIndex(
options: Options,
context: BuilderContext,
): BuilderOutput {
try {
// create destination folder if not found
if (!existsSync(options.destination)){
mkdirSync(options.destination);
}
const html = readFileSync(options.source, 'utf8');
// for every language replace content as we wish
for (const lang of options.languages) {
let contents = html.toString();
if (lang.isRtl) {
// remove reLTR
contents = contents.replace(reLTR, '');
} else {
contents = contents.replace(reRTL, '');
}
// also replace lang
contents = contents.replace(reLang, lang.name);
// you should be doing one of the two following for your real life app
// save file with index.lang.html, base href = /
writeFileSync(`${options.destination}/index.${lang.name}.html`, contents.replace(reBase, '/'));
// save file with index.lang.url.html with base href = /lang/
writeFileSync(`${options.destination}/index.${lang.name}.url.html`, contents.replace(reBase, `/${lang.name}/`));
}
} catch (err) {
context.logger.error('Failed to generate locales.');
return {
success: false,
error: err.message,
};
}
context.reportStatus('Done.');
return { success: true };
}
Здесь используется базовое синхронное чтение и запись файлов, и можно было бы использовать больше опций, например, основываться ли на URL или нет. Я не хочу усложнять этот учебник, но вы, вероятно, можете придумать более цветистый код.
Наша команда: ng run cr:writeindex
(cr
— это имя нашего проекта). Посмотрите на StackBlitz в папке host/index, чтобы увидеть выходные индексные файлы.
На этом с этой стороны все. Давайте перейдем к серверу.
Экспресс-маршруты
В моем дурацком конфигурационном файле сервера я добавил новое свойство: pre
, чтобы попробовать различные маршруты с подготовленным HTML. Найдите эти маршруты с суффиксом: -pre
в имени файла в разделе host/server
.
Подготовленные серверные маршруты StackBlitz
По мере того, как мы переходим от одного эпизода к другому, я заметил возможное усовершенствование. С добавлением шаблонизаторов мы могли бы отказаться от правила перезаписи
language.js
, но оно как бы вылетело. Поэтому сегодня мы добавили соответствующий скрипт вplaceholder.html
, и нам больше не нужно его переписывать. Правило дляlocale/language.js
отменено.
Почти все осталось прежним, только последняя перезапись, использующая шаблонизаторы, теперь будет обслуживать правильную HTML-страницу.
Решения только для браузеров
Последний экспресс-маршрут таков:
// in express routes, browser only, sendFile
// serve the right language index file for all urls
res.sendFile(config.rootPath + `index/index.${res.locals.lang}.html`);
SSR решения
Правило перезаписи, использующее ngExpressEngine
теперь проще:
// in express routes, with Angular ssr, render
// serve the right language index
res.render(config.rootPath + `index/index.${res.locals.lang}.html`, {
req,
res
});
Наш экспресс-сервер становится все проще и проще. Это открывает больше возможностей, таких как хостинг на различных облачных хостах и хосты только для клиентов. Давайте сначала посмотрим, можем ли мы использовать gulp вместо Angular builder.
Gulp
Мы сделаем то же самое, создадим gulp во вложенной папке со всем необходимым. Это сделает основные пакеты проще и чище, и позволит нам позже перенести эти задачи в общий пакет npm
. Внутри только что созданной папки gulp мы инициализируем npm
и установим как минимум gulp
. Мы начнем с gulpfiles.js
, и единственного файла locales/index.js
.
Один из способов выполнения задачи gulp — простое выполнение вызовов функций NodeJs, повторяя тот же код, что и выше:
// the simplest form of gulp tasks, gulping without using gulp
const { readFileSync, writeFileSync, existsSync, mkdirSync } = require('fs');
const options = {
source: '../host/client/placeholder.html',
destination: '../host/index',
languages: [
{ name: 'ar', isRtl: true },
{ name: 'en', isRtl: false },
{ name: '...', isRtl: ...}]
}
// define same consts, then create the same function in ./builder
exports.LocalizeIndex = function (cb) {
// instead of context.logger use console.log
// instead of return function use cb()
cb();
}
В gulpfile.js
мы экспортируем главную функцию.
Вы, наверное, уже заметили, что я выбираю разные названия для одной и той же вещи, причина в том, что я всегда хочу помнить, что куда идет, и насколько эти термины зависимы. Очевидно, что никакой!
writeindex
не имеет ничего общего с именем функцииLocalizeIndex
.
const locales = require('./locales');
// export the LocalizeIndex as a "writeindex" gulp task
exports.writeindex = locales.LocalizeIndex;
Чтобы запустить его, выполните следующую команду:
gulp writeindex
Чтобы запустить его из нашего корневого пакета, нам нужно сначала сменить каталог (windows, простите, think-different people!), а затем gulp:
// in root package.json
"postbuild": "cd ./gulp && gulp writeindex",
Примечание: вам всегда нужен
gulp cli
для запуска этой команды, в подпапке или используя пакетnpm
. Gulp намного дороже, чем сборщики Angular CLI. Некоторые пакеты gulp также устарели, что усложняет работу с ним.
Однако использование правильных плагинов gulp приносит больше пользы в долгосрочной перспективе. Нам нужно сделать следующее:
const _indexEn = function() {
// read placeholder.html
return gulp.src('placeholder.html').pipe(
// transform it with specific language
transform(function(contents, file) {
// rewrite content
}, { encoding: 'utf8' })
// rename file
.pipe(rename({ basename: `index.en` }))
// save to destination
.pipe(gulp.dest(options.dest))
);
}
// another one:
const _indexAr = function() {
// ...
}
// run them in parallel
exports.localizeIndez = gulp.parallel(_indexEn, _indexAr, ...);
Кроме gulp
, нам нужны gulp-rename
и gulp-transform
. Установите их и следите за ними, они действительно устарели. В этом учебнике мы будем создавать оба индексных файла, с приложениями на основе URL и на основе cookie, но в реальной жизни мы бы уже знали, на какой тип ориентируемся.
// here is proper gulping
const gulp = require('gulp');
// those plugins are not kept up to date, maybe one Tuesday we shall replace them?
const rename = require('gulp-rename');
const transform = require('gulp-transform');
const options = {
// relative to gulpfile.js location
source: '../host/client/placeholder.html',
destination: '../host/index',
// allow both types of apps
isUrlBased: false,
languages: [
{ name: 'ar', isRtl: true },
{ name: 'en', isRtl: false },
{ name: '...', isRtl: ...},
],
};
const reLTR = /<!-- #LTR -->([sS]*?)<!-- #ENDLTR -->/gim;
const reRTL = /<!-- #RTL -->([sS]*?)<!-- #ENDRTL -->/gim;
const reLang = /$lang/gim;
const reBase = /$basehref/gim;
// base function, returns a function to be used as a task
const baseFunction = function (urlBased, lang) {
return function () {
// source the placeholder.html
return gulp.src(options.source).pipe(
// transform it with specific language
transform(function (contents, file) {
// rewrite content
if (lang.isRtl) {
contents = contents.replace(reLTR, '');
} else {
contents = contents.replace(reRTL, '');
}
// replace lang
contents = contents.replace(reLang, lang.name);
// replace base href
return contents.replace(reBase, urlBased ? `/${lang.name}/` : '/');
}, { encoding: 'utf8' }))
// rename file to index.lang.url.html
.pipe(rename({ basename: `index.${lang.name}${urlBased ? '.url' : ''}` }))
// save to destination
.pipe(gulp.dest(options.destination));
};
};
// for tutorial's purposes, create both url and cookie based files
const allIndexFiles = [];
options.languages.forEach((n) => {
allIndexFiles.push(baseFunction(true, n));
allIndexFiles.push(baseFunction(false, n));
});
// in real life, one option:
// const allIndexFiles = options.languages.map(language => baseFunction(options.isUrlBased, language));
// run in parallel
exports.LocalizeIndex = gulp.parallel(...allIndexFiles);
Вот и все. Независимо от того, используем ли мы Angular или Gulp, результат один и тот же. Выбор будет сделан в любую сторону при наличии других приоритетов.
Хостинг на Netlify
Есть еще одна возможность заставить приложение Angular обслуживать разные URL-адреса с одной и той же сборкой. Чтобы выяснить это, давайте попробуем опубликовать приложение только для клиентов на Netlify, где среда более строгая. Приходите на следующей неделе за радостью. 😴
Спасибо, что дочитали до конца, а вы продолжаете выполнять первоначальную задачу: крутить локализацию Angular?
РЕСУРСЫ
- Проект StackBlitz
- Angular CLI builders
- Angular CLI под капотом — демистифицированные билдеры
- Typescript: Почему —outDir перемещает вывод после добавления нового файла?
- Gulp