waybetter-for-x/apps/web/i18n.ts
2026-10-01 06:01:13 +07:00

115 lines
7.2 KiB
TypeScript

// The site's languages and words (locales/<lang>.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<string, string> };
export type Translate = (key: string, params?: Record<string, unknown>) => 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.<name>.…`). */
export const PAGE_SOURCES: Record<string, string> = {
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<string, Map<string, SiteCatalog>>();
/** Every site language that has a catalog, English first, in the order of LANGUAGES (site.config.ts). */
export function loadCatalogs(root: string = HERE): Map<string, SiteCatalog> {
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 <bdi> 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<string, Set<string>>();
function isolated(message: string, params: Record<string, unknown>): Record<string, unknown> {
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<string, string> {
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<string, string>; notes: Record<string, string> } {
const messages: Record<string, string> = {};
const notes: Record<string, string> = {};
const where: Record<string, string[]> = {};
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.<page>.… 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<string, string> = { 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<string, string> = {
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)',
};