diff --git a/docs/configuration.md b/docs/configuration.md index 6c50d41f..e5c71aae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,3 +66,76 @@ Global options apply to all editors in your app and are configured using `Lexxy. {: .important } When overriding configuration, call `Lexxy.configure` immediately after your import statement. Editor elements are registered after the import's call stack completes, so configuration must happen synchronously to take effect. + +## Internationalization (i18n) + +Lexxy ships with English as the default locale. You can register additional locales and set the active locale: + +```js +import { configure } from "@37signals/lexxy" + +configure({ + i18n: { + locale: "ar", + ar: { + toolbar: { + bold: "عريض", + italic: "مائل", + // ... see src/config/locales/en.js for full list + }, + table: { + row: "صف", + column: "عمود", + rowCount: { one: "صف واحد", two: "صفان", few: "%{count} صفوف", many: "%{count} صفًا", other: "%{count} صف" }, + columnCount: { one: "عمود واحد", two: "عمودان", few: "%{count} أعمدة", many: "%{count} عمودًا", other: "%{count} عمود" }, + // ... + } + } + } +}) +``` + +### Partial translations + +You only need to provide the keys you want to translate. Missing keys fall back to English: + +```js +configure({ + i18n: { + locale: "fr", + fr: { + toolbar: { bold: "Gras", italic: "Italique" } + } + } +}) +``` + +### Pluralization + +Table count labels support locale-aware pluralization via [`Intl.PluralRules`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules). Provide an object with plural category keys (`zero`, `one`, `two`, `few`, `many`, `other`): + +```js +{ + table: { + rowCount: { one: "%{count} row", other: "%{count} rows" } + } +} +``` + +### Interpolation + +Table labels use `%{variable}` placeholders for interpolation: + +```js +{ + table: { + add: "Add %{childType}", // "Add row", "Add column" + row: "row", // interpolated into %{childType} + column: "column", + } +} +``` + +### Available keys + +See [`src/config/locales/en.js`](../src/config/locales/en.js) for the complete list of translatable keys. diff --git a/src/config/i18n.js b/src/config/i18n.js new file mode 100644 index 00000000..9f377160 --- /dev/null +++ b/src/config/i18n.js @@ -0,0 +1,55 @@ +import en from "./locales/en" + +export default class I18n { + #locale + #registry + + constructor(locales = {}, defaultLocale = "en") { + this.#locale = defaultLocale + this.#registry = { en, ...locales } + } + + get locale() { return this.#locale } + set locale(value) { this.#locale = value } + + registerLocale(name, translations) { + this.#registry[name] = translations + } + + t(path, values = {}) { + let result = resolve(this.#registry[this.#locale], path) + + if (result === undefined && this.#locale !== "en") { + result = resolve(this.#registry.en, path) + } + + if (result === undefined) return path + + if (result && typeof result === "object") { + if (!("count" in values)) return path + const rule = selectPluralForm(this.#locale, values.count) + result = result[rule] ?? result.other ?? path + } + + if (typeof result === "string") { + return interpolate(result, values) + } + + return path + } +} + +const pluralRulesCache = {} + +function resolve(tree, path) { + return path.split(".").reduce((node, key) => node?.[key], tree) +} + +function selectPluralForm(locale, count) { + pluralRulesCache[locale] ??= new Intl.PluralRules(locale) + return pluralRulesCache[locale].select(count) +} + +function interpolate(str, values) { + return str.replace(/%\{(\w+)\}/g, (_, key) => values[key] ?? `%{${key}}`) +} diff --git a/src/config/lexxy.js b/src/config/lexxy.js index e26c591c..34e5d785 100644 --- a/src/config/lexxy.js +++ b/src/config/lexxy.js @@ -1,5 +1,6 @@ import Configuration from "./configuration" import { range } from "../helpers/array_helper.js" +import I18n from "./i18n" const global = new Configuration({ attachmentTagName: "action-text-attachment", @@ -30,13 +31,25 @@ const presets = new Configuration({ } }) +const i18n = new I18n() + export default { global, presets, - configure({ global: newGlobal, ...newPresets }) { + i18n, + configure({ global: newGlobal, i18n: i18nConfig, ...newPresets }) { if (newGlobal) { global.merge(newGlobal) } + if (i18nConfig) { + const { locale, ...locales } = i18nConfig + for (const [ name, translations ] of Object.entries(locales)) { + i18n.registerLocale(name, translations) + } + if (locale) { + i18n.locale = locale + } + } presets.merge(newPresets) } } diff --git a/src/config/locales/en.js b/src/config/locales/en.js new file mode 100644 index 00000000..d7aa1e7d --- /dev/null +++ b/src/config/locales/en.js @@ -0,0 +1,47 @@ +export default { + toolbar: { + bold: "Bold", + italic: "Italic", + format: "Text formatting", + paragraph: "Normal", + paragraphTitle: "Paragraph", + headingLarge: "Large Heading", + headingLargeTitle: "Large heading", + headingMedium: "Medium Heading", + headingMediumTitle: "Medium heading", + headingSmall: "Small Heading", + headingSmallTitle: "Small heading", + strikethrough: "Strikethrough", + underline: "Underline", + highlight: "Color highlight", + removeHighlight: "Remove all coloring", + link: "Link", + linkPlaceholder: "Enter a URL\u2026", + linkSubmit: "Link", + unlink: "Unlink", + quote: "Quote", + code: "Code", + bulletList: "Bullet list", + numberedList: "Numbered list", + table: "Insert a table", + divider: "Insert a divider", + undo: "Undo", + redo: "Redo", + overflow: "Show more toolbar buttons", + overflowMenu: "More toolbar buttons", + uploadImage: "Add images and video", + uploadFile: "Upload files", + }, + table: { + addBefore: "Add %{childType} before", + addAfter: "Add %{childType} after", + add: "Add %{childType}", + remove: "Remove %{childType}", + toggleStyle: "Toggle %{childType} style", + deleteTable: "Delete this table?", + row: "row", + column: "column", + rowCount: { one: "%{count} row", other: "%{count} rows" }, + columnCount: { one: "%{count} column", other: "%{count} columns" }, + }, +} diff --git a/src/elements/editor.js b/src/elements/editor.js index a5f30e3a..8154b69f 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -25,6 +25,7 @@ import Clipboard from "../editor/clipboard" import Extensions from "../editor/extensions" import { BrowserAdapter } from "../editor/adapters/browser_adapter" import { getHighlightStyles } from "../helpers/format_helper" +import Lexxy from "../config/lexxy" import { CustomActionTextAttachmentNode } from "../nodes/custom_action_text_attachment_node" import { exportTextNodeDOM } from "../helpers/text_node_export_helper" @@ -35,6 +36,7 @@ import { TablesExtension } from "../extensions/tables_extension" import { AttachmentsExtension } from "../extensions/attachments_extension.js" import { FormatEscapeExtension } from "../extensions/format_escape_extension.js" +function t(key, values) { return Lexxy.i18n.t(key, values) } export class LexicalEditorElement extends HTMLElement { static formAssociated = true @@ -659,10 +661,10 @@ export class LexicalEditorElement extends HTMLElement { if (!this.supportsRichText) return [] return [ - { label: "Normal", command: "setFormatParagraph", tag: null }, - { label: "Large heading", command: "setFormatHeadingLarge", tag: "h2" }, - { label: "Medium heading", command: "setFormatHeadingMedium", tag: "h3" }, - { label: "Small heading", command: "setFormatHeadingSmall", tag: "h4" }, + { label: t("toolbar.paragraph"), command: "setFormatParagraph", tag: null }, + { label: t("toolbar.headingLargeTitle"), command: "setFormatHeadingLarge", tag: "h2" }, + { label: t("toolbar.headingMediumTitle"), command: "setFormatHeadingMedium", tag: "h3" }, + { label: t("toolbar.headingSmallTitle"), command: "setFormatHeadingSmall", tag: "h4" }, ] } diff --git a/src/elements/table/table_tools.js b/src/elements/table/table_tools.js index 199fcb7a..7350551e 100644 --- a/src/elements/table/table_tools.js +++ b/src/elements/table/table_tools.js @@ -7,6 +7,9 @@ import theme from "../../config/theme" import { handleRollingTabIndex } from "../../helpers/accessibility_helper" import { createElement } from "../../helpers/html_helper" import { nextFrame } from "../../helpers/timing_helpers" +import Lexxy from "../../config/lexxy" + +function t(key, values) { return Lexxy.i18n.t(key, values) } export class TableTools extends HTMLElement { connectedCallback() { @@ -58,16 +61,17 @@ export class TableTools extends HTMLElement { } #createButtonsContainer(childType, setCountProperty, moreMenu) { + const typeName = t(`table.${childType}`) const container = createElement("div", { className: `lexxy-floating-controls__group lexxy-table-control lexxy-table-control--${childType}` }) - const plusButton = this.#createButton(`Add ${childType}`, { action: "insert", childType, direction: "after" }, "+") - const minusButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType }, "−") + const plusButton = this.#createButton(t("table.add", { childType: typeName }), { action: "insert", childType, direction: "after" }, "+") + const minusButton = this.#createButton(t("table.remove", { childType: typeName }), { action: "delete", childType }, "−") const dropdown = createElement("details", { className: "lexxy-table-control__more-menu" }) dropdown.setAttribute("name", "lexxy-dropdown") dropdown.tabIndex = -1 - const count = createElement("summary", {}, `_ ${childType}s`) + const count = createElement("summary", {}, t(`table.${childType}Count`, { count: "_" })) setCountProperty(count) dropdown.appendChild(count) @@ -97,11 +101,12 @@ export class TableTools extends HTMLElement { } #createMoreMenuSection(childType) { + const typeName = t(`table.${childType}`) const section = createElement("div", { className: "lexxy-floating-controls__group lexxy-table-control__more-menu-details" }) - const addBeforeButton = this.#createButton(`Add ${childType} before`, { action: "insert", childType, direction: "before" }) - const addAfterButton = this.#createButton(`Add ${childType} after`, { action: "insert", childType, direction: "after" }) - const toggleStyleButton = this.#createButton(`Toggle ${childType} style`, { action: "toggle", childType }) - const deleteButton = this.#createButton(`Remove ${childType}`, { action: "delete", childType }) + const addBeforeButton = this.#createButton(t("table.addBefore", { childType: typeName }), { action: "insert", childType, direction: "before" }) + const addAfterButton = this.#createButton(t("table.addAfter", { childType: typeName }), { action: "insert", childType, direction: "after" }) + const toggleStyleButton = this.#createButton(t("table.toggleStyle", { childType: typeName }), { action: "toggle", childType }) + const deleteButton = this.#createButton(t("table.remove", { childType: typeName }), { action: "delete", childType }) section.appendChild(addBeforeButton) section.appendChild(addAfterButton) @@ -114,7 +119,7 @@ export class TableTools extends HTMLElement { #createDeleteTableButton() { const container = createElement("div", { className: "lexxy-table-control lexxy-floating-controls__group" }) - const deleteTableButton = this.#createButton("Delete this table?", { action: "delete", childType: "table" }) + const deleteTableButton = this.#createButton(t("table.deleteTable"), { action: "delete", childType: "table" }) deleteTableButton.classList.add("lexxy-table-control__button--delete-table") container.appendChild(deleteTableButton) @@ -289,8 +294,8 @@ export class TableTools extends HTMLElement { const rowCount = tableElement.rows const columnCount = tableElement.columns - this.rowCount.textContent = `${rowCount} row${rowCount === 1 ? "" : "s"}` - this.columnCount.textContent = `${columnCount} column${columnCount === 1 ? "" : "s"}` + this.rowCount.textContent = t("table.rowCount", { count: rowCount }) + this.columnCount.textContent = t("table.columnCount", { count: columnCount }) } #setTableCellFocus() { diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 815f24aa..aed034b8 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -6,6 +6,9 @@ import { import { getNonce } from "../helpers/csp_helper" import { handleRollingTabIndex } from "../helpers/accessibility_helper" import ToolbarIcons from "./toolbar_icons" +import Lexxy from "../config/lexxy" + +function t(key, values) { return Lexxy.i18n.t(key, values) } export class LexicalToolbarElement extends HTMLElement { static observedAttributes = [ "connected" ] @@ -357,114 +360,114 @@ export class LexicalToolbarElement extends HTMLElement { } static get defaultTemplate() { - return ` - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - ` - } + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` +} } export default LexicalToolbarElement diff --git a/test/javascript/unit/config/i18n.test.js b/test/javascript/unit/config/i18n.test.js new file mode 100644 index 00000000..959074f1 --- /dev/null +++ b/test/javascript/unit/config/i18n.test.js @@ -0,0 +1,71 @@ +import { expect, test, beforeEach } from "vitest" +import I18n from "src/config/i18n" + +let i18n + +beforeEach(() => { + i18n = new I18n() +}) + +test("looks up a simple key", () => { + expect(i18n.t("toolbar.bold")).toBe("Bold") +}) + +test("returns the key itself when not found", () => { + expect(i18n.t("toolbar.nonexistent")).toBe("toolbar.nonexistent") +}) + +test("interpolates %{variable} placeholders", () => { + expect(i18n.t("table.add", { childType: "row" })).toBe("Add row") +}) + +test("interpolates multiple placeholders", () => { + i18n.registerLocale("test", { msg: "%{a} and %{b}" }) + i18n.locale = "test" + expect(i18n.t("msg", { a: "X", b: "Y" })).toBe("X and Y") +}) + +test("handles pluralization with count", () => { + expect(i18n.t("table.rowCount", { count: 1 })).toBe("1 row") + expect(i18n.t("table.rowCount", { count: 3 })).toBe("3 rows") +}) + +test("handles pluralization with count 0", () => { + expect(i18n.t("table.rowCount", { count: 0 })).toBe("0 rows") +}) + +test("returns key when result is plural object but count not provided", () => { + expect(i18n.t("table.rowCount")).toBe("table.rowCount") +}) + +test("falls back to English for missing locale key", () => { + const i18nAr = new I18n({ ar: { toolbar: { bold: "عريض" } } }, "ar") + expect(i18nAr.t("toolbar.bold")).toBe("عريض") + expect(i18nAr.t("toolbar.italic")).toBe("Italic") +}) + +test("registers a new locale", () => { + i18n.registerLocale("fr", { toolbar: { bold: "Gras" } }) + i18n.locale = "fr" + expect(i18n.t("toolbar.bold")).toBe("Gras") + expect(i18n.t("toolbar.italic")).toBe("Italic") +}) + +test("setting locale changes active language", () => { + i18n.registerLocale("ar", { toolbar: { bold: "عريض" } }) + i18n.locale = "ar" + expect(i18n.t("toolbar.bold")).toBe("عريض") +}) + +test("empty string translation is returned, not treated as missing", () => { + i18n.registerLocale("test", { toolbar: { bold: "" } }) + i18n.locale = "test" + expect(i18n.t("toolbar.bold")).toBe("") +}) + +test("registerLocale overwrites existing locale", () => { + i18n.registerLocale("fr", { toolbar: { bold: "Gras" } }) + i18n.registerLocale("fr", { toolbar: { bold: "Gras v2" } }) + i18n.locale = "fr" + expect(i18n.t("toolbar.bold")).toBe("Gras v2") +})