From f65288ef6cd7ee7e2b8eb1c8f042d9aca3d7f04c Mon Sep 17 00:00:00 2001 From: Claas Augner Date: Fri, 20 Mar 2026 11:38:46 +0100 Subject: [PATCH 1/5] feat(menu): introduce `tabs.js` and `l10n.js` to localize menu strings - `tabs.js` enriches `tabs.json` with l10n IDs generated from the text. - `l10n.js` derives a strings Map for the extractor --- components/menu/l10n.js | 39 ++++++++++++++++++++++++ components/menu/tabs.js | 61 ++++++++++++++++++++++++++++++++++++++ components/menu/types.d.ts | 12 ++++++-- 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 components/menu/l10n.js create mode 100644 components/menu/tabs.js diff --git a/components/menu/l10n.js b/components/menu/l10n.js new file mode 100644 index 000000000..debb40dcb --- /dev/null +++ b/components/menu/l10n.js @@ -0,0 +1,39 @@ +import { TABS } from "./tabs.js"; + +/** + * All localizable menu strings, derived from tabs.js. + * Used by the l10n extractor only — not imported at runtime. + * + * @type {Map} + */ +export const strings = new Map( + TABS.flatMap((tab) => { + /** @type {[string, string][]} */ + const entries = []; + + if (typeof tab.buttonL10nId === "string") { + entries.push([tab.buttonL10nId, /** @type {string} */ (tab.buttonText)]); + } else { + entries.push([tab.buttonL10nId.long, tab.buttonText.long], [tab.buttonL10nId.short, tab.buttonText.short]); + } + + if (!("panelTitle" in tab)) return entries; + + entries.push([tab.panelTitle.l10nId, tab.panelTitle.text]); + + for (const group of tab.panelGroups) { + if (group.titleL10nId) { + entries.push([group.titleL10nId, /** @type {string} */ (group.title)]); + } + for (const item of group.items) { + if (!("l10nId" in item)) continue; + entries.push([item.l10nId, item.text]); + if (item.labelL10nId) { + entries.push([item.labelL10nId, /** @type {string} */ (item.label)]); + } + } + } + + return entries; + }), +); diff --git a/components/menu/tabs.js b/components/menu/tabs.js new file mode 100644 index 000000000..e5dadc3fe --- /dev/null +++ b/components/menu/tabs.js @@ -0,0 +1,61 @@ +import tabsData from "./tabs.json" with { type: "json" }; + +/** + * Converts a string to a kebab-case slug for use in l10n IDs. + * @param {string} str + */ +const slugify = (str) => + str + .toLowerCase() + .replaceAll(/[^\p{L}\p{N}]+/gu, "-") + .replaceAll(/^-|-$/g, ""); + +/** + * Menu tabs enriched with l10n IDs. + * + * @type {ReadonlyArray} + */ +export const TABS = Object.freeze( + /** @type {import("./types.js").MenuTab[]} */ ( + tabsData.map((tab) => { + const buttonL10nId = + typeof tab.buttonText === "string" + ? `menu-${tab.id}` + : { + long: `menu-${tab.id}`, + short: `menu-${tab.id}-short`, + }; + + if (!("panelTitle" in tab)) { + return { ...tab, buttonL10nId }; + } + + return { + ...tab, + buttonL10nId, + panelTitle: { + ...tab.panelTitle, + l10nId: `menu-${tab.id}-panel-title`, + }, + panelGroups: tab.panelGroups.map((group, gi) => { + const groupSlug = group.title ? slugify(group.title) : String(gi); + return { + ...group, + titleL10nId: group.title + ? `menu-${tab.id}-${groupSlug}` + : undefined, + items: group.items.map((item) => { + if ("render" in item) return item; + const l10nId = `menu-${tab.id}-${groupSlug}-${slugify(item.text)}`; + return { + ...item, + l10nId, + labelL10nId: item.label ? `${l10nId}-label` : undefined, + }; + }), + }; + }), + }; + }) + ), +); diff --git a/components/menu/types.d.ts b/components/menu/types.d.ts index 877a48713..24a2176f5 100644 --- a/components/menu/types.d.ts +++ b/components/menu/types.d.ts @@ -1,19 +1,25 @@ import { ServerRenderedTemplate } from "@lit-labs/ssr"; + +type ButtonText = string | { long: string; short: string }; +type ButtonL10nId = string | { long: string; short: string }; + interface BaseTab { id: string; buttonText: ButtonText; + buttonL10nId: ButtonL10nId; } -type ButtonText = string | { long: string; short: string }; - interface PanelTitle { text: string; slug?: string; + l10nId: string; } interface BaseItem { text: string; label?: string; + l10nId: string; + labelL10nId?: string; } interface SlugItem extends BaseItem { @@ -35,6 +41,7 @@ type PanelItem = LinkItem | RenderItem; interface PanelGroup { title?: string; + titleL10nId?: string; items: PanelItem[]; } @@ -45,6 +52,7 @@ interface DropdownTab extends BaseTab { interface LinkTab extends BaseTab { href: string; + buttonL10nId: string; } export type MenuTab = DropdownTab | LinkTab; From 22d14779ce51c15a46cfe85dd09b27492c204c9e Mon Sep 17 00:00:00 2001 From: Claas Augner Date: Fri, 20 Mar 2026 11:39:02 +0100 Subject: [PATCH 2/5] refactor(menu): consume tabs via `tabs.js` --- components/menu/constants.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/components/menu/constants.js b/components/menu/constants.js index 995280c23..12647497b 100644 --- a/components/menu/constants.js +++ b/components/menu/constants.js @@ -1,8 +1,6 @@ import missingDocs from "./missing-docs.json" with { type: "json" }; -import tabs from "./tabs.json" with { type: "json" }; -/** @type {Readonly}*/ -export const TABS = Object.freeze(tabs); +export { TABS } from "./tabs.js"; /** * Lists untranslated menu pages per locale. From ecd6bfd7b58748758b9c5dc1ce81ee579e156977 Mon Sep 17 00:00:00 2001 From: Claas Augner Date: Fri, 20 Mar 2026 11:41:59 +0100 Subject: [PATCH 3/5] feat(menu): localize text --- components/menu/l10n.js | 12 ++++++--- components/menu/server.js | 54 ++++++++++++++++++++++++-------------- components/menu/tabs.js | 9 ++++--- components/menu/types.d.ts | 21 +++++++++++++++ 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/components/menu/l10n.js b/components/menu/l10n.js index debb40dcb..90fad59f0 100644 --- a/components/menu/l10n.js +++ b/components/menu/l10n.js @@ -11,10 +11,16 @@ export const strings = new Map( /** @type {[string, string][]} */ const entries = []; - if (typeof tab.buttonL10nId === "string") { - entries.push([tab.buttonL10nId, /** @type {string} */ (tab.buttonText)]); + if (typeof tab.buttonText === "string") { + entries.push([/** @type {string} */ (tab.buttonL10nId), tab.buttonText]); } else { - entries.push([tab.buttonL10nId.long, tab.buttonText.long], [tab.buttonL10nId.short, tab.buttonText.short]); + const buttonL10nId = /** @type {{ long: string; short: string }} */ ( + tab.buttonL10nId + ); + entries.push( + [buttonL10nId.long, tab.buttonText.long], + [buttonL10nId.short, tab.buttonText.short], + ); } if (!("panelTitle" in tab)) return entries; diff --git a/components/menu/server.js b/components/menu/server.js index fb4cc6a05..02dc9b848 100644 --- a/components/menu/server.js +++ b/components/menu/server.js @@ -33,9 +33,9 @@ export class Menu extends ServerComponent { * Renders a link to a page. * * @param {string} slug - The link slug (the part after `/en-US/docs/`!). - * @param {string} text - The link text. + * @param {import("../../types/fluent.js").L10nTag} text - The link text. * @param {object} [options] - * @param {string} [options.label] - The title and aria-label of the link. + * @param {import("../../types/fluent.js").L10nTag} [options.label] - The title and aria-label of the link. * @param {boolean} [options.primary] - Whether this is the primary link (in the panel title). */ const link = (slug, text, { label, primary = false } = {}) => { @@ -69,27 +69,29 @@ export class Menu extends ServerComponent { class="menu__tab-link" href=${tab.href} data-glean-id=${`menu_click_link: top-level -> ${tab.href}`} - >${tab.buttonText}${context.l10n(tab.buttonL10nId)}` : html`