Files
dylanmazzei-website/src/i18n.mjs
T
Alejandro Veras eb9137b300 Add Spanish and Arabic versions with a language switch
- 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>
2026-10-15 16:18:11 -04:00

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] };
}