From 7be993bb1d3b18bf8b1ec7135c4cb807608df752 Mon Sep 17 00:00:00 2001 From: Yusuf Khasbulatov Date: Wed, 25 Mar 2026 23:35:32 +0100 Subject: [PATCH] Add locale-aware config and localized framework UI defaults --- docs/config.md | 28 ++++++++++--- src/client/sidebar.js | 3 +- src/config.ts | 52 ++++++++++++++++++------ src/frontMatter.ts | 13 ++++++ src/i18n.ts | 86 ++++++++++++++++++++++++++++++++++++++++ src/pager.ts | 6 ++- src/render.ts | 52 +++++++++++++++++------- src/style/layout.css | 4 +- test/config-test.ts | 51 +++++++++++++++++++++--- test/frontMatter-test.ts | 10 +++++ test/pager-test.ts | 8 ++++ test/render-test.ts | 72 +++++++++++++++++++++++++++++++++ 12 files changed, 341 insertions(+), 44 deletions(-) create mode 100644 src/i18n.ts create mode 100644 test/render-test.ts diff --git a/docs/config.md b/docs/config.md index 1d036f253..efdaa18e7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -106,17 +106,35 @@ In this case, the path to the stylesheet is resolved relative to the page’s Ma The app’s title. If specified, this text is appended to page titles with a separating pipe symbol (“|”). For instance, a page titled “Sales” in an app titled “ACME, Inc.” will display “Sales | ACME, Inc.” in the browser’s title bar. See also the [**home** option](#home). +## locale + +The app’s default locale, such as `en-US`, `fr-FR`, or `ar-EG`. This locale is used for Framework-owned UI defaults such as the footer date, search placeholder, pager labels, and other built-in labels. If [**lang**](#lang) is not specified, it defaults to the locale’s language subtag. + +Page-level YAML front matter may override the locale for an individual page. + +## lang + +The document language, used to set the root HTML `lang` attribute. If not specified, it defaults to the language subtag of [**locale**](#locale), if any. + +Page-level YAML front matter may override the language for an individual page. + +## dir + +The document direction, either `ltr` or `rtl`, used to set the root HTML `dir` attribute. If not specified, Framework derives the direction from the page language when possible. + +Page-level YAML front matter may override the direction for an individual page. + ## sidebar Whether to show the sidebar. Defaults to true if **pages** is not empty. ## home -An HTML fragment to render the link to the home page in the top of the sidebar. Defaults to the [app’s title](#title), if any, and otherwise the word “Home”. If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string. +An HTML fragment to render the link to the home page in the top of the sidebar. Defaults to the [app’s title](#title), if any, and otherwise a localized equivalent of “Home” based on [**locale**](#locale) or [**lang**](#lang). If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string. ## pages -An array containing pages and sections. If not specified, it defaults to all Markdown files found in the source root in directory listing order. +An array containing pages and sections. If not specified, it defaults to all Markdown files found in the source root in directory listing order. Pages without an inferred title use a localized equivalent of “Untitled” based on [**locale**](#locale) or [**lang**](#lang). Both pages and sections have a **name**, which typically corresponds to the page’s title. The name gets displayed in the sidebar. Sections are used to group related pages; each section must specify an array of **pages**. (Sections can only contain pages; nested sections are not currently supported.) @@ -185,7 +203,7 @@ By default, the header is fixed to the top of the window. To instead have the he ## footer -An HTML fragment to add to the footer. Defaults to “Built with Observable.” If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string. +An HTML fragment to add to the footer. By default, Framework renders a localized “Built with Observable on [date].” footer based on [**locale**](#locale) or [**lang**](#lang), and page-level front matter locale overrides are respected. If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string. For example, the following adds a link to the bottom of each page: @@ -218,7 +236,7 @@ export interface TableOfContents { } ``` -If **show** is not set, it defaults to true. If **label** is not set, it defaults to “Contents”. The **toc** option can also be set to a boolean, in which case it is shorthand for **toc.show**. +If **show** is not set, it defaults to true. If **label** is not set, it defaults to a localized equivalent of “Contents” based on [**locale**](#locale) or [**lang**](#lang). The **toc** option can also be set to a boolean, in which case it is shorthand for **toc.show**. If shown, the table of contents enumerates the second-level headings (H2 elements, such as `## Section name`) on the right-hand side of the page. The currently-shown section is highlighted in the table of contents. @@ -232,7 +250,7 @@ toc: false ## search -If true, enable [search](./search); defaults to false. The **search** option may also be specified as an object with an **index** method , in which case additional results can be added to the search index. Each result is specified as: +If true, enable [search](./search); defaults to false. Framework localizes the built-in search placeholder from [**locale**](#locale) or [**lang**](#lang). The **search** option may also be specified as an object with an **index** method , in which case additional results can be added to the search index. Each result is specified as: ```ts run=false interface SearchResult { diff --git a/src/client/sidebar.js b/src/client/sidebar.js index f63e2037e..2b5174e7f 100644 --- a/src/client/sidebar.js +++ b/src/client/sidebar.js @@ -31,7 +31,8 @@ if (toggle) { event.preventDefault(); } }); - const title = `Toggle sidebar ${ + const baseTitle = toggle.dataset.title || "Toggle sidebar"; + const title = `${baseTitle} ${ /Mac|iPhone/.test(navigator.platform) ? /Firefox/.test(navigator.userAgent) ? "⌥" // option symbol for mac firefox diff --git a/src/config.ts b/src/config.ts index 8e53688ce..6492839f9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,7 @@ import {DUCKDB_CORE_ALIASES, DUCKDB_CORE_EXTENSIONS} from "./duckdb.js"; import {visitFiles} from "./files.js"; import {formatIsoDate, formatLocaleDate} from "./format.js"; import type {FrontMatter} from "./frontMatter.js"; +import {getFrameworkLanguage, getFrameworkLocale, getFrameworkMessages} from "./i18n.js"; import {findModule} from "./javascript/module.js"; import {LoaderResolver} from "./loader.js"; import {createMarkdownIt, parseMarkdownMetadata} from "./markdown.js"; @@ -100,6 +101,9 @@ export interface Config { base: string; // defaults to "/" home: string; // defaults to the (escaped) title, or "Home" title?: string; + lang?: string; + dir?: "ltr" | "rtl"; + locale?: string; sidebar: boolean; // defaults to true if pages isn’t empty pages: (Page | Section)[]; pager: boolean; // defaults to true @@ -135,6 +139,9 @@ export interface ConfigSpec { interpreters?: unknown; home?: unknown; title?: unknown; + lang?: unknown; + dir?: unknown; + locale?: unknown; pages?: unknown; pager?: unknown; dynamicPaths?: unknown; @@ -206,7 +213,8 @@ async function resolveDefaultConfig(root?: string): Promise let cachedPages: {key: string; pages: Page[]} | null = null; -function readPages(root: string, md: MarkdownIt): Page[] { +function readPages(root: string, md: MarkdownIt, locale?: string, lang?: string): Page[] { + const messages = getFrameworkMessages(locale, lang); const files: {file: string; source: string}[] = []; const hash = createHash("sha256"); for (const file of visitFiles(root, (name) => !isParameterized(name))) { @@ -225,7 +233,7 @@ function readPages(root: string, md: MarkdownIt): Page[] { if (data.draft) continue; const name = basename(file, ".md"); const {pager = "main"} = data; - const page = {path: join("/", dirname(file), name), name: title ?? "Untitled", pager}; + const page = {path: join("/", dirname(file), name), name: title ?? messages.untitled, pager}; if (name === "index") pages.unshift(page); else pages.push(page); } @@ -269,16 +277,20 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat markdownIt: spec.markdownIt as any }); const title = spec.title === undefined ? undefined : String(spec.title); - const home = spec.home === undefined ? he.escape(title ?? "Home") : String(spec.home); // eslint-disable-line import/no-named-as-default-member + const locale = spec.locale === undefined ? undefined : String(spec.locale); + const lang = spec.lang === undefined ? getFrameworkLanguage(locale) : String(spec.lang); + const dir = spec.dir === undefined ? undefined : normalizeDir(spec.dir); + const messages = getFrameworkMessages(locale, lang); + const home = spec.home === undefined ? he.escape(title ?? messages.home) : String(spec.home); // eslint-disable-line import/no-named-as-default-member const pages = spec.pages === undefined ? undefined : normalizePages(spec.pages); const pager = spec.pager === undefined ? true : Boolean(spec.pager); const dynamicPaths = normalizeDynamicPaths(spec.dynamicPaths); - const toc = normalizeToc(spec.toc as any); + const toc = normalizeToc(spec.toc as any, messages.contents); const sidebar = spec.sidebar === undefined ? undefined : Boolean(spec.sidebar); const scripts = spec.scripts === undefined ? [] : normalizeScripts(spec.scripts); const head = pageFragment(spec.head === undefined ? "" : spec.head); const header = pageFragment(spec.header === undefined ? "" : spec.header); - const footer = pageFragment(spec.footer === undefined ? defaultFooter() : spec.footer); + const footer = pageFragment(spec.footer === undefined ? defaultFooter(locale, lang) : spec.footer); const search = spec.search == null || spec.search === false ? null : normalizeSearch(spec.search as any); const interpreters = normalizeInterpreters(spec.interpreters as any); const normalizePath = getPathNormalizer(spec); @@ -301,6 +313,9 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat base, home, title, + lang, + dir, + locale, sidebar: sidebar!, // see below pages: pages!, // see below pager, @@ -336,7 +351,7 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat watchPath, duckdb }; - if (pages === undefined) Object.defineProperty(config, "pages", {get: () => readPages(root, md)}); + if (pages === undefined) Object.defineProperty(config, "pages", {get: () => readPages(root, md, locale, lang)}); if (sidebar === undefined) Object.defineProperty(config, "sidebar", {get: () => config.pages.length > 0}); configCache.set(spec, config); return config; @@ -378,11 +393,16 @@ function defaultGlobalStylesheets(): string[] { ]; } -function defaultFooter(): string { - const date = currentDate ?? new Date(); - return `Built with Observable on ${formatLocaleDate(date)}.`; +function defaultFooter(projectLocale?: string, projectLang?: string): PageFragmentFunction { + return ({data}) => { + const locale = data.locale ?? projectLocale; + const lang = data.lang ?? getFrameworkLanguage(data.locale) ?? projectLang ?? getFrameworkLanguage(projectLocale); + const date = currentDate ?? new Date(); + const messages = getFrameworkMessages(locale, lang); + return `${messages.footerPrefix} Observable ${ + messages.footerDatePreposition + } ${formatLocaleDate(date, getFrameworkLocale(locale, lang))}.`; + }; } function findDefaultRoot(defaultRoot?: string): string { @@ -416,6 +436,12 @@ function normalizeBase(spec: unknown): string { return base; } +function normalizeDir(spec: unknown): "ltr" | "rtl" { + const dir = String(spec); + if (dir !== "ltr" && dir !== "rtl") throw new Error(`invalid dir: ${dir}`); + return dir; +} + function normalizeGlobalStylesheets(spec: unknown): string[] { return normalizeArray(spec, String); } @@ -489,9 +515,9 @@ function normalizeInterpreters(spec: {[key: string]: unknown} = {}): {[key: stri ); } -function normalizeToc(spec: TableOfContentsSpec | boolean = true): TableOfContents { +function normalizeToc(spec: TableOfContentsSpec | boolean = true, defaultLabel = "Contents"): TableOfContents { const toc = typeof spec === "boolean" ? {show: spec} : (spec as TableOfContentsSpec); - const label = toc.label === undefined ? "Contents" : String(toc.label); + const label = toc.label === undefined ? defaultLabel : String(toc.label); const show = toc.show === undefined ? true : Boolean(toc.show); return {label, show}; } diff --git a/src/frontMatter.ts b/src/frontMatter.ts index 63300ce82..8c23af314 100644 --- a/src/frontMatter.ts +++ b/src/frontMatter.ts @@ -4,6 +4,9 @@ import {yellow} from "./tty.js"; export interface FrontMatter { title?: string | null; + lang?: string | null; + dir?: "ltr" | "rtl" | null; + locale?: string | null; toc?: {show?: boolean; label?: string}; style?: string | null; theme?: string[]; @@ -36,6 +39,9 @@ export function normalizeFrontMatter(spec: any = {}): FrontMatter { if (spec == null || typeof spec !== "object") return frontMatter; const {title, sidebar, toc, index, keywords, draft, sql, head, header, footer, pager, style, theme} = spec; if (title !== undefined) frontMatter.title = stringOrNull(title); + if (spec.lang !== undefined) frontMatter.lang = stringOrNull(spec.lang); + if (spec.dir !== undefined) frontMatter.dir = normalizeDir(spec.dir); + if (spec.locale !== undefined) frontMatter.locale = stringOrNull(spec.locale); if (sidebar !== undefined) frontMatter.sidebar = Boolean(sidebar); if (toc !== undefined) frontMatter.toc = normalizeToc(toc); if (index !== undefined) frontMatter.index = Boolean(index); @@ -71,3 +77,10 @@ function normalizeSql(spec: unknown): {[key: string]: string} { for (const key in spec) sql[key] = String(spec[key]); return sql; } + +function normalizeDir(spec: unknown): "ltr" | "rtl" | null { + if (spec == null || spec === false) return null; + const dir = String(spec); + if (dir !== "ltr" && dir !== "rtl") throw new Error(`invalid front matter dir: ${dir}`); + return dir; +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 000000000..cc056fcdc --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,86 @@ +const rtlLanguages = new Set([ + "ar", + "fa", + "he", + "ks", + "ku", + "ps", + "sd", + "ug", + "ur", + "yi" +]); + +export interface FrameworkMessages { + home: string; + contents: string; + untitled: string; + search: string; + toggleSidebar: string; + previousPage: string; + nextPage: string; + footerPrefix: string; + footerDatePreposition: string; +} + +const frameworkMessages: Record = { + en: { + home: "Home", + contents: "Contents", + untitled: "Untitled", + search: "Search", + toggleSidebar: "Toggle sidebar", + previousPage: "Previous page", + nextPage: "Next page", + footerPrefix: "Built with", + footerDatePreposition: "on" + }, + fr: { + home: "Accueil", + contents: "Sommaire", + untitled: "Sans titre", + search: "Rechercher", + toggleSidebar: "Basculer la barre latérale", + previousPage: "Page précédente", + nextPage: "Page suivante", + footerPrefix: "Créé avec", + footerDatePreposition: "le" + }, + ar: { + home: "الرئيسية", + contents: "المحتويات", + untitled: "بدون عنوان", + search: "بحث", + toggleSidebar: "تبديل الشريط الجانبي", + previousPage: "الصفحة السابقة", + nextPage: "الصفحة التالية", + footerPrefix: "أُنشئ باستخدام", + footerDatePreposition: "في" + } +}; + +export function getFrameworkLanguage(locale?: string | null, lang?: string | null): string | undefined { + return languageSubtag(lang) ?? languageSubtag(locale); +} + +export function getFrameworkDirection( + locale?: string | null, + lang?: string | null +): "ltr" | "rtl" | undefined { + const language = getFrameworkLanguage(locale, lang); + return language ? (rtlLanguages.has(language) ? "rtl" : "ltr") : undefined; +} + +export function getFrameworkLocale(locale?: string | null, lang?: string | null): string { + return locale ?? lang ?? "en-US"; +} + +export function getFrameworkMessages(locale?: string | null, lang?: string | null): FrameworkMessages { + const language = getFrameworkLanguage(locale, lang); + return (language && frameworkMessages[language]) || frameworkMessages.en; +} + +function languageSubtag(tag?: string | null): string | undefined { + const match = tag?.trim().match(/^([A-Za-z]{2,3})(?:[-_]|$)/); + return match?.[1].toLowerCase(); +} diff --git a/src/pager.ts b/src/pager.ts index aa3f6f792..02a5f62c5 100644 --- a/src/pager.ts +++ b/src/pager.ts @@ -1,4 +1,5 @@ import type {Config, Page} from "./config.js"; +import {getFrameworkMessages} from "./i18n.js"; export type PageLink = | {prev: undefined; next: Page} // first page @@ -45,7 +46,8 @@ export function findLink(path: string, config: Config): PageLink | undefined { * adds a link at the beginning to the home page (/index). */ function walk(config: Config): Iterable> { - const {pages, loaders, title = "Home"} = config; + const {pages, loaders, title} = config; + const {home} = getFrameworkMessages(config.locale, config.lang); const pageGroups = new Map(); const visited = new Set(); @@ -57,7 +59,7 @@ function walk(config: Config): Iterable> { pageGroup.push(page); } - if (loaders.findPage("/index")) visit({name: title, path: "/index", pager: "main"}); + if (loaders.findPage("/index")) visit({name: title ?? home, path: "/index", pager: "main"}); for (const page of pages) { if (page.path !== null) visit(page as Page); diff --git a/src/render.ts b/src/render.ts index 309d4b1ef..ea7533e3f 100644 --- a/src/render.ts +++ b/src/render.ts @@ -17,6 +17,7 @@ import {isAssetPath, resolvePath, resolveRelativePath} from "./path.js"; import type {Resolvers} from "./resolvers.js"; import {getModuleResolver, getModuleStaticImports, getResolvers} from "./resolvers.js"; import {rollupClient} from "./rollup.js"; +import {getFrameworkDirection, getFrameworkLanguage, getFrameworkMessages} from "./i18n.js"; export interface RenderOptions extends Config { root: string; @@ -33,10 +34,18 @@ export async function renderPage(page: MarkdownPage, options: RenderOptions & Re const {base, path, title, preview} = options; const {loaders, resolvers = await getResolvers(page, options)} = options; const {draft = false, sidebar = options.sidebar} = data; + const locale = data.locale ?? options.locale; + const lang = data.lang ?? getFrameworkLanguage(data.locale) ?? options.lang ?? getFrameworkLanguage(options.locale); + const dir = + data.dir ?? + (data.lang !== undefined || data.locale !== undefined ? getFrameworkDirection(data.locale, lang) : undefined) ?? + options.dir ?? + getFrameworkDirection(options.locale, options.lang); + const messages = getFrameworkMessages(locale, lang); const toc = mergeToc(data.toc, options.toc); const {files, resolveFile, resolveImport} = resolvers; return String(html` - + ${path === "/404" ? html`\n` : ""} @@ -85,12 +94,12 @@ ${preview ? `\nopen({hash: ${JSON.stringify(resolvers.hash)}, eval: (body) => ev .join("")}`)} -${sidebar ? html`\n${await renderSidebar(options, resolvers)}` : ""} +${sidebar ? html`\n${await renderSidebar(options, resolvers, messages)}` : ""}
${renderHeader(page.header, resolvers)}${ toc.show ? html`\n${renderToc(findHeaders(page), toc.label)}` : "" }
-${html.unsafe(rewriteHtml(page.body, resolvers))}
${renderFooter(page.footer, resolvers, options)} +${html.unsafe(rewriteHtml(page.body, resolvers))}${renderFooter(page.footer, resolvers, options, messages)}
@@ -137,19 +146,23 @@ function registerFile( })});`; } -async function renderSidebar(options: RenderOptions, {resolveImport, resolveLink}: Resolvers): Promise { +async function renderSidebar( + options: RenderOptions, + {resolveImport, resolveLink}: Resolvers, + messages: ReturnType +): Promise { const {home, pages, root, path, search} = options; - return html` - + return html` +