// The site's languages and words (locales/.json, the framework's catalog format; checked by // `waybetter i18n check --config apps/web/waybetter.i18n.json`, docs/TRANSLATING.md). English is the // source: a page's own text lives in its HTML (i18n-html.ts), everything the build writes (meta tags, // Store cards, item pages) and the scripts say is a `site.*` key looked up here. import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { argumentShapes, direction, formatMessage } from '../../waybetter/packages/runtime/src/i18n.ts'; import { pageMessages } from './i18n-html.ts'; import { LANGUAGES } from './site.config.ts'; const HERE = dirname(fileURLToPath(import.meta.url)); export type SiteCatalog = { locale: string; name: string; englishName: string; dir?: 'ltr' | 'rtl'; status: string; reviewers?: string[]; messages: Record }; export type Translate = (key: string, params?: Record) => string; export type Language = { code: string; name: string; englishName: string; dir: 'ltr' | 'rtl' }; /** The pages whose own text is translated, by the name their keys carry (`site..…`). */ export const PAGE_SOURCES: Record = { home: 'index.html', x: 'x/index.html', store: 'store/index.html', item: 'store/_item.html', submit: 'store/submit/index.html', themes: 'themes/index.html', plugins: 'plugins/index.html', notFound: '404.html', }; /** Keys the build and the scripts look up (not text of a page): written by hand in en.json. */ export const CODE_PREFIXES = ['site.meta.', 'site.gen.', 'site.js.', 'site.lang.']; /** What the pages' scripts say (src/i18n.ts reads them from the page). */ export const CLIENT_PREFIX = 'site.js.'; const catalogs = new Map>(); /** Every site language that has a catalog, English first, in the order of LANGUAGES (site.config.ts). */ export function loadCatalogs(root: string = HERE): Map { let loaded = catalogs.get(root); if (!loaded) { loaded = new Map(); for (const code of LANGUAGES) { const file = join(root, 'locales', `${code}.json`); if (existsSync(file)) loaded.set(code, JSON.parse(readFileSync(file, 'utf8')) as SiteCatalog); } if (!loaded.has('en')) throw new Error('i18n: locales/en.json is missing'); catalogs.set(root, loaded); } return loaded; } /** Forget what was read (the dev server calls it when a catalog changes). */ export const resetCatalogs = (): void => catalogs.clear(); export function languages(root: string = HERE): Language[] { return [...loadCatalogs(root)].map(([code, catalog]) => ({ code, name: catalog.name, englishName: catalog.englishName, dir: direction(code) })); } /** * In a right-to-left sentence a Latin value must keep its own order: ".plugin.js" would show as * "plugin.js." and "wayBetter for X 1.0.0" could be re-ordered around its neighbours. Each such value * is wrapped in a first-strong isolate (U+2068 … U+2069), which is what does, and works in * attributes and titles too. Catalogs never carry these characters themselves (the check refuses * them); only values filled in here do. A value that chooses a plural or select form is left alone. */ const FSI = String.fromCharCode(0x2068); const PDI = String.fromCharCode(0x2069); const chosen = new Map>(); function isolated(message: string, params: Record): Record { let forms = chosen.get(message); if (!forms) chosen.set(message, forms = new Set(Object.keys(argumentShapes(message)))); return Object.fromEntries(Object.entries(params).map(([name, value]) => [name, typeof value === 'string' && /[A-Za-z0-9]/.test(value) && !forms.has(name) && value.charCodeAt(0) !== 0xe000 ? `${FSI}${value}${PDI}` : value])); } /** `t(key, params)` for one language. A key a translation lacks is English; a key English lacks is a build error. */ export function translator(lang: string, root: string = HERE): Translate { const all = loadCatalogs(root); const source = all.get('en')!.messages; const own = all.get(lang)?.messages ?? source; const rtl = direction(lang) === 'rtl'; return (key, params = {}) => { const english = source[key]; if (english === undefined) throw new Error(`i18n: no message "${key}" in locales/en.json`); const message = own[key] ?? english; return formatMessage(message, rtl && own[key] !== undefined ? isolated(message, params) : params, own[key] === undefined ? 'en' : lang, { onError: error => { throw new Error(`i18n: ${lang} ${key}: ${error.message}`); } }); }; } /** The client's words for one language, as the page carries them. */ export function clientMessages(lang: string, root: string = HERE): Record { const all = loadCatalogs(root); const source = all.get('en')!.messages; const own = all.get(lang)?.messages ?? source; return Object.fromEntries(Object.keys(source).filter(key => key.startsWith(CLIENT_PREFIX)).map(key => [key, own[key] ?? source[key]!])); } /** The English messages the pages' HTML carries, with a note for translators each, in page order. */ export function extractPages(root: string = HERE): { messages: Record; notes: Record } { const messages: Record = {}; const notes: Record = {}; const where: Record = {}; for (const [page, file] of Object.entries(PAGE_SOURCES)) { for (const message of pageMessages(readFileSync(join(root, file), 'utf8'))) { if (!message.key.startsWith('site.') || CODE_PREFIXES.some(prefix => message.key.startsWith(prefix))) throw new Error(`i18n: ${file}: "${message.key}" is not a page key (site..… or site.common.…)`); if (messages[message.key] !== undefined && messages[message.key] !== message.source) throw new Error(`i18n: ${file}: "${message.key}" marks two different texts:\n ${messages[message.key]}\n ${message.source}`); messages[message.key] = message.source; (where[message.key] ??= []).includes(page) || where[message.key]!.push(page); const kind = message.attr ? `the ${message.attr} of an element (read by screen readers${message.attr === 'placeholder' ? ', shown in an empty field' : ''})` : ''; const values = message.values.map(name => `{${name}} is ${VALUE_NOTES[name] ?? 'filled in by the build'}`); const parts = [kind, ...message.notes, ...values].filter(Boolean); if (parts.length && !notes[message.key]) notes[message.key] = parts.join('; '); } } for (const [key, pages] of Object.entries(where)) { const place = `${pages.map(page => PAGE_NAMES[page] ?? page).join(', ')}`; notes[key] = notes[key] ? `${place}: ${notes[key]}.` : `${place}.`; } return { messages, notes }; } const PAGE_NAMES: Record = { home: 'Home page', x: 'wayBetter for X page', store: 'Store', item: 'Store item page', submit: 'Publish page', themes: 'Themes gallery', plugins: 'Plugins gallery', notFound: '404 page' }; const VALUE_NOTES: Record = { themes: 'the number of themes', plugins: 'the number of plugins', items: 'the number of Store items', date: 'a date (YYYY-MM-DD)', apps: 'the apps’ names', version: 'a version number', host: 'the forge’s host name (git.crt.fyi)', };