Files
beall-11ty/node_modules/@11ty/eleventy/src/Plugins/I18nPlugin.js
2026-03-31 16:38:22 -07:00

318 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { bcp47Normalize } from "bcp-47-normalize";
import iso639 from "iso-639-1";
import { DeepCopy } from "@11ty/eleventy-utils";
// 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 wont 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`
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);
}
}
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, options = {}) {
let filemap = {};
for (let url in urlToInputPath) {
// Group number comes from Pagination.js
let { inputPath: originalFilepath, groupNumber } = urlToInputPath[url];
let filepath = normalizeInputPath(originalFilepath, extensionMap);
let replaced =
LangUtils.swapLanguageCodeNoCheck(filepath, "__11ty_i18n") + `_group:${groupNumber}`;
if (!filemap[replaced]) {
filemap[replaced] = [];
}
let langCode = LangUtils.getLanguageCodeFromInputPath(originalFilepath);
if (!langCode) {
langCode = LangUtils.getLanguageCodeFromUrl(url);
}
if (!langCode) {
langCode = options.defaultLanguage;
}
if (langCode) {
filemap[replaced].push({
url,
lang: langCode,
label: iso639.getNativeName(langCode.split("-")[0]),
});
} else {
filemap[replaced].push({ url });
}
}
// 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) => {
if (entry.lang) {
return true;
}
return entry.url !== url;
});
}
}
}
return urlMap;
}
function eleventyI18nPlugin(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 Eleventys Internationalization (I18N) plugin.",
);
}
let extensionMap;
eleventyConfig.on("eleventy.extensionmap", (map) => {
extensionMap = map;
});
let bench = eleventyConfig.benchmarkManager.get("Aggregate");
let contentMaps = {};
eleventyConfig.on("eleventy.contentMap", function ({ urlToInputPath, inputPathToUrl }) {
let b = bench.get("(i18n Plugin) Setting up content map.");
b.before();
contentMaps.inputPathToUrl = inputPathToUrl;
contentMaps.urlToInputPath = urlToInputPath;
contentMaps.localeUrlsMap = getLocaleUrlsMap(urlToInputPath, extensionMap, options);
b.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 pages 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") {
// Youre linking to a localized file that doesnt 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] || []).filter((entry) => {
return entry.url !== 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;
},
);
}
export { Comparator, LangUtils };
Object.defineProperty(eleventyI18nPlugin, "eleventyPackage", {
value: "@11ty/eleventy/i18n-plugin",
});
Object.defineProperty(eleventyI18nPlugin, "eleventyPluginOptions", {
value: {
unique: true,
},
});
export default eleventyI18nPlugin;