diff --git a/README.md b/README.md index 3bfbc1374..2bb40225b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ MDN's next fr(ont)e(n)d. - `npm run preview` - runs the preview server: using the production bundles with the rari server: useful for testing our prod rspack config +## L10n + +See [the l10n README](./l10n/README.md). + ## Development principles ### Inline JS diff --git a/build/plugins/extract-l10n.js b/build/plugins/extract-l10n.js new file mode 100644 index 000000000..9bf629cca --- /dev/null +++ b/build/plugins/extract-l10n.js @@ -0,0 +1,16 @@ +import { extract } from "../../l10n/parser/extractor.js"; + +/** + * @import { Compiler } from "@rspack/core" + */ + +export class ExtractL10nPlugin { + /** + * @param {Compiler} compiler + */ + apply(compiler) { + compiler.hooks.beforeCompile.tapPromise("ExtractL10nPlugin", async () => { + await extract(); + }); + } +} diff --git a/entry.ssr.js b/entry.ssr.js index 2b9e63a0f..079a782d9 100644 --- a/entry.ssr.js +++ b/entry.ssr.js @@ -45,9 +45,6 @@ for (const [name, def] of customElements.__definitions) { */ export async function render(path, partialContext, compilationStats) { const locale = path.split("/")[1] || "en-US"; - if (locale === "qa") { - path = path.replace("/qa/", "/en-US/"); - } const context = { path, diff --git a/l10n/README.md b/l10n/README.md new file mode 100644 index 000000000..1df174ead --- /dev/null +++ b/l10n/README.md @@ -0,0 +1,71 @@ +# L10n + +We use [Fluent](https://projectfluent.org/) for l10n. + +## For developers + +In order to make adding l10n strings easy, we have a couple of convenience methods exposed via `context.l10n` (in a server component) and `this.l10n` (in a custom element, with the `L10nMixin` applied). + +These can be used in a few ways: + +```js +// As a simple inline string, with no ID: it'll be +// automatically generated +context.l10n`Hello`; + +// As an inline string, with a manually specified ID: +// necessary if you need to disambiguate between two +// instances of the same string in en-US, which aren't +// the same in other locales +context.l10n("hello")`Hello`; + +// For more complex scenarios, involving arguments or +// HTML within the string: +context.l10n.raw({ + id: "hello-person", + args: { + name: "world", + }, + elements: { + link: { tag: "a", href: "https://example.com/" }, + }, +}); +``` + +The last of those examples requires manually adding a string to `./locales/en-US.ftl`, like this: + +```ftl +hello-person = Hello { $name }! +``` + +And it'll render into HTML like this: + +```html +Hello world! +``` + +The other examples are automatically extracted, and combined with the manually specified strings, into `./template.ftl`. + +### Pseudolocalization + +We have a few pseudo locales for testing if strings have been added, or if components will adapt to localized strings: + +- `qaa`: "accented" locale: adds accents to all characters, duplicates some vowels to create longer strings, wraps string in square brackets to help detect truncation +- `qai`: "id" locale: replaces strings with their identifiers, wrapped in square brackets + +The `qai` locale works all the time, the `qaa` locale must be manually generated with `node ./parser/transform.js` + +## For localizers + +The l10n experience isn't fantastic at the moment, and we have improvements to make, but it should be functional. + +Strings to be localized can be sourced from `./template.ftl`: this will include both manually added strings, as well as strings scraped from the code. Localized strings should be placed in `./locales/{locale}.ftl`. + +Adding Fluent comments to explain what context strings appear in is an open task, but for now they can be found in code: + +- if the string has an autogenerated ID (ending in a hash), search for the English string in code: it'll appear as `` {this|context}.l10n`{the string}` `` +- if the string has manual ID, search for the ID in code: it'll appear as `{this|context}.l10n({the id})` or `{this|context}.l10n({ id: {the id} })` + +If one English string is used in multiple places and requires multiple different strings in a locale, file an issue to manually add IDs for each string. + +Please also file an issue for any other problems you encounter with localizing - either with specific strings which need fixing, or general issues with the l10n process. diff --git a/l10n/context.js b/l10n/context.js index 105da7e85..20f514f51 100644 --- a/l10n/context.js +++ b/l10n/context.js @@ -1,10 +1,11 @@ -import getFluentContext from "./fluent.js"; +import getFluentContext, { loadFluentFile } from "./fluent.js"; /** * @param {string} locale * @returns {Promise} */ export async function addFluent(locale) { + await loadFluentFile(locale); return { locale: locale, l10n: getFluentContext(locale), diff --git a/l10n/djb2a.js b/l10n/djb2a.js new file mode 100644 index 000000000..e3a53a63d --- /dev/null +++ b/l10n/djb2a.js @@ -0,0 +1,33 @@ +/* eslint-disable unicorn/prefer-code-point */ +/** + * djb2a based on https://github.com/sindresorhus/djb2a + * + * @license MIT + * + * Copyright (c) Sindre Sorhus (https://sindresorhus.com) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// Explanation: https://stackoverflow.com/a/31621312/64949 + +const MAGIC_CONSTANT = 5381; + +/** + * @param {string} string + */ +export default function djb2a(string) { + let hash = MAGIC_CONSTANT; + + for (let index = 0; index < string.length; index++) { + // Equivalent to: `hash * 33 ^ string.charCodeAt(i)` + hash = ((hash << 5) + hash) ^ string.charCodeAt(index); + } + + // Convert it to an unsigned 32-bit integer. + return hash >>> 0; +} diff --git a/l10n/fluent.js b/l10n/fluent.js index fe9469a63..15b08ba24 100644 --- a/l10n/fluent.js +++ b/l10n/fluent.js @@ -2,33 +2,16 @@ import { FluentBundle, FluentResource } from "@fluent/bundle"; import insane from "insane"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import de_ftl from "../l10n/de.ftl"; -import enUS_ftl from "../l10n/en-US.ftl"; -import es_ftl from "../l10n/es.ftl"; -import fr_ftl from "../l10n/fr.ftl"; -import ja_ftl from "../l10n/ja.ftl"; -import ko_ftl from "../l10n/ko.ftl"; -import ptBR_ftl from "../l10n/pt-BR.ftl"; -import ru_ftl from "../l10n/ru.ftl"; -import zhCN_ftl from "../l10n/zh-CN.ftl"; -import zhTW_ftl from "../l10n/zh-TW.ftl"; +import enUS_ftl from "./locales/en-US.ftl"; +import { generateIdFromString } from "./utils.js"; /** * @import { AllowedTags } from "insane"; */ /** @type {Record} */ -const ftlMap = { +let ftlMap = { "en-US": enUS_ftl, - de: de_ftl, - es: es_ftl, - fr: fr_ftl, - ja: ja_ftl, - ko: ko_ftl, - "pt-BR": ptBR_ftl, - ru: ru_ftl, - "zh-CN": zhCN_ftl, - "zh-TW": zhTW_ftl, }; const ALLOWED_TAGS = ["i", "strong", "br", "em"]; @@ -149,7 +132,7 @@ export class Fluent { const parentMessage = bundle ? bundle.getMessage(id) : undefined; let message; - if (this.locale === "qa") { + if (this.locale === "qai") { return `[${id}${attr ? `.${attr}` : ""}]`; } @@ -209,7 +192,23 @@ function getLocale(locale) { } /** - * @param {string} [locale] + * @param {string} locale + */ +export async function loadFluentFile(locale) { + if (locale !== "qai" && !ftlMap[locale]) { + try { + const { default: localeStrings } = await import( + `./locales/${locale}.ftl` + ); + ftlMap[locale] = localeStrings; + } catch (error) { + console.error(error); + } + } +} + +/** + * @param {string} locale */ export default function getFluentContext(locale) { /** @@ -256,10 +255,14 @@ export default function getFluentContext(locale) { } // called directly as a template tag: // l10n`Foobar` - // TODO: create consistent logic for id generation at runtime and scrapetime const strings = idOrStrings; - const templateString = strings[0]; - return templateString || ""; + const templateString = strings[0] || ""; + const id = generateIdFromString(templateString); + const localizedString = getLocale(locale)?.get(id); + + return typeof localizedString === "string" + ? localizedString + : templateString; } /** diff --git a/l10n/locales/.gitignore b/l10n/locales/.gitignore new file mode 100644 index 000000000..bee5eadef --- /dev/null +++ b/l10n/locales/.gitignore @@ -0,0 +1 @@ +qa*.ftl \ No newline at end of file diff --git a/l10n/de.ftl b/l10n/locales/de.ftl similarity index 100% rename from l10n/de.ftl rename to l10n/locales/de.ftl diff --git a/l10n/en-US.ftl b/l10n/locales/en-US.ftl similarity index 97% rename from l10n/en-US.ftl rename to l10n/locales/en-US.ftl index 4f56ce4f6..f92f3726a 100644 --- a/l10n/en-US.ftl +++ b/l10n/locales/en-US.ftl @@ -1,3 +1,6 @@ +# WARNING: don't use this file as a source for strings requiring l10n, use ../template.ftl instead: +# this file only contains manually added strings, not ones inlined in code. See ../README.md for more details. + # TODO Use comments, see: https://firefox-source-docs.mozilla.org/l10n/fluent/review.html#comments # TODO Consider using terms, see: https://firefox-source-docs.mozilla.org/l10n/fluent/review.html#terms and https://projectfluent.org/fluent/guide/references.html#message-references diff --git a/l10n/es.ftl b/l10n/locales/es.ftl similarity index 100% rename from l10n/es.ftl rename to l10n/locales/es.ftl diff --git a/l10n/fr.ftl b/l10n/locales/fr.ftl similarity index 100% rename from l10n/fr.ftl rename to l10n/locales/fr.ftl diff --git a/l10n/ja.ftl b/l10n/locales/ja.ftl similarity index 100% rename from l10n/ja.ftl rename to l10n/locales/ja.ftl diff --git a/l10n/ko.ftl b/l10n/locales/ko.ftl similarity index 100% rename from l10n/ko.ftl rename to l10n/locales/ko.ftl diff --git a/l10n/pt-BR.ftl b/l10n/locales/pt-BR.ftl similarity index 100% rename from l10n/pt-BR.ftl rename to l10n/locales/pt-BR.ftl diff --git a/l10n/ru.ftl b/l10n/locales/ru.ftl similarity index 100% rename from l10n/ru.ftl rename to l10n/locales/ru.ftl diff --git a/l10n/zh-CN.ftl b/l10n/locales/zh-CN.ftl similarity index 100% rename from l10n/zh-CN.ftl rename to l10n/locales/zh-CN.ftl diff --git a/l10n/zh-TW.ftl b/l10n/locales/zh-TW.ftl similarity index 100% rename from l10n/zh-TW.ftl rename to l10n/locales/zh-TW.ftl diff --git a/l10n/mixin.js b/l10n/mixin.js index 9e6b03b74..0f813cb52 100644 --- a/l10n/mixin.js +++ b/l10n/mixin.js @@ -1,11 +1,14 @@ import { getSymmetricContext } from "../symmetric-context/both.js"; -import getFluentContext from "./fluent.js"; +import getFluentContext, { loadFluentFile } from "./fluent.js"; /** * @import { LitElement } from "lit"; */ +const locale = getSymmetricContext()?.locale; +if (locale) await loadFluentFile(locale); + /** * @template {new (...args: any[]) => LitElement} TBase * @param {TBase} Base @@ -17,7 +20,13 @@ export const L10nMixin = (Base) => */ constructor(...args) { super(...args); - const context = getSymmetricContext(); + let context = getSymmetricContext(); + if (!context) { + console.error("SymmetricContext is undefined, reverting to defaults"); + context = { + locale: "en-US", + }; + } this.locale = context.locale; this.l10n = getFluentContext(this.locale); } diff --git a/l10n/parser/extract.js b/l10n/parser/extract.js new file mode 100644 index 000000000..3b9989056 --- /dev/null +++ b/l10n/parser/extract.js @@ -0,0 +1,3 @@ +import { extract } from "./extractor.js"; + +await extract(); diff --git a/l10n/parser/extractor.js b/l10n/parser/extractor.js new file mode 100644 index 000000000..335b609fa --- /dev/null +++ b/l10n/parser/extractor.js @@ -0,0 +1,117 @@ +import { readFile, writeFile } from "node:fs/promises"; + +import path from "node:path"; + +import { fileURLToPath } from "node:url"; + +import { + Comment, + Identifier, + Message, + Pattern, + TextElement, + parse, + serialize, +} from "@fluent/syntax"; +import { Node, Project, SyntaxKind } from "ts-morph"; + +import { generateIdFromString } from "../utils.js"; + +/** + * @import { PropertyAccessExpression, TaggedTemplateExpression } from "ts-morph"; + */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export async function extract() { + const manualStrings = await readFile( + fileURLToPath(import.meta.resolve("../locales/en-US.ftl")), + "utf8", + ); + const fluentResource = parse(manualStrings, {}); + + const project = new Project({}); + project.addSourceFilesAtPaths( + path.join(__dirname, "..", "..", "components", "**", "*.js"), + ); + + /** @type {Map} */ + const map = new Map(); + + for (const file of project.getSourceFiles()) { + for (const taggedTemplate of file.getDescendantsOfKind( + SyntaxKind.TaggedTemplateExpression, + )) { + const tagNode = taggedTemplate.getTag(); + if (Node.isCallExpression(tagNode)) { + // e.g. this.l10n("foobar")`barfoo` + const expr = tagNode.getExpression(); + if (Node.isPropertyAccessExpression(expr) && isL10nTag(expr)) { + const [arg] = tagNode.getArguments(); + if (Node.isStringLiteral(arg)) { + const key = arg.getLiteralValue(); + const value = getLiteralValue(taggedTemplate); + map.set(key, value); + } + } + } else if ( + Node.isPropertyAccessExpression(tagNode) && + isL10nTag(tagNode) + ) { + // e.g. this.l10n`barfoo` + const value = getLiteralValue(taggedTemplate); + const key = generateIdFromString(value); + map.set(key, value); + } + } + } + + fluentResource.body = [ + new Comment( + `WARNING: do not edit this file, it's automatically generated by ExtractL10nPlugin. +If you need to manually add strings, do so in ./locales/en-US.ftl. See ./README.md for more details.`, + ), + ...fluentResource.body.filter( + (entry) => + !( + entry instanceof Comment && + (entry.content.startsWith("WARNING") || + entry.content.startsWith("TODO")) + ), + ), + ...[...map].map( + ([key, value]) => + new Message(new Identifier(key), new Pattern([new TextElement(value)])), + ), + ]; + + await writeFile( + fileURLToPath(import.meta.resolve("../template.ftl")), + serialize(fluentResource, {}), + "utf8", + ); +} + +/** + * @param {PropertyAccessExpression} tagNode + */ +function isL10nTag(tagNode) { + return ( + ["context", "this"].includes(tagNode.getExpression().getText()) && + "l10n" === tagNode.getName() + ); +} + +/** + * @param {TaggedTemplateExpression} taggedTemplate + */ +function getLiteralValue(taggedTemplate) { + const template = taggedTemplate.getTemplate(); + if (Node.isNoSubstitutionTemplateLiteral(template)) { + return template.getLiteralValue(); + } else { + throw new Error( + `L10n extractor: \`${taggedTemplate.getText()}\` has substitutions, which we don't support`, + ); + } +} diff --git a/l10n/parser/transform.js b/l10n/parser/transform.js new file mode 100644 index 000000000..4b24f14fc --- /dev/null +++ b/l10n/parser/transform.js @@ -0,0 +1,78 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { Transformer, parse, serialize } from "@fluent/syntax"; + +/** + * @import { TextElement } from "@fluent/syntax"; + */ + +class AccentTransformer extends Transformer { + // eslint-disable-next-line unicorn/consistent-function-scoping + MARKS = Array.from({ length: 0x3_6f - 0x3_00 + 1 }, (_, i) => + String.fromCodePoint(0x3_00 + i), + ); + + /** + * @param {number} min + * @param {number} max + */ + _randInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + /** + * @param {number} min + * @param {number} max + */ + _randMarks(min, max) { + const n = this._randInt(min, max); + let out = ""; + for (let i = 0; i < n; i++) { + out += this.MARKS[this._randInt(0, this.MARKS.length - 1)]; + } + return out; + } + + /** @param {string} str */ + _accent(str) { + return [...str] + .flatMap((char) => { + return /\S/i.test(char) + ? ("aeiou".includes(char.toLowerCase()) + ? Array.from({ length: this._randInt(1, 3) }, () => char) + : [char] + ).map((char) => char + this._randMarks(1, 1)) + : char; + }) + .join(""); + } + + /** + * @param {TextElement} node + */ + visitTextElement(node) { + node.value = + "[" + + node.value + .split(/(<[^>]*>)/g) + .map((token) => (token.startsWith("<") ? token : this._accent(token))) + .join("") + + "]"; + return node; + } +} + +const strings = await readFile( + fileURLToPath(import.meta.resolve("../template.ftl")), + "utf8", +); +const resource = parse(strings, {}); + +const accentedResource = resource.clone(); +new AccentTransformer().genericVisit(accentedResource); +await writeFile( + fileURLToPath(import.meta.resolve("../locales/qaa.ftl")), + serialize(accentedResource, {}), + "utf8", +); diff --git a/l10n/template.ftl b/l10n/template.ftl new file mode 100644 index 000000000..cc750684c --- /dev/null +++ b/l10n/template.ftl @@ -0,0 +1,288 @@ +# WARNING: do not edit this file, it's automatically generated by ExtractL10nPlugin. +# If you need to manually add strings, do so in ./locales/en-US.ftl. See ./README.md for more details. + +article-footer-last-modified = This page was last modified on by MDN contributors. +article-footer-source-title = Folder: { $folder } (Opens in a new tab) +baseline-asterisk = Some parts of this feature may have varying levels of support. +baseline-high-extra = This feature is well established and works across many devices and browser versions. It’s been available across browsers since { $date }. +baseline-low-extra = Since { $date }, this feature works across the latest devices and browser versions. This feature might not work in older devices or browsers. +baseline-not-extra = This feature is not Baseline because it does not work in some of the most widely-used browsers. +baseline-supported-in = Supported in { $browsers } +baseline-unsupported-in = Not widely supported in { $browsers } +baseline-supported-and-unsupported-in = Supported in { $supported }, but not widely supported in { $unsupported } +homepage-hero-title = Resources for Developers,
by Developers +homepage-hero-description = Documenting CSS, HTML, and JavaScript, since 2005. +not-found-title = Page not found +not-found-description = Sorry, the page { $url } could not be found. +not-found-fallback-english = Good news: The page you requested exists in English. +not-found-fallback-search = The page you requested doesn't exist, but you could try a site search for: +not-found-back = Go back to the home page +reference-toc-header = In this article +footer-mofo = Visit Mozilla Corporation’s not-for-profit parent, the Mozilla Foundation. +footer-copyright = Portions of this content are ©1998–{ $year } by individual mozilla.org contributors. Content available under a Creative Commons license. +search-modal-site-search = Site search for { $query } +site-search-search-stats = Found { $results } documents. +site-search-suggestion-matches = + { $relation -> + [gt] + more than { $matches -> + [one] { $matches } match + *[other] { $matches } matches + } + *[eq] + { $matches -> + [one] { $matches } match + *[other] { $matches } matches + } + } +site-search-suggestions-text = Did you mean: +blog-time-to-read = + { $minutes -> + [one] { $minutes } minute read + *[other] { $minutes } minutes read + } +blog-post-not-found = Blog post not found. +blog-previous = Previous post +blog-next = Next post +-brand-name-obs = HTTP Observatory +obs-report = Report +obs-title = { -brand-name-obs } +obs-landing-intro = Launched in 2016, the { -brand-name-obs } enhances web security by analyzing compliance with best security practices. It has provided insights to over 6.9 million websites through 47 million scans. +obs-assessment = Developed by Mozilla, the { -brand-name-obs } performs an in-depth assessment of a site’s HTTP headers and other key security configurations. +obs-scanning = Its automated scanning process provides developers and website administrators with detailed, actionable feedback, focusing on identifying and addressing potential security vulnerabilities. +obs-security = The tool is instrumental in helping developers and website administrators strengthen their sites against common security threats in a constantly advancing digital environment. +obs-mdn = The { -brand-name-obs } provides effective security insights, guided by Mozilla's expertise and commitment to a safer and more secure internet and based on well-established trends and guidelines. +compat-loading = Loading… +compat-browser-version-date = { $browser } { $version } – Released { $date } +compat-browser-version-released = Released { $date } +compat-link-report-issue = Report problems with this compatibility data +compat-link-report-issue-title = Report an issue with this compatibility data +compat-link-report-missing-title = Report missing compatibility data +compat-link-report-missing = Report this issue +compat-link-source = View data on GitHub +compat-link-source-title = File: { $filename } +compat-deprecated = Deprecated +compat-experimental = Experimental +compat-nonstandard = Non-standard +compat-no = No +compat-support-full = Full support +compat-support-partial = Partial support +compat-support-no = No support +compat-support-unknown = Support unknown +compat-support-preview = Preview browser support +compat-support-prefix = Implemented with the vendor prefix: { $prefix } +compat-support-altname = Alternate name: { $altname } +compat-support-removed = Removed in { $version } and later +compat-support-see-impl-url = See { $label } +compat-support-flags = + { NUMBER($has_added) -> + [one] From version { $version_added } + *[other] { "" } + }{ $has_last -> + [one] + { NUMBER($has_added) -> + *[zero] Until { $versionLast } users + [one] { " " }until { $versionLast } users + } + *[zero] + { NUMBER($has_added) -> + *[zero] Users + [one] { " " }users + } + } + { " " }must explicitly set the { $flag_name }{ " " } + { $flag_type -> + *[preference] preference + [runtime_flag] runtime flag + }{ NUMBER($has_value) -> + [one] { " " }to { $flag_value } + *[other] { "" } + }{ "." } + { $flag_type -> + [preference] To change preferences in { $browser_name }, visit { $browser_pref_url }. + *[other] { "" } + } +compat-legend = Legend +compat-legend-tip = Tip: you can click/tap on a cell for more information. +compat-legend-yes = { compat-support-full } +compat-legend-partial = { compat-support-partial } +compat-legend-preview = In development. Supported in a pre-release version. +compat-legend-no = { compat-support-no } +compat-legend-unknown = Compatibility unknown +compat-legend-experimental = { compat-experimental }. Expect behavior to change in the future. +compat-legend-nonstandard = { compat-nonstandard }. Check cross-browser support before using. +compat-legend-deprecated = { compat-deprecated }. Not for use in new websites. +compat-legend-footnote = See implementation notes. +compat-legend-disabled = User must explicitly enable this feature. +compat-legend-altname = Uses a non-standard name. +compat-legend-prefix = Requires a vendor prefix or different name for use. +compat-legend-more = Has more compatibility info. +placement-note = Ad +placement-no = Don't want to see ads? +pagination-next = Next page +pagination-prev = Previous page +pagination-current = Current page +pagination-goto = Go to page { $page } +logout = Sign out +login = Log in +settings = My settings +example-play-button-label = Play +example-play-button-title = Run example in MDN Playground (opens in new tab) +skip-to-main-content-kp9i = Skip to main content +skip-to-search-17gk = Skip to search +learn-how-to-contribute-g07f = Learn how to contribute +view-this-page-on-github-1hde = View this page on GitHub +this-will-take-you-to-github-to-9hic = This will take you to GitHub to file a new issue. +report-a-problem-with-this-conte-1mwa = Report a problem with this content +baseline-cross-148l = Baseline Cross +baseline-check-14gc = Baseline Check +limited-availability-13ek = Limited availability +baseline-1gj8 = Baseline +widely-available-1u5i = Widely available +newly-available-d6a7 = Newly available +check-2xey = check +cross-2xgq = cross +learn-more-1o3l = Learn more +see-full-compatibility-omsy = See full compatibility +report-feedback-13qx = Report feedback +blog-previous = Previous Post +blog-next = Next post +blog-it-better-zfyf = Blog it better +reference-toc-header = In this article +blog-post-not-found = Blog post not found +save-in-collection-2sdd = Save in collection +remove-1ih9 = Remove +save-yjzj = Save +add-to-collection-1p4p = Add to collection +collection-1y5l = Collection: +saved-articles-mule = Saved articles +new-collection-18z4 = New collection +name-3kiu = Name: +note-3khl = Note: +saving-1bub = Saving… +cancel-18x3 = Cancel +deleting-1rvl = Deleting… +delete-16wm = Delete +theme-default = OS default +light-3loo = Light +dark-yjkq = Dark +switch-color-theme-10hx = Switch color theme +theme-3rth = Theme +compat-link-report-issue-title = Report an issue with this compatibility data +compat-link-report-issue = Report problems with this compatibility data +compat-link-source = View data on GitHub +compat-experimental = Experimental +compat-deprecated = Experimental +compat-nonstandard = Non-standard +compat-support-partial = Partial support +compat-support-preview = Preview support +compat-support-full = Full support +compat-support-no = No support +compat-support-unknown = Support unknown +compat-yes = Yes +compat-partial = Partial +compat-no = No +compat-legend = Legend +compat-legend-tip = Tip: you can click/tap on a cell for more information. +compat-link-report-missing-title = Report missing compatibility data +compat-link-report-missing = Report this issue +compat-loading = Loading… +content-feedback-question = Was this page helpful to you? +yes-376p = Yes +no-3hna = No +content-feedback-reason = Why was this page not helpful to you? +submit-1ivp = Submit +content-feedback-thanks = Thank you for your feedback! +want-to-be-part-of-the-journey-tj68 = Want to be part of the journey? +our-constant-quest-for-innovatio-o2x9 = Our constant quest for innovation starts here, with you. Every part of MDN (docs, demos and the site itself) springs from our incredible open community of developers. Please join us! +contributor-profile-h45r = Contributor profile +copied-18w5 = Copied +copy-failed-aec2 = Copy failed! +copy-yjn7 = Copy +mdn-on-github-ha0a = MDN on GitHub +mdn-on-bluesky-15oy = MDN on Bluesky +mdn-on-x-1ht5 = MDN on X +mdn-on-mastodon-gz9n = MDN on Mastodon +mdn-blog-rss-feed-xx1x = MDN blog RSS feed +mdn-3769 = MDN +about-3ebm = About +blog-yjmm = Blog +mozilla-careers-6a7r = Mozilla careers +advertise-with-us-1m98 = Advertise with us +mdn-plus-1hsl = MDN Plus +product-help-twv0 = Product help +contribute-ur20 = Contribute +mdn-community-1a2k = MDN Community +community-resources-175y = Community resources +writing-guidelines-nkte = Writing guidelines +mdn-discord-1d86 = MDN Discord +developers-m0ik = Developers +web-technologies-1khu = Web technologies +learn-web-development-psay = Learn web development +guides-16q9 = Guides +tutorials-1phf = Tutorials +glossary-1klw = Glossary +hacks-blog-18lu = Hacks blog +website-privacy-notice-19vo = Website Privacy Notice +cookies-1oga = Cookies +legal-3lxp = Legal +community-participation-guidelin-ir07 = Community Participation Guidelines +mdn-logo-1ht4 = MDN logo +footer-tagline = Your blueprint for a better internet. +mozilla-logo-ga04 = Mozilla logo +generic-toc__header = In this article +featured-articles-y0ni = Featured articles +latest-news-1tvp = Latest news +recent-contributions-kik0 = Recent contributions +contributor-spotlight-1qnv = Contributor Spotlight +get-involved-10ku = Get involved +search-the-site-13uv = Search the site +search-1j74 = Search +reset-3swj = Reset +value-select-873f = Value select +the-current-value-is-not-support-1m7m = The current value is not supported by your browser. +run-example-and-show-console-ou-14k0 = Run example, and show console output +run-376m = Run +reset-example-and-clear-console-whty = Reset example, and clear console output +console-output-q07x = Console output +output-1bw1 = Output +remember-language-8ut5 = Remember language +enable-this-setting-to-always-sw-1ah2 = Enable this setting to always switch to the current language when available. (Click to learn more.) +login-3lw0 = Login +obs-about-title = About the HTTP Observatory +read-our-faq-rafe = Read our FAQ +report-feedback-1e4t = Report Feedback +rescan-1igp = Rescan +wait-a-minute-to-rescan-8uvn = Wait a minute to rescan +faq-3765 = FAQ +pagination-5qfe = Pagination +do-you-really-want-to-clear-ever-192d = Do you really want to clear everything? +do-you-really-want-to-revert-you-1o83 = Do you really want to revert your changes? +playground-gj6l = Playground +format-15w4 = Format +share-3tz9 = Share +clear-3ill = Clear +console-1ofu = Console +share-markdown-1fv6 = Share Markdown +copy-markdown-to-clipboard-clet = Copy markdown to clipboard +share-your-code-via-permalink-oor0 = Share your code via Permalink +copy-to-clipboard-8ok2 = Copy to clipboard +create-link-1n16 = Create link +loading-search-index-19qe = Loading search index… +exit-search-1yon = Exit search +filter-sidebar-1v9u = Filter sidebar +filter-15rf = Filter +clear-filter-input-ud9f = Clear filter input +previous-17il = Previous +next-yjpj = Next +site-search-suggestions-text = Did you mean… +searching-1q50 = Searching… +language-1s1g = Language +both-yjmi = Both +this-feature-does-not-appear-to-lkgx = This feature does not appear to be defined in any specification. +specification-etos = Specification +hide-this-survey-1c9b = Hide this survey +toggle-sidebar-hffq = Toggle sidebar +user-yjrj = User +open-in-editor-5g3t = Open in editor +view-on-mdn-1nup = View on MDN diff --git a/l10n/utils.js b/l10n/utils.js new file mode 100644 index 000000000..f616120fc --- /dev/null +++ b/l10n/utils.js @@ -0,0 +1,15 @@ +import djb2a from "./djb2a.js"; + +/** + * @param {string} str + */ +export function generateIdFromString(str) { + const hash = djb2a(str).toString(36).slice(0, 4); + const slug = str + .slice(0, 32) + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, " ") + .trim() + .replaceAll(" ", "-"); + return `${slug}-${hash}`; +} diff --git a/package-lock.json b/package-lock.json index 215c56d52..0ed19c8dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "@csstools/postcss-global-data": "^3.1.0", "@eslint/compat": "^1.3.2", "@eslint/js": "^9.34.0", + "@fluent/syntax": "^0.19.0", "@jackolope/lit-analyzer": "^3.2.0", "@jackolope/ts-lit-plugin": "^3.1.6", "@mdn/browser-compat-data": "^7.0.0", @@ -96,6 +97,7 @@ "stylelint-config-recess-order": "^7.2.0", "stylelint-config-standard": "^39.0.0", "svgo-loader": "^4.0.0", + "ts-morph": "^26.0.0", "typescript": "^5.9.2", "web-features": "^2.41.1", "webpack-dev-middleware": "^7.4.2", @@ -3811,6 +3813,17 @@ "npm": ">=7.0.0" } }, + "node_modules/@fluent/syntax": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@fluent/syntax/-/syntax-0.19.0.tgz", + "integrity": "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3887,6 +3900,29 @@ "node": ">=18" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -7772,6 +7808,34 @@ "node": ">=10.13.0" } }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", @@ -10794,6 +10858,13 @@ "node": ">=4" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -26165,6 +26236,17 @@ "typescript": ">=4.0.0" } }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/ts-simple-type": { "version": "2.0.0-next.0", "resolved": "https://registry.npmjs.org/ts-simple-type/-/ts-simple-type-2.0.0-next.0.tgz", diff --git a/package.json b/package.json index d720e38d9..306e930d2 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@csstools/postcss-global-data": "^3.1.0", "@eslint/compat": "^1.3.2", "@eslint/js": "^9.34.0", + "@fluent/syntax": "^0.19.0", "@jackolope/lit-analyzer": "^3.2.0", "@jackolope/ts-lit-plugin": "^3.1.6", "@mdn/browser-compat-data": "^7.0.0", @@ -116,6 +117,7 @@ "stylelint-config-recess-order": "^7.2.0", "stylelint-config-standard": "^39.0.0", "svgo-loader": "^4.0.0", + "ts-morph": "^26.0.0", "typescript": "^5.9.2", "web-features": "^2.41.1", "webpack-dev-middleware": "^7.4.2", diff --git a/rspack.config.js b/rspack.config.js index 65f637dac..95b8b8191 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -14,6 +14,7 @@ import { StatsWriterPlugin } from "webpack-stats-plugin"; import { BUILD_OUT_ROOT } from "./build/env.js"; import { CSPHashPlugin } from "./build/plugins/csp-hash.js"; +import { ExtractL10nPlugin } from "./build/plugins/extract-l10n.js"; import { GenerateElementMapPlugin } from "./build/plugins/generate-element-map.js"; import { override as svgoOverride } from "./svgo.config.js"; @@ -346,6 +347,7 @@ const clientConfig = merge( }, ), !isProd && new GenerateElementMapPlugin(), + !isProd && new ExtractL10nPlugin(), new rspack.CssExtractRspackPlugin({ filename: isProd ? "[name].[contenthash].css" : "[name].css", // chunkFilename: "[name].[contenthash].css", diff --git a/server.js b/server.js index 614641eae..66940cf01 100644 --- a/server.js +++ b/server.js @@ -209,6 +209,12 @@ export async function startServer() { }, selfHandleResponse: true, on: { + proxyReq: async (req) => { + const locale = req.path.split("/")[1]; + if (locale && /^q[a-t][a-z]$/.test(locale)) { + req.path = req.path.replace(locale, "en-US"); + } + }, proxyRes: async (proxyRes, req, res) => { const contentType = proxyRes.headers["content-type"] || ""; const statusCode = proxyRes.statusCode || 500; diff --git a/symmetric-context/both.js b/symmetric-context/both.js index 14f6aa0c6..b122b84a2 100644 --- a/symmetric-context/both.js +++ b/symmetric-context/both.js @@ -1,7 +1,7 @@ /** * Runs on either client or server, * and returns the client or server context respectively - * @returns {import("./types.js").SymmetricContext} + * @returns {import("./types.js").SymmetricContext | undefined} */ export function getSymmetricContext() { const serverStore = globalThis.__MDNServerContext?.getStore();