wayBetter for X
This commit is contained in:
commit
df5dff619e
720 changed files with 147465 additions and 0 deletions
115
apps/web/i18n.ts
Normal file
115
apps/web/i18n.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// 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)',
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue