349 lines
11 KiB
JavaScript
349 lines
11 KiB
JavaScript
|
// pathPrefix note:
|
|||
|
// When using `locale_url` filter with the `url` filter, `locale_url` must run first like
|
|||
|
// `| locale_url | url`. If you run `| url | locale_url` it won’t match correctly.
|
|||
|
|
|||
|
// TODO improvement would be to throw an error if `locale_url` finds a url with the
|
|||
|
// path prefix at the beginning? Would need a better way to know `url` has transformed a string
|
|||
|
// rather than just raw comparison.
|
|||
|
// e.g. --pathprefix=/en/ should return `/en/en/` for `/en/index.liquid`
|
|||
|
const { DeepCopy } = require("../Util/Merge");
|
|||
|
const bcp47Normalize = require("bcp-47-normalize");
|
|||
|
const iso639 = require("iso-639-1");
|
|||
|
|
|||
|
class LangUtils {
|
|||
|
static getLanguageCodeFromInputPath(filepath) {
|
|||
|
return (filepath || "")
|
|||
|
.split("/")
|
|||
|
.find((entry) => Comparator.isLangCode(entry));
|
|||
|
}
|
|||
|
|
|||
|
static getLanguageCodeFromUrl(url) {
|
|||
|
let s = (url || "").split("/");
|
|||
|
return s.length > 0 && Comparator.isLangCode(s[1]) ? s[1] : "";
|
|||
|
}
|
|||
|
|
|||
|
static swapLanguageCodeNoCheck(str, langCode) {
|
|||
|
let found = false;
|
|||
|
return str
|
|||
|
.split("/")
|
|||
|
.map((entry) => {
|
|||
|
// only match the first one
|
|||
|
if (!found && Comparator.isLangCode(entry)) {
|
|||
|
found = true;
|
|||
|
return langCode;
|
|||
|
}
|
|||
|
return entry;
|
|||
|
})
|
|||
|
.join("/");
|
|||
|
}
|
|||
|
|
|||
|
static swapLanguageCode(str, langCode) {
|
|||
|
if (!Comparator.isLangCode(langCode)) {
|
|||
|
return str;
|
|||
|
}
|
|||
|
|
|||
|
return LangUtils.swapLanguageCodeNoCheck(str, langCode);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
class Comparator {
|
|||
|
// https://en.wikipedia.org/wiki/IETF_language_tag#Relation_to_other_standards
|
|||
|
// Requires a ISO-639-1 language code at the start (2 characters before the first -)
|
|||
|
static isLangCode(code) {
|
|||
|
let [s] = (code || "").split("-");
|
|||
|
if (!iso639.validate(s)) {
|
|||
|
return false;
|
|||
|
}
|
|||
|
if (!bcp47Normalize(code)) {
|
|||
|
return false;
|
|||
|
}
|
|||
|
return true;
|
|||
|
}
|
|||
|
|
|||
|
static urlHasLangCode(url, code) {
|
|||
|
if (!Comparator.isLangCode(code)) {
|
|||
|
return false;
|
|||
|
}
|
|||
|
|
|||
|
return url.split("/").some((entry) => entry === code);
|
|||
|
}
|
|||
|
|
|||
|
// Search for same input path files with only differing locale folders
|
|||
|
// matches /en/about.html and /es/about.html
|
|||
|
// Folders can exist anywhere in the hierarchy!
|
|||
|
// matches /testing/en/about.html and /testing/es/about.html
|
|||
|
|
|||
|
// Returns an array of the first matched language codes in the path
|
|||
|
// [inputPathLangCode, inputPath2LangCode]
|
|||
|
static matchLanguageFolder(inputPath, inputPath2) {
|
|||
|
if (inputPath === inputPath2) {
|
|||
|
return false;
|
|||
|
}
|
|||
|
|
|||
|
let langCodes = [];
|
|||
|
let s1 = inputPath.split("/");
|
|||
|
let s2 = inputPath2.split("/");
|
|||
|
for (let j = 0, k = s1.length; j < k; j++) {
|
|||
|
if (Comparator.isLangCode(s1[j]) && Comparator.isLangCode(s2[j])) {
|
|||
|
// Return the first match only
|
|||
|
if (langCodes.length === 0) {
|
|||
|
langCodes = [s1[j], s2[j]];
|
|||
|
}
|
|||
|
continue;
|
|||
|
}
|
|||
|
if (s1[j] !== s2[j]) {
|
|||
|
return false;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
return langCodes;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
function normalizeInputPath(inputPath, extensionMap) {
|
|||
|
if (extensionMap) {
|
|||
|
return extensionMap.removeTemplateExtension(inputPath);
|
|||
|
}
|
|||
|
return inputPath;
|
|||
|
}
|
|||
|
|
|||
|
/*
|
|||
|
* Input: {
|
|||
|
* '/en-us/test/': './test/stubs-i18n/en-us/test.11ty.js',
|
|||
|
* '/en/test/': './test/stubs-i18n/en/test.liquid',
|
|||
|
* '/es/test/': './test/stubs-i18n/es/test.njk',
|
|||
|
* '/non-lang-file/': './test/stubs-i18n/non-lang-file.njk'
|
|||
|
* }
|
|||
|
*
|
|||
|
* Output: {
|
|||
|
* '/en-us/test/': [ { url: '/en/test/' }, { url: '/es/test/' } ],
|
|||
|
* '/en/test/': [ { url: '/en-us/test/' }, { url: '/es/test/' } ],
|
|||
|
* '/es/test/': [ { url: '/en-us/test/' }, { url: '/en/test/' } ]
|
|||
|
* }
|
|||
|
*/
|
|||
|
function getLocaleUrlsMap(urlToInputPath, extensionMap) {
|
|||
|
let filemap = {};
|
|||
|
let paginationTemplateCheck = {};
|
|||
|
for (let url in urlToInputPath) {
|
|||
|
let originalFilepath = urlToInputPath[url];
|
|||
|
let filepath = normalizeInputPath(originalFilepath, extensionMap);
|
|||
|
let replaced = LangUtils.swapLanguageCodeNoCheck(filepath, "__11ty_i18n");
|
|||
|
if (!filemap[replaced]) {
|
|||
|
filemap[replaced] = [];
|
|||
|
}
|
|||
|
|
|||
|
let langCode = LangUtils.getLanguageCodeFromInputPath(originalFilepath);
|
|||
|
let paginationCheckKey = `${originalFilepath}__${langCode}`;
|
|||
|
|
|||
|
// pagination templates should only match once per language
|
|||
|
if (!paginationTemplateCheck[paginationCheckKey]) {
|
|||
|
if (langCode) {
|
|||
|
filemap[replaced].push({
|
|||
|
url,
|
|||
|
lang: langCode,
|
|||
|
label: iso639.getNativeName(langCode.split("-")[0]),
|
|||
|
});
|
|||
|
} else {
|
|||
|
filemap[replaced].push({ url });
|
|||
|
}
|
|||
|
paginationTemplateCheck[paginationCheckKey] = true;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// Default sorted by lang code
|
|||
|
for (let key in filemap) {
|
|||
|
filemap[key].sort(function (a, b) {
|
|||
|
if (a.lang < b.lang) {
|
|||
|
return -1;
|
|||
|
}
|
|||
|
if (a.lang > b.lang) {
|
|||
|
return 1;
|
|||
|
}
|
|||
|
return 0;
|
|||
|
});
|
|||
|
}
|
|||
|
|
|||
|
// map of input paths => array of localized urls
|
|||
|
let urlMap = {};
|
|||
|
for (let filepath in filemap) {
|
|||
|
for (let entry of filemap[filepath]) {
|
|||
|
let url = entry.url;
|
|||
|
if (!urlMap[url]) {
|
|||
|
urlMap[url] = filemap[filepath].filter((entry) => {
|
|||
|
return entry.url !== url;
|
|||
|
});
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
return urlMap;
|
|||
|
}
|
|||
|
|
|||
|
function EleventyPlugin(eleventyConfig, opts = {}) {
|
|||
|
let options = DeepCopy(
|
|||
|
{
|
|||
|
defaultLanguage: "",
|
|||
|
filters: {
|
|||
|
url: "locale_url",
|
|||
|
links: "locale_links",
|
|||
|
},
|
|||
|
errorMode: "strict", // allow-fallback, never
|
|||
|
},
|
|||
|
opts
|
|||
|
);
|
|||
|
|
|||
|
if (!options.defaultLanguage) {
|
|||
|
throw new Error(
|
|||
|
"You must specify a `defaultLanguage` in Eleventy’s Internationalization (I18N) plugin."
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
let benchmarkManager = eleventyConfig.benchmarkManager.get("Aggregate");
|
|||
|
|
|||
|
let extensionMap;
|
|||
|
eleventyConfig.on("eleventy.extensionmap", (map) => {
|
|||
|
extensionMap = map;
|
|||
|
});
|
|||
|
|
|||
|
let contentMaps = {};
|
|||
|
eleventyConfig.on(
|
|||
|
"eleventy.contentMap",
|
|||
|
function ({ urlToInputPath, inputPathToUrl }) {
|
|||
|
let bench = benchmarkManager.get("(i18n Plugin) Setting up content map.");
|
|||
|
bench.before();
|
|||
|
contentMaps.inputPathToUrl = inputPathToUrl;
|
|||
|
contentMaps.urlToInputPath = urlToInputPath;
|
|||
|
|
|||
|
contentMaps.localeUrlsMap = getLocaleUrlsMap(
|
|||
|
urlToInputPath,
|
|||
|
extensionMap,
|
|||
|
benchmarkManager
|
|||
|
);
|
|||
|
bench.after();
|
|||
|
}
|
|||
|
);
|
|||
|
|
|||
|
eleventyConfig.addGlobalData("eleventyComputed.page.lang", () => {
|
|||
|
// if addGlobalData receives a function it will execute it immediately,
|
|||
|
// so we return a nested function for computed data
|
|||
|
return (data) => {
|
|||
|
return (
|
|||
|
LangUtils.getLanguageCodeFromUrl(data.page.url) ||
|
|||
|
options.defaultLanguage
|
|||
|
);
|
|||
|
};
|
|||
|
});
|
|||
|
|
|||
|
// Normalize a theoretical URL based on the current page’s language
|
|||
|
// If a non-localized file exists, returns the URL without a language assigned
|
|||
|
// Fails if no file exists (localized and not localized)
|
|||
|
eleventyConfig.addFilter(
|
|||
|
options.filters.url,
|
|||
|
function (url, langCodeOverride) {
|
|||
|
let langCode =
|
|||
|
langCodeOverride ||
|
|||
|
LangUtils.getLanguageCodeFromUrl(this.page?.url) ||
|
|||
|
options.defaultLanguage;
|
|||
|
|
|||
|
// Already has a language code on it and has a relevant url with the target language code
|
|||
|
if (
|
|||
|
contentMaps.localeUrlsMap[url] ||
|
|||
|
(!url.endsWith("/") && contentMaps.localeUrlsMap[`${url}/`])
|
|||
|
) {
|
|||
|
for (let existingUrlObj of contentMaps.localeUrlsMap[url] ||
|
|||
|
contentMaps.localeUrlsMap[`${url}/`]) {
|
|||
|
if (Comparator.urlHasLangCode(existingUrlObj.url, langCode)) {
|
|||
|
return existingUrlObj.url;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// Needs the language code prepended to the URL
|
|||
|
let prependedLangCodeUrl = `/${langCode}${url}`;
|
|||
|
if (
|
|||
|
contentMaps.localeUrlsMap[prependedLangCodeUrl] ||
|
|||
|
(!prependedLangCodeUrl.endsWith("/") &&
|
|||
|
contentMaps.localeUrlsMap[`${prependedLangCodeUrl}/`])
|
|||
|
) {
|
|||
|
return prependedLangCodeUrl;
|
|||
|
}
|
|||
|
|
|||
|
if (
|
|||
|
contentMaps.urlToInputPath[url] ||
|
|||
|
(!url.endsWith("/") && contentMaps.urlToInputPath[`${url}/`])
|
|||
|
) {
|
|||
|
// this is not a localized file (independent of a language code)
|
|||
|
if (options.errorMode === "strict") {
|
|||
|
throw new Error(
|
|||
|
`Localized file for URL ${prependedLangCodeUrl} was not found in your project. A non-localized version does exist—are you sure you meant to use the \`${options.filters.url}\` filter for this? You can bypass this error using the \`errorMode\` option in the I18N plugin (current value: "${options.errorMode}").`
|
|||
|
);
|
|||
|
}
|
|||
|
} else if (options.errorMode === "allow-fallback") {
|
|||
|
// You’re linking to a localized file that doesn’t exist!
|
|||
|
throw new Error(
|
|||
|
`Localized file for URL ${prependedLangCodeUrl} was not found in your project! You will need to add it if you want to link to it using the \`${options.filters.url}\` filter. You can bypass this error using the \`errorMode\` option in the I18N plugin (current value: "${options.errorMode}").`
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
return url;
|
|||
|
}
|
|||
|
);
|
|||
|
|
|||
|
// Refactor to use url
|
|||
|
// Find the links that are localized alternates to the inputPath argument
|
|||
|
eleventyConfig.addFilter(options.filters.links, function (urlOverride) {
|
|||
|
let url = urlOverride || this.page?.url;
|
|||
|
return contentMaps.localeUrlsMap[url] || [];
|
|||
|
});
|
|||
|
|
|||
|
// Returns a `page`-esque variable for the root default language page
|
|||
|
// If paginated, returns first result only
|
|||
|
eleventyConfig.addFilter(
|
|||
|
"locale_page", // This is not exposed in `options` because it is an Eleventy internals filter (used in get*CollectionItem filters)
|
|||
|
function (pageOverride, languageCode) {
|
|||
|
// both args here are optional
|
|||
|
if (!languageCode) {
|
|||
|
languageCode = options.defaultLanguage;
|
|||
|
}
|
|||
|
|
|||
|
let page = pageOverride || this.page;
|
|||
|
let url; // new url
|
|||
|
if (contentMaps.localeUrlsMap[page.url]) {
|
|||
|
for (let entry of contentMaps.localeUrlsMap[page.url]) {
|
|||
|
if (entry.lang === languageCode) {
|
|||
|
url = entry.url;
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
let inputPath = LangUtils.swapLanguageCode(page.inputPath, languageCode);
|
|||
|
|
|||
|
if (
|
|||
|
!url ||
|
|||
|
!Array.isArray(contentMaps.inputPathToUrl[inputPath]) ||
|
|||
|
contentMaps.inputPathToUrl[inputPath].length === 0
|
|||
|
) {
|
|||
|
// no internationalized pages found
|
|||
|
return page;
|
|||
|
}
|
|||
|
|
|||
|
let result = {
|
|||
|
// // note that the permalink/slug may be different for the localized file!
|
|||
|
url,
|
|||
|
inputPath,
|
|||
|
filePathStem: LangUtils.swapLanguageCode(
|
|||
|
page.filePathStem,
|
|||
|
languageCode
|
|||
|
),
|
|||
|
// outputPath is omitted here, not necessary for GetCollectionItem.js if url is provided
|
|||
|
__locale_page_resolved: true,
|
|||
|
};
|
|||
|
return result;
|
|||
|
}
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
module.exports = EleventyPlugin;
|
|||
|
module.exports.Comparator = Comparator;
|
|||
|
module.exports.LangUtils = LangUtils;
|