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
+27 -9
View File
@@ -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>