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>
This commit is contained in:
Alejandro Veras
2026-10-15 16:18:11 -04:00
parent 4ba5f05f75
commit eb9137b300
11 changed files with 2919 additions and 25 deletions
+16
View File
@@ -29,6 +29,22 @@ build.mjs Genera dist/ + sitemap.xml + robots.txt + 404.html
| `/contact/book/` | `src/pages/book.mjs` | | `/contact/book/` | `src/pages/book.mjs` |
| `/press/` | `src/pages/press.mjs` | | `/press/` | `src/pages/press.mjs` |
## Idiomas
El sitio sale en inglés (`/`), español (`/es/`) y árabe (`/ar/`, de derecha a izquierda), con selector EN · ES · AR en la barra de navegación, `hreflang` en cada página y un `sitemap.xml` multilingüe.
Las páginas se escriben en inglés. Al construir, `build.mjs` genera las versiones traducidas sustituyendo cada texto por su traducción de `src/i18n/es.json` y `src/i18n/ar.json` (clave = texto en inglés). Los textos que ponen los scripts del navegador están en `JS_STRINGS` (`src/i18n.mjs`). Los ajustes de diseño para árabe están en `static/assets/rtl.css`.
Si cambias un texto en inglés, su traducción deja de coincidir y esa frase se ve en inglés en `/es/` y `/ar/`. El build lo avisa; para listar lo que falta:
```bash
node scripts/i18n-extract.mjs # cuántos textos faltan por idioma
node scripts/i18n-extract.mjs es falta.json # los textos en español que faltan, para traducir
npm run build -- --strict # falla si falta alguna traducción
```
El formulario y el calendario de GoHighLevel se muestran tal como están configurados en GoHighLevel (en inglés).
## Desarrollo ## Desarrollo
```bash ```bash
+25 -7
View File
@@ -3,7 +3,8 @@ import { mkdir, rm, writeFile, cp, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { page, eyebrow, btn } from './src/layout.mjs'; import { page, eyebrow, btn, SITE } from './src/layout.mjs';
import { LOCALES, DEFAULT_LOCALE, localize, translatePage } from './src/i18n.mjs';
const root = dirname(fileURLToPath(import.meta.url)); const root = dirname(fileURLToPath(import.meta.url));
const out = join(root, 'dist'); const out = join(root, 'dist');
@@ -15,18 +16,35 @@ await cp(join(root, 'static'), out, { recursive: true });
// Fingerprint CSS/JS so a new deploy never reuses a stale cached copy. // Fingerprint CSS/JS so a new deploy never reuses a stale cached copy.
const hashOf = async (f) => createHash('sha256').update(await readFile(join(root, 'static', f))).digest('hex').slice(0, 10); const hashOf = async (f) => createHash('sha256').update(await readFile(join(root, 'static', f))).digest('hex').slice(0, 10);
const assetVersions = Object.fromEntries(await Promise.all(['styles.css', 'site.js', 'home.css', 'home.js', 'fonts.css', 'book.js'].map(async (f) => ['/assets/' + f, await hashOf('assets/' + f)]))); const assetVersions = Object.fromEntries(await Promise.all(['styles.css', 'site.js', 'home.css', 'home.js', 'fonts.css', 'book.js', 'rtl.css'].map(async (f) => ['/assets/' + f, await hashOf('assets/' + f)])));
const fingerprint = (html) => Object.entries(assetVersions).reduce((h, [u, v]) => h.replaceAll(`"${u}"`, `"${u}?v=${v}"`), html); const fingerprint = (html) => Object.entries(assetVersions).reduce((h, [u, v]) => h.replaceAll(`"${u}"`, `"${u}?v=${v}"`), html);
// Spanish and Arabic pages are the English pages with their text swapped through src/i18n/<locale>.json.
const dicts = Object.fromEntries(await Promise.all(LOCALES.filter((l) => l !== DEFAULT_LOCALE).map(async (l) => [l, JSON.parse(await readFile(join(root, 'src/i18n', l + '.json'), 'utf8'))])));
const missing = {};
const urls = []; const urls = [];
for (const name of pages) { for (const name of pages) {
const def = (await import(`./src/pages/${name}.mjs`)).default; const def = (await import(`./src/pages/${name}.mjs`)).default;
const file = join(out, def.path, 'index.html'); for (const locale of LOCALES) {
await mkdir(dirname(file), { recursive: true }); let html = page(def, locale);
await writeFile(file, fingerprint(page(def))); if (locale !== DEFAULT_LOCALE) {
const t = translatePage(html, locale, dicts[locale]);
html = t.html;
for (const s of t.missing) (missing[locale] ??= new Set()).add(s);
}
const file = join(out, localize(def.path, locale), 'index.html');
await mkdir(dirname(file), { recursive: true });
await writeFile(file, fingerprint(html));
}
urls.push(def.path); urls.push(def.path);
console.log('built', def.path); console.log('built', def.path);
} }
for (const [l, set] of Object.entries(missing)) {
console.warn(`\n${l}: ${set.size} strings without translation (shown in English):`);
for (const s of set) console.warn(' ' + JSON.stringify(s));
}
if (process.argv.includes('--strict') && Object.keys(missing).length) process.exit(1);
await writeFile(join(out, '404.html'), fingerprint(page({ await writeFile(join(out, '404.html'), fingerprint(page({
path: '/404/', path: '/404/',
@@ -37,8 +55,8 @@ await writeFile(join(out, '404.html'), fingerprint(page({
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
await writeFile(join(out, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?> await writeFile(join(out, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
${urls.map((u) => ` <url><loc>https://dylanmazzei.com${u}</loc><lastmod>${today}</lastmod></url>`).join('\n')} ${urls.flatMap((u) => LOCALES.map((l) => ` <url><loc>${SITE.url}${localize(u, l)}</loc><lastmod>${today}</lastmod>${LOCALES.map((a) => `<xhtml:link rel="alternate" hreflang="${a}" href="${SITE.url}${localize(u, a)}"/>`).join('')}</url>`)).join('\n')}
</urlset> </urlset>
`); `);
await writeFile(join(out, 'robots.txt'), 'User-agent: *\nAllow: /\nSitemap: https://dylanmazzei.com/sitemap.xml\n'); await writeFile(join(out, 'robots.txt'), 'User-agent: *\nAllow: /\nSitemap: https://dylanmazzei.com/sitemap.xml\n');
+23
View File
@@ -0,0 +1,23 @@
// Lists every English string the translated pages need, and which ones a dictionary still lacks.
// node scripts/i18n-extract.mjs → counts per language
// node scripts/i18n-extract.mjs es out.json → writes the missing Spanish strings as {english: ""}
import { readFile, writeFile } from 'node:fs/promises';
import { page } from '../src/layout.mjs';
import { JS_STRINGS, mapStrings } from '../src/i18n.mjs';
const pages = ['home', 'about', 'portfolio', 'consulting', 'marketing', 'business-setup', 'business-optimization', 'supply-chain', 'cybersecurity', 'education', 'fractional', 'contact', 'book', 'press'];
const strings = new Set();
for (const name of pages) {
const def = (await import(`../src/pages/${name}.mjs`)).default;
mapStrings(page(def, 'es'), (s) => (strings.add(s), s));
}
JS_STRINGS.forEach((s) => strings.add(s));
const [locale, outFile] = process.argv.slice(2);
for (const l of locale ? [locale] : ['es', 'ar']) {
const dict = JSON.parse(await readFile(new URL(`../src/i18n/${l}.json`, import.meta.url), 'utf8'));
const todo = [...strings].filter((s) => !Object.hasOwn(dict, s));
const unused = Object.keys(dict).filter((k) => !strings.has(k));
console.log(`${l}: ${strings.size} strings, ${todo.length} missing, ${unused.length} unused`);
if (outFile) await writeFile(outFile, JSON.stringify(Object.fromEntries(todo.map((s) => [s, ''])), null, 1) + '\n');
}
+98
View File
@@ -0,0 +1,98 @@
// 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] };
}
+1316
View File
File diff suppressed because it is too large Load Diff
+1316
View File
File diff suppressed because it is too large Load Diff
+27 -9
View File
@@ -1,4 +1,5 @@
// Shared layout, icons and small HTML helpers for every page. // Shared layout, icons and small HTML helpers for every page.
import { LOCALES, LOCALE_NAMES, LOCALE_SHORT, OG_LOCALE, dirOf, localize } from './i18n.mjs';
export const SITE = { export const SITE = {
url: 'https://dylanmazzei.com', url: 'https://dylanmazzei.com',
@@ -61,7 +62,9 @@ const SMALL = new Set(['arrow', 'arrow-ur', 'check', 'plus', 'chev', 'brace']);
export function icon(name, cls = '') { export function icon(name, cls = '') {
const vb = SMALL.has(name) ? '0 0 16 16' : name === 'menu' || name === 'close' ? '0 0 18 18' : '0 0 24 24'; const vb = SMALL.has(name) ? '0 0 16 16' : name === 'menu' || name === 'close' ? '0 0 18 18' : '0 0 24 24';
const sw = SMALL.has(name) || name === 'menu' || name === 'close' ? '' : ' stroke-width="1.75"'; const sw = SMALL.has(name) || name === 'menu' || name === 'close' ? '' : ' stroke-width="1.75"';
return `<svg class="${cls}" viewBox="${vb}" fill="none" stroke="currentColor"${sw} stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`; // Arrows point the other way on right-to-left pages (rtl.css).
const c = name.startsWith('arrow') ? (cls ? cls + ' flip' : 'flip') : cls;
return `<svg class="${c}" viewBox="${vb}" fill="none" stroke="currentColor"${sw} stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`;
} }
// Dylan Mazzei monogram: italic "D" (inherits text color) and the sky-blue blade. // Dylan Mazzei monogram: italic "D" (inherits text color) and the sky-blue blade.
@@ -111,7 +114,16 @@ const NAV = [
{ label: 'Press', href: '/press/' }, { label: 'Press', href: '/press/' },
]; ];
function nav(path) { // EN · ES · AR. Plain links: each language is its own static page.
function langSwitch(path, locale) {
return `<nav class="lang-switch" aria-label="Language">${LOCALES.map((l) =>
l === locale
? `<span aria-current="true" lang="${l}" title="${LOCALE_NAMES[l]}">${LOCALE_SHORT[l]}</span>`
: `<a href="${localize(path, l)}" hreflang="${l}" lang="${l}" title="${LOCALE_NAMES[l]}">${LOCALE_SHORT[l]}</a>`
).join('')}</nav>`;
}
function nav(path, locale) {
const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href)); const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href));
const desktop = NAV.map((item) => { const desktop = NAV.map((item) => {
if (!item.children) { if (!item.children) {
@@ -136,6 +148,7 @@ function nav(path) {
<a class="brand" href="/" aria-label="Dylan Mazzei, home">${logo()}</a> <a class="brand" href="/" aria-label="Dylan Mazzei, home">${logo()}</a>
<nav aria-label="Primary"><ul class="nav-links">${desktop}</ul></nav> <nav aria-label="Primary"><ul class="nav-links">${desktop}</ul></nav>
<div class="nav-cta"> <div class="nav-cta">
${langSwitch(path, locale)}
<button class="theme-btn" id="theme-btn" type="button" aria-label="Switch to dark mode">${icon('moon', 'moon')}${icon('sun', 'sun')}</button> <button class="theme-btn" id="theme-btn" type="button" aria-label="Switch to dark mode">${icon('moon', 'moon')}${icon('sun', 'sun')}</button>
<a class="btn btn-primary btn-sm" href="/contact/">START A CONVERSATION<span class="go">${icon('arrow')}</span></a> <a class="btn btn-primary btn-sm" href="/contact/">START A CONVERSATION<span class="go">${icon('arrow')}</span></a>
<button class="menu-btn" id="menu-btn" type="button" aria-expanded="false" aria-controls="mobile-menu" aria-label="Open menu">${icon('menu')}</button> <button class="menu-btn" id="menu-btn" type="button" aria-expanded="false" aria-controls="mobile-menu" aria-label="Open menu">${icon('menu')}</button>
@@ -143,6 +156,7 @@ function nav(path) {
</div> </div>
<div class="wrap mobile-menu" id="mobile-menu" hidden> <div class="wrap mobile-menu" id="mobile-menu" hidden>
<ul>${mobile}</ul> <ul>${mobile}</ul>
${langSwitch(path, locale)}
<a class="btn btn-primary" href="/contact/">START A CONVERSATION<span class="go">${icon('arrow')}</span></a> <a class="btn btn-primary" href="/contact/">START A CONVERSATION<span class="go">${icon('arrow')}</span></a>
</div> </div>
</header>`; </header>`;
@@ -193,15 +207,17 @@ function footer() {
<span class="eyebrow">WHERE I WORK</span> <span class="eyebrow">WHERE I WORK</span>
<p class="places">Texas<i>•</i>Florida<i>•</i>New York<i>•</i>Dubai</p> <p class="places">Texas<i>•</i>Florida<i>•</i>New York<i>•</i>Dubai</p>
</div> </div>
<small>© ${new Date().getFullYear()} Dylan Mazzei</small> <small>© <span>${new Date().getFullYear()}</span> Dylan Mazzei</small>
</div> </div>
</div> </div>
<div class="foot-word" aria-hidden="true">DYLAN MAZZEI</div> <div class="foot-word" aria-hidden="true">DYLAN MAZZEI</div>
</footer>`; </footer>`;
} }
export function page({ path, title, description, body, schema = [], head = '', scripts = '', noindex = false }) { export function page({ path, title, description, body, schema = [], head = '', scripts = '', noindex = false }, locale = 'en') {
const canonical = SITE.url + path; const canonical = SITE.url + localize(path, locale);
const alternates = noindex ? '' : [...LOCALES.map((l) => [l, l]), ['x-default', 'en']]
.map(([hl, l]) => `\n<link rel="alternate" hreflang="${hl}" href="${SITE.url + localize(path, l)}">`).join('');
const person = { const person = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Person', '@type': 'Person',
@@ -215,17 +231,18 @@ export function page({ path, title, description, body, schema = [], head = '', s
}; };
const ld = [person, ...schema].map((s) => `<script type="application/ld+json">${JSON.stringify(s)}</script>`).join('\n'); const ld = [person, ...schema].map((s) => `<script type="application/ld+json">${JSON.stringify(s)}</script>`).join('\n');
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="${locale}" dir="${dirOf(locale)}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>${title}</title> <title>${title}</title>
<meta name="description" content="${description}"> <meta name="description" content="${description}">
<link rel="canonical" href="${canonical}">${noindex ? '\n<meta name="robots" content="noindex">' : ''} <link rel="canonical" href="${canonical}">${alternates}${noindex ? '\n<meta name="robots" content="noindex">' : ''}
<meta name="theme-color" content="#04060c"> <meta name="theme-color" content="#04060c">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:site_name" content="Dylan Mazzei"> <meta property="og:site_name" content="Dylan Mazzei">
<meta property="og:locale" content="${OG_LOCALE[locale]}">
<meta property="og:title" content="${title}"> <meta property="og:title" content="${title}">
<meta property="og:description" content="${description}"> <meta property="og:description" content="${description}">
<meta property="og:url" content="${canonical}"> <meta property="og:url" content="${canonical}">
@@ -240,15 +257,16 @@ export function page({ path, title, description, body, schema = [], head = '', s
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500&family=Unbounded:wght@700&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500&family=Unbounded:wght@700&display=swap">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/assets/fonts/inter-tight-latin-500-normal.woff2"> <link rel="preload" as="font" type="font/woff2" crossorigin href="/assets/fonts/inter-tight-latin-500-normal.woff2">
<link rel="stylesheet" href="/assets/fonts.css"> <link rel="stylesheet" href="/assets/fonts.css">
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/styles.css">${locale === 'ar' ? '\n<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Kufi+Arabic:wght@600;700&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap">\n<link rel="stylesheet" href="/assets/rtl.css">' : ''}
${head} ${head}
<script>(function(){var r=document.documentElement;try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')r.setAttribute('data-theme',t)}catch(e){}if(!r.hasAttribute('data-theme'))r.setAttribute('data-theme','dark')})();document.documentElement.classList.add('js');setTimeout(function(){if(!window.__site)document.documentElement.classList.remove('js')},2500)</script> <script>(function(){var r=document.documentElement;try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')r.setAttribute('data-theme',t)}catch(e){}if(!r.hasAttribute('data-theme'))r.setAttribute('data-theme','dark')})();document.documentElement.classList.add('js');setTimeout(function(){if(!window.__site)document.documentElement.classList.remove('js')},2500)</script>
<script>window.__i18n={}</script>
${ld} ${ld}
</head> </head>
<body> <body>
<div class="bgfx" aria-hidden="true"></div> <div class="bgfx" aria-hidden="true"></div>
<div class="progress" id="progress" aria-hidden="true"></div> <div class="progress" id="progress" aria-hidden="true"></div>
${nav(path)} ${nav(path, locale)}
<main id="main"> <main id="main">
${body} ${body}
</main> </main>
+8 -5
View File
@@ -8,6 +8,7 @@
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t; const lerp = (a, b, t) => a + (b - a) * t;
const T = (s) => (window.__i18n && window.__i18n[s]) || s;
/* ---------- hero icons: one per venture ---------- */ /* ---------- hero icons: one per venture ---------- */
const svg = (p) => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + p + '</svg>'; const svg = (p) => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + p + '</svg>';
@@ -19,7 +20,7 @@
{ n: 'Cybersecurity', side: 'r', x: '15%', y: '50%', r: '7deg', d: '-3s', p: '<path d="M12 3 4.5 6v5.5c0 4.5 3.2 7.8 7.5 9.5 4.3-1.7 7.5-5 7.5-9.5V6z"/><rect x="9" y="10.8" width="6" height="5" rx="1"/><path d="M10.2 10.8V9.4a1.8 1.8 0 0 1 3.6 0v1.4"/>' }, { n: 'Cybersecurity', side: 'r', x: '15%', y: '50%', r: '7deg', d: '-3s', p: '<path d="M12 3 4.5 6v5.5c0 4.5 3.2 7.8 7.5 9.5 4.3-1.7 7.5-5 7.5-9.5V6z"/><rect x="9" y="10.8" width="6" height="5" rx="1"/><path d="M10.2 10.8V9.4a1.8 1.8 0 0 1 3.6 0v1.4"/>' },
{ n: 'E-Commerce', side: 'r', x: '6%', y: '73%', r: '-8deg', d: '-5s', p: '<path d="M5 8h14l-1 12H6z"/><path d="M9 8V7a3 3 0 0 1 6 0v1"/>' }, { n: 'E-Commerce', side: 'r', x: '6%', y: '73%', r: '-8deg', d: '-5s', p: '<path d="M5 8h14l-1 12H6z"/><path d="M9 8V7a3 3 0 0 1 6 0v1"/>' },
]; ];
const tileHTML = (i) => '<div class="tile">' + svg(i.p) + '</div><span class="tl">' + i.n.replace('&', '&amp;') + '</span>'; const tileHTML = (i) => '<div class="tile">' + svg(i.p) + '</div><span class="tl">' + T(i.n).replace('&', '&amp;') + '</span>';
const fl = $('#floaters'), ir = $('#iconRow'); const fl = $('#floaters'), ir = $('#iconRow');
if (fl && ir) { if (fl && ir) {
ICONS.forEach((i) => { ICONS.forEach((i) => {
@@ -225,7 +226,7 @@
let vi = 0, vSwitch = () => {}; let vi = 0, vSwitch = () => {};
V.forEach((v, i) => { V.forEach((v, i) => {
const b = document.createElement('button'); const b = document.createElement('button');
b.type = 'button'; b.setAttribute('role', 'tab'); b.textContent = v.n; b.type = 'button'; b.setAttribute('role', 'tab'); b.textContent = T(v.n);
b.addEventListener('click', () => setV(i)); b.addEventListener('click', () => setV(i));
vtabs.appendChild(b); vtabs.appendChild(b);
}); });
@@ -233,7 +234,7 @@
function setV(i, first) { function setV(i, first) {
vi = (i + V.length) % V.length; vi = (i + V.length) % V.length;
vbtns.forEach((b, n) => b.setAttribute('aria-selected', String(n === vi))); vbtns.forEach((b, n) => b.setAttribute('aria-selected', String(n === vi)));
const apply = () => { vname.textContent = V[vi].n; vdesc.textContent = V[vi].d; vnum.textContent = '0' + (vi + 1) + ' / 06'; vpanel.style.setProperty('--ac', V[vi].c); vt.classList.remove('out'); }; const apply = () => { vname.textContent = T(V[vi].n); vdesc.textContent = T(V[vi].d); vnum.textContent = '0' + (vi + 1) + ' / 06'; vpanel.style.setProperty('--ac', V[vi].c); vt.classList.remove('out'); };
if (first || reduce) apply(); else { vt.classList.add('out'); setTimeout(apply, 200); } if (first || reduce) apply(); else { vt.classList.add('out'); setTimeout(apply, 200); }
vSwitch(vi); vSwitch(vi);
const sel = vbtns[vi]; const sel = vbtns[vi];
@@ -242,10 +243,12 @@
$('#vprev').addEventListener('click', () => setV(vi - 1)); $('#vprev').addEventListener('click', () => setV(vi - 1));
$('#vnext').addEventListener('click', () => setV(vi + 1)); $('#vnext').addEventListener('click', () => setV(vi + 1));
vpanel.setAttribute('tabindex', '0'); vpanel.setAttribute('tabindex', '0');
vpanel.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') setV(vi + 1); if (e.key === 'ArrowLeft') setV(vi - 1); }); // In Arabic (right to left) "next" is to the left.
const fwd = document.documentElement.dir === 'rtl' ? -1 : 1;
vpanel.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') setV(vi + fwd); if (e.key === 'ArrowLeft') setV(vi - fwd); });
let sx0 = null; let sx0 = null;
vpanel.addEventListener('touchstart', (e) => { sx0 = e.touches[0].clientX; }, { passive: true }); vpanel.addEventListener('touchstart', (e) => { sx0 = e.touches[0].clientX; }, { passive: true });
vpanel.addEventListener('touchend', (e) => { if (sx0 === null) return; const dx = e.changedTouches[0].clientX - sx0; if (Math.abs(dx) > 50) setV(vi + (dx < 0 ? 1 : -1)); sx0 = null; }); vpanel.addEventListener('touchend', (e) => { if (sx0 === null) return; const dx = e.changedTouches[0].clientX - sx0; if (Math.abs(dx) > 50) setV(vi + (dx < 0 ? fwd : -fwd)); sx0 = null; });
setV(0, true); setV(0, true);
if (typeof THREE === 'undefined') return; if (typeof THREE === 'undefined') return;
+77
View File
@@ -0,0 +1,77 @@
/* Arabic: right-to-left layout and type. Loaded only on /ar/ pages, after styles.css. */
:root {
--font-sans: "Noto Sans Arabic", "Inter Tight", "Inter", ui-sans-serif, system-ui, "Geeza Pro", Tahoma, sans-serif;
}
/* Arabic letters join; tracking (positive or negative) breaks them apart. */
body *:not(.brand-word):not(.brand-word *):not(.foot-word) { letter-spacing: 0 !important; }
.h-display-lg, .h-display, .h-lg, .h-md, .rule h3, .book h3, .shout q {
font-family: "Noto Kufi Arabic", var(--font-sans); line-height: 1.35;
}
.statement, .poem, .quote-line { line-height: 1.6; }
.eyebrow, .btn, .text-link, .tag-upper { font-size: 13.5px; }
/* The logo, code samples, URLs and the giant footer word stay left to right. */
.brand, .foot-word, .file, .qlist p, .panel-url, .lang-switch, [dir="ltr"] { direction: ltr; }
/* Arrows */
.flip { transform: scaleX(-1); }
.btn:hover svg.flip, .text-link:hover svg.flip { transform: translateX(-3px) scaleX(-1); }
.btn:hover .go { transform: translateX(-3px); }
.tab-btn .arr { transform: translateX(4px) scaleX(-1); }
.tab-btn[aria-selected="true"] .arr { transform: scaleX(-1); }
#vprev svg, #vnext svg { transform: scaleX(-1); }
/* Physical offsets mirrored */
.skip { left: auto; right: 16px; }
.mobile-menu .sub a { padding-left: 2px; padding-right: 18px; }
.panel-url { margin-left: 0; margin-right: auto; }
.cursor { margin-left: 0; margin-right: 2px; }
.cluster { text-align: right; }
.dash { left: auto; right: 228px; }
.profile { left: auto; right: 0; }
.code { right: auto; left: 0; }
.feature::before { left: auto; right: 0; }
.stat { padding: 28px 0 0 24px; }
.stat b sup { margin-left: 0; margin-right: 2px; }
.quote-line { padding-left: 0; border-left: 0; padding-right: 20px; border-right: 2px solid var(--accent); }
.flow { left: auto; right: -32px; }
.process-step:not(:last-child)::after { right: auto; left: -16px; }
.tab-btn { text-align: right; }
.principle { padding: 32px 0 8px 32px; }
.industries { border-left: 0; border-right: 1px solid var(--mist); }
.industry { border-right: 0; border-left: 1px solid var(--mist); }
.progress { transform-origin: 100% 50%; }
@media (min-width: 641px) {
.stat + .stat { padding-left: 0; border-left: 0; padding-right: 24px; border-right: 1px solid var(--mist); }
}
@media (max-width: 1100px) {
.stat-note { border-right: 0 !important; }
}
@media (min-width: 901px) {
.principle + .principle { padding-left: 0; border-left: 0; padding-right: 32px; border-right: 1px solid var(--mist); }
}
@media (max-width: 900px) {
.flow { right: 16px; }
.process-step:not(:last-child)::after { left: auto; right: 58px; }
}
@media (max-width: 1100px) {
.cluster .panel { right: auto; }
}
/* Homepage */
.cluster-fit .dash { left: auto; right: 176px; }
.cluster-fit .profile { left: auto; right: 0; }
.cluster-fit .code { right: auto; left: 0; }
@media (max-width: 1100px) {
.cluster-fit .panel { right: auto; left: auto; }
}
.w-side { right: auto; left: max(var(--gutter), calc((100vw - var(--page-max)) / 2 + var(--gutter))); }
.w-top { left: auto; right: max(var(--gutter), calc((100vw - var(--page-max)) / 2 + var(--gutter))); }
@media (max-width: 700px) { .w-side { left: var(--gutter); right: var(--gutter); } }
.varrows { right: auto; left: 1.2rem; }
.jfinal { left: auto; right: 1.2rem; }
.jreplay { right: auto; left: 1rem; }
.cc::before { right: auto; left: -40px; }
/* Scroll-driven word tracks move left to right in every language. */
.wmask, .pbword { direction: ltr; }
+6 -4
View File
@@ -1,6 +1,8 @@
(function () { (function () {
window.__site = true; window.__site = true;
var root = document.documentElement; var root = document.documentElement;
// Translations for the current page's language, written into the page by the build.
var T = function (s) { return (window.__i18n && window.__i18n[s]) || s; };
var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Theme toggle: remembers the visitor's choice; otherwise follows the system. // Theme toggle: remembers the visitor's choice; otherwise follows the system.
@@ -9,7 +11,7 @@
var paint = function () { var paint = function () {
if (!themeBtn) return; if (!themeBtn) return;
var dark = current() === 'dark'; var dark = current() === 'dark';
themeBtn.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode'); themeBtn.setAttribute('aria-label', T(dark ? 'Switch to light mode' : 'Switch to dark mode'));
themeBtn.setAttribute('aria-pressed', String(dark)); themeBtn.setAttribute('aria-pressed', String(dark));
}; };
var apply = function (t, save) { var apply = function (t, save) {
@@ -41,7 +43,7 @@
var open = menu.hidden; var open = menu.hidden;
menu.hidden = !open; menu.hidden = !open;
menuBtn.setAttribute('aria-expanded', String(open)); menuBtn.setAttribute('aria-expanded', String(open));
menuBtn.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); menuBtn.setAttribute('aria-label', T(open ? 'Close menu' : 'Open menu'));
}); });
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !menu.hidden) { menu.hidden = true; menuBtn.setAttribute('aria-expanded', 'false'); menuBtn.focus(); } if (e.key === 'Escape' && !menu.hidden) { menu.hidden = true; menuBtn.setAttribute('aria-expanded', 'false'); menuBtn.focus(); }
@@ -116,7 +118,7 @@
b.addEventListener('click', function () { b.addEventListener('click', function () {
var text = b.getAttribute('data-copy'); var text = b.getAttribute('data-copy');
var label = b.textContent; var label = b.textContent;
var done = function () { b.textContent = 'Copied'; setTimeout(function () { b.textContent = label; }, 1600); }; var done = function () { b.textContent = T('Copied'); setTimeout(function () { b.textContent = label; }, 1600); };
try { navigator.clipboard.writeText(text).then(done, function () {}); } catch (e) {} try { navigator.clipboard.writeText(text).then(done, function () {}); } catch (e) {}
}); });
}); });
@@ -139,7 +141,7 @@
var subject = 'Inquiry from ' + d.get('name') + (d.get('topic') ? ' — ' + d.get('topic') : ''); var subject = 'Inquiry from ' + d.get('name') + (d.get('topic') ? ' — ' + d.get('topic') : '');
window.location.href = 'mailto:' + form.getAttribute('data-to') + '?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body); window.location.href = 'mailto:' + form.getAttribute('data-to') + '?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
var status = document.getElementById('form-status'); var status = document.getElementById('form-status');
if (status) status.textContent = 'Your email app should open with the message ready to send.'; if (status) status.textContent = T('Your email app should open with the message ready to send.');
}); });
} }
+7
View File
@@ -889,6 +889,12 @@ h1, h2, h3, h4 { font-weight: 600; }
.dropdown { background: var(--nav-bg); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); border-color: var(--bd); } .dropdown { background: var(--nav-bg); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); border-color: var(--bd); }
:root[data-theme="dark"] .dropdown { background: rgba(10, 14, 26, .96); } :root[data-theme="dark"] .dropdown { background: rgba(10, 14, 26, .96); }
.theme-btn, .menu-btn { border-radius: 10px; border-color: var(--bd); background: var(--glass); } .theme-btn, .menu-btn { border-radius: 10px; border-color: var(--bd); background: var(--glass); }
.lang-switch { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--bd); border-radius: 10px; background: var(--glass); flex: none; }
.lang-switch a, .lang-switch span { display: inline-grid; place-items: center; min-width: 32px; height: 32px; padding: 0 6px; border-radius: 7px; font: 600 11.5px/1 var(--font-sans); letter-spacing: 0.08em; color: var(--slate); transition: color .15s ease, background-color .15s ease; }
.lang-switch a:hover { color: var(--ink); }
.lang-switch [aria-current] { color: var(--ink); background: rgba(26, 127, 255, .22); }
.mobile-menu .lang-switch { display: flex; width: max-content; margin-top: 20px; }
.mobile-menu .lang-switch a { padding: 0 6px; font-size: 12.5px; border-bottom: 0; }
/* Real logo on dark, vector recreation on light */ /* Real logo on dark, vector recreation on light */
.logo-img { display: none; height: 30px; width: auto; } .logo-img { display: none; height: 30px; width: auto; }
@@ -950,6 +956,7 @@ h1, h2, h3, h4 { font-weight: 600; }
.brand-word { font-size: 12px; } .brand-word { font-size: 12px; }
.logo-mark { width: 38px; } .logo-mark { width: 38px; }
.nav-cta { gap: 6px; } .nav-cta { gap: 6px; }
.nav-cta .lang-switch { display: none; }
.hero { padding-top: 56px; } .hero { padding-top: 56px; }
.page-hero { padding-block: 48px 56px; } .page-hero { padding-block: 48px 56px; }
.cluster { grid-template-columns: 1fr; margin-top: 48px; } .cluster { grid-template-columns: 1fr; margin-top: 48px; }