eb9137b300
- English stays at /, Spanish at /es/ and Arabic at /ar/ (right to left) - EN · ES · AR switch in the header and mobile menu - Pages are written in English; the build translates them through src/i18n/es.json and src/i18n/ar.json, including browser-script strings - rtl.css mirrors the layout and loads Arabic fonts on /ar/ pages - hreflang alternates on every page and a multilingual sitemap - scripts/i18n-extract.mjs lists strings that still need translating Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
4.7 KiB
JavaScript
99 lines
4.7 KiB
JavaScript
// Languages. English is served from the root (/about/); Spanish and Arabic carry a prefix (/es/about/).
|
|
// Pages are written in English; each translated page is the English HTML with its text swapped
|
|
// through the dictionaries in src/i18n/<locale>.json (English text → translation).
|
|
|
|
export const LOCALES = ['en', 'es', 'ar'];
|
|
export const DEFAULT_LOCALE = 'en';
|
|
export const LOCALE_NAMES = { en: 'English', es: 'Español', ar: 'العربية' };
|
|
export const LOCALE_SHORT = { en: 'EN', es: 'ES', ar: 'AR' };
|
|
export const OG_LOCALE = { en: 'en_US', es: 'es_ES', ar: 'ar_AE' };
|
|
export const dirOf = (locale) => (locale === 'ar' ? 'rtl' : 'ltr');
|
|
|
|
/** Prefix an internal path with the locale. */
|
|
export const localize = (path, locale) => (locale === DEFAULT_LOCALE ? path : `/${locale}${path}`);
|
|
|
|
// Strings set by the browser scripts. Each page gets the ones for its language in window.__i18n.
|
|
export const JS_STRINGS = [
|
|
'Switch to light mode', 'Switch to dark mode', 'Open menu', 'Close menu', 'Copied',
|
|
'Your email app should open with the message ready to send.',
|
|
'AI Software', 'Marketing & AI Search', 'Spirits', 'Education Technology', 'Cybersecurity', 'E-Commerce',
|
|
'MARKETING & AI SEARCH', 'AI SOFTWARE', 'SPIRITS', 'EDUCATION TECHNOLOGY', 'CYBERSECURITY', 'E-COMMERCE',
|
|
'Leading teams through a constantly changing search landscape.',
|
|
'Building around new opportunities created by AI.',
|
|
'Taking a consumer brand from idea to market.',
|
|
'Creating technology designed for real classrooms.',
|
|
'Solving security challenges for growing businesses.',
|
|
'Turning product ideas into businesses built around real customers.',
|
|
];
|
|
|
|
// Tags that stay inside a translatable segment; any other tag ends it.
|
|
const INLINE = new Set(['b', 'strong', 'i', 'em', 'u', 'br', 'sup', 'sub', 'mark', 'abbr', 'wbr']);
|
|
const ATTRS = ['alt', 'aria-label', 'title', 'placeholder'];
|
|
const META = new Set(['description', 'og:title', 'og:description', 'twitter:title', 'twitter:description']);
|
|
const TOKEN = /<!--[\s\S]*?-->|<(script|style|svg)\b[\s\S]*?<\/\1>|<[^>]+>|[^<]+/gi;
|
|
|
|
export const norm = (s) => s.replace(/\s+/g, ' ').trim();
|
|
const hasWords = (s) => /\p{L}/u.test(s.replace(/<[^>]*>|&[a-z#0-9]+;/gi, ''));
|
|
const tagName = (t) => (t.match(/^<\/?([a-z0-9-]+)/i) || [])[1]?.toLowerCase();
|
|
|
|
/**
|
|
* Walk the HTML and call `fn(text)` for every translatable string: runs of text with inline
|
|
* markup, translatable attributes and meta descriptions. `fn` returns the replacement.
|
|
*/
|
|
export function mapStrings(html, fn) {
|
|
const out = [];
|
|
let seg = [];
|
|
const flush = () => {
|
|
if (!seg.length) return;
|
|
const raw = seg.join('');
|
|
seg = [];
|
|
if (!hasWords(raw)) return out.push(raw);
|
|
const lead = raw.match(/^\s*/)[0], trail = raw.slice(lead.length).match(/\s*$/)[0];
|
|
out.push(lead + fn(norm(raw)) + trail);
|
|
};
|
|
for (const [tok, block] of html.matchAll(TOKEN)) {
|
|
if (block || tok.startsWith('<!')) { flush(); out.push(tok); continue; }
|
|
if (tok[0] !== '<') { seg.push(tok); continue; }
|
|
const name = tagName(tok);
|
|
if (INLINE.has(name) && !/\b(class|id|data-)/.test(tok)) { seg.push(tok); continue; }
|
|
flush();
|
|
out.push(mapAttrs(tok, name, fn));
|
|
}
|
|
flush();
|
|
return out.join('');
|
|
}
|
|
|
|
function mapAttrs(tag, name, fn) {
|
|
if (/\slang="/.test(tag)) return tag; // the language switch names each language in its own script
|
|
let t = tag.replace(new RegExp(`\\s(${ATTRS.join('|')})="([^"]*)"`, 'g'), (m, a, v) =>
|
|
hasWords(v) ? ` ${a}="${fn(norm(v))}"` : m);
|
|
if (name === 'meta') {
|
|
const key = (t.match(/\s(?:name|property)="([^"]+)"/) || [])[1];
|
|
if (META.has(key)) t = t.replace(/\scontent="([^"]*)"/, (m, v) => ` content="${fn(norm(v))}"`);
|
|
}
|
|
return t;
|
|
}
|
|
|
|
/** Point internal page links at the locale's copy. Links marked with hreflang are left alone. */
|
|
export function localizeLinks(html, locale) {
|
|
if (locale === DEFAULT_LOCALE) return html;
|
|
return html.replace(/<a\b[^>]*>/g, (tag) =>
|
|
/\shreflang=/.test(tag) ? tag : tag.replace(/\shref="(\/(?![^"#?]*\.[a-z0-9]+(?:[#?"]))[^"]*)"/i, (m, p) => ` href="${localize(p, locale)}"`));
|
|
}
|
|
|
|
/** Translate a rendered English page. Returns the HTML and the strings with no translation. */
|
|
export function translatePage(html, locale, dict) {
|
|
const missing = new Set();
|
|
const tr = (s) => {
|
|
if (Object.hasOwn(dict, s)) return dict[s];
|
|
missing.add(s);
|
|
return s;
|
|
};
|
|
const jsDict = Object.fromEntries(JS_STRINGS.filter((s) => Object.hasOwn(dict, s)).map((s) => [s, dict[s]]));
|
|
// Leave <head>'s inline scripts and JSON-LD alone; translate the rest.
|
|
let out = mapStrings(html, tr);
|
|
out = localizeLinks(out, locale);
|
|
out = out.replace('<script>window.__i18n={}</script>', `<script>window.__i18n=${JSON.stringify(jsDict).replace(/</g, '\\u003c')}</script>`);
|
|
return { html: out, missing: [...missing] };
|
|
}
|