Предварительная генерация нескольких индексных файлов с помощью Angular Builders и задач Gulp для обслуживания многоязычного приложения Angular

Теперь, когда мы знаем, как генерировать различные индексные файлы во время выполнения с помощью шаблонизаторов 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

Оцените статью
devanswers.ru
Добавить комментарий