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:
@@ -29,6 +29,22 @@ build.mjs Genera dist/ + sitemap.xml + robots.txt + 404.html
|
||||
| `/contact/book/` | `src/pages/book.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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -3,7 +3,8 @@ import { mkdir, rm, writeFile, cp, readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, dirname } from 'node:path';
|
||||
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 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.
|
||||
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);
|
||||
|
||||
// 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 = [];
|
||||
for (const name of pages) {
|
||||
const def = (await import(`./src/pages/${name}.mjs`)).default;
|
||||
const file = join(out, def.path, 'index.html');
|
||||
for (const locale of LOCALES) {
|
||||
let html = page(def, locale);
|
||||
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(page(def)));
|
||||
await writeFile(file, fingerprint(html));
|
||||
}
|
||||
urls.push(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({
|
||||
path: '/404/',
|
||||
@@ -37,8 +55,8 @@ await writeFile(join(out, '404.html'), fingerprint(page({
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
await writeFile(join(out, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.map((u) => ` <url><loc>https://dylanmazzei.com${u}</loc><lastmod>${today}</lastmod></url>`).join('\n')}
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
${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>
|
||||
`);
|
||||
await writeFile(join(out, 'robots.txt'), 'User-agent: *\nAllow: /\nSitemap: https://dylanmazzei.com/sitemap.xml\n');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1316
File diff suppressed because it is too large
Load Diff
+27
-9
@@ -1,4 +1,5 @@
|
||||
// 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 = {
|
||||
url: 'https://dylanmazzei.com',
|
||||
@@ -61,7 +62,9 @@ const SMALL = new Set(['arrow', 'arrow-ur', 'check', 'plus', 'chev', 'brace']);
|
||||
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 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.
|
||||
@@ -111,7 +114,16 @@ const NAV = [
|
||||
{ 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 desktop = NAV.map((item) => {
|
||||
if (!item.children) {
|
||||
@@ -136,6 +148,7 @@ function nav(path) {
|
||||
<a class="brand" href="/" aria-label="Dylan Mazzei, home">${logo()}</a>
|
||||
<nav aria-label="Primary"><ul class="nav-links">${desktop}</ul></nav>
|
||||
<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>
|
||||
<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>
|
||||
@@ -143,6 +156,7 @@ function nav(path) {
|
||||
</div>
|
||||
<div class="wrap mobile-menu" id="mobile-menu" hidden>
|
||||
<ul>${mobile}</ul>
|
||||
${langSwitch(path, locale)}
|
||||
<a class="btn btn-primary" href="/contact/">START A CONVERSATION<span class="go">${icon('arrow')}</span></a>
|
||||
</div>
|
||||
</header>`;
|
||||
@@ -193,15 +207,17 @@ function footer() {
|
||||
<span class="eyebrow">WHERE I WORK</span>
|
||||
<p class="places">Texas<i>•</i>Florida<i>•</i>New York<i>•</i>Dubai</p>
|
||||
</div>
|
||||
<small>© ${new Date().getFullYear()} Dylan Mazzei</small>
|
||||
<small>© <span>${new Date().getFullYear()}</span> Dylan Mazzei</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot-word" aria-hidden="true">DYLAN MAZZEI</div>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
export function page({ path, title, description, body, schema = [], head = '', scripts = '', noindex = false }) {
|
||||
const canonical = SITE.url + path;
|
||||
export function page({ path, title, description, body, schema = [], head = '', scripts = '', noindex = false }, locale = 'en') {
|
||||
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 = {
|
||||
'@context': 'https://schema.org',
|
||||
'@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');
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="${locale}" dir="${dirOf(locale)}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>${title}</title>
|
||||
<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="color-scheme" content="light dark">
|
||||
<meta property="og:type" content="website">
|
||||
<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:description" content="${description}">
|
||||
<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="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/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}
|
||||
<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}
|
||||
</head>
|
||||
<body>
|
||||
<div class="bgfx" aria-hidden="true"></div>
|
||||
<div class="progress" id="progress" aria-hidden="true"></div>
|
||||
${nav(path)}
|
||||
${nav(path, locale)}
|
||||
<main id="main">
|
||||
${body}
|
||||
</main>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const T = (s) => (window.__i18n && window.__i18n[s]) || s;
|
||||
|
||||
/* ---------- 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>';
|
||||
@@ -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: '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('&', '&') + '</span>';
|
||||
const tileHTML = (i) => '<div class="tile">' + svg(i.p) + '</div><span class="tl">' + T(i.n).replace('&', '&') + '</span>';
|
||||
const fl = $('#floaters'), ir = $('#iconRow');
|
||||
if (fl && ir) {
|
||||
ICONS.forEach((i) => {
|
||||
@@ -225,7 +226,7 @@
|
||||
let vi = 0, vSwitch = () => {};
|
||||
V.forEach((v, i) => {
|
||||
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));
|
||||
vtabs.appendChild(b);
|
||||
});
|
||||
@@ -233,7 +234,7 @@
|
||||
function setV(i, first) {
|
||||
vi = (i + V.length) % V.length;
|
||||
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); }
|
||||
vSwitch(vi);
|
||||
const sel = vbtns[vi];
|
||||
@@ -242,10 +243,12 @@
|
||||
$('#vprev').addEventListener('click', () => setV(vi - 1));
|
||||
$('#vnext').addEventListener('click', () => setV(vi + 1));
|
||||
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;
|
||||
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);
|
||||
|
||||
if (typeof THREE === 'undefined') return;
|
||||
|
||||
@@ -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; }
|
||||
@@ -1,6 +1,8 @@
|
||||
(function () {
|
||||
window.__site = true;
|
||||
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;
|
||||
|
||||
// Theme toggle: remembers the visitor's choice; otherwise follows the system.
|
||||
@@ -9,7 +11,7 @@
|
||||
var paint = function () {
|
||||
if (!themeBtn) return;
|
||||
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));
|
||||
};
|
||||
var apply = function (t, save) {
|
||||
@@ -41,7 +43,7 @@
|
||||
var open = menu.hidden;
|
||||
menu.hidden = !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) {
|
||||
if (e.key === 'Escape' && !menu.hidden) { menu.hidden = true; menuBtn.setAttribute('aria-expanded', 'false'); menuBtn.focus(); }
|
||||
@@ -116,7 +118,7 @@
|
||||
b.addEventListener('click', function () {
|
||||
var text = b.getAttribute('data-copy');
|
||||
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) {}
|
||||
});
|
||||
});
|
||||
@@ -139,7 +141,7 @@
|
||||
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);
|
||||
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.');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
: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); }
|
||||
.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 */
|
||||
.logo-img { display: none; height: 30px; width: auto; }
|
||||
@@ -950,6 +956,7 @@ h1, h2, h3, h4 { font-weight: 600; }
|
||||
.brand-word { font-size: 12px; }
|
||||
.logo-mark { width: 38px; }
|
||||
.nav-cta { gap: 6px; }
|
||||
.nav-cta .lang-switch { display: none; }
|
||||
.hero { padding-top: 56px; }
|
||||
.page-hero { padding-block: 48px 56px; }
|
||||
.cluster { grid-template-columns: 1fr; margin-top: 48px; }
|
||||
|
||||
Reference in New Issue
Block a user