Serve the site in English, Spanish and Arabic, add a captcha, make the message optional

- Move every route under app/[lang]; a proxy keeps English on unprefixed URLs and serves /es and /ar
- Interface strings and homepage copy per locale; content pages load a translated JSON when one exists
- Language switch in the header, hreflang alternates and a multilingual sitemap
- Right-to-left layout for Arabic with logical CSS properties, Noto Sans Arabic and no letter-spacing
- Self-hosted proof-of-work "I'm not a robot" check (no third-party account) replaces Turnstile
- The message field is optional; failed attempts refill the form instead of clearing it
This commit is contained in:
Alejandro Veras
2026-10-13 10:49:14 -04:00
parent 2f9b63ccac
commit 5bf472b735
65 changed files with 2386 additions and 756 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ Usage:
Paragraphs that start with "> " were bold sentences in the deck and render as
emphasised lines. Each document follows the same conventions: bold all-caps lines are eyebrows
(when followed by a heading) or CTAs; Heading 1/2/3 give the structure.
The output lands in src/content/pages/<slug>.json. The homepage is hand-built
The output lands in src/content/pages/en/<slug>.json. The homepage is hand-built
and is skipped.
"""
@@ -18,7 +18,7 @@ import re
import sys
import zipfile
OUT = pathlib.Path(__file__).resolve().parent.parent / "src" / "content" / "pages"
OUT = pathlib.Path(__file__).resolve().parent.parent / "src" / "content" / "pages" / "en"
# Lines in the decks that are notes to the team, not site copy.
DROP = [
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Translation helpers for the generated content pages.
python3 -E scripts/i18n.py extract <out_dir>
Write one flat {path: text} JSON per English page with only the strings to translate.
python3 -E scripts/i18n.py apply <locale> <dir>
Read translated flat files from <dir> and write src/content/pages/<locale>/<slug>.json,
keeping the English structure. Fails if a translation is missing or extra.
python3 -E scripts/i18n.py index
Regenerate src/content/pages/index.ts. A locale without a page falls back to English.
"""
from __future__ import annotations
import copy
import json
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
PAGES = ROOT / "src" / "content" / "pages"
LOCALES = ["en", "es", "ar"]
SKIP_KEYS = {"slug", "path", "form", "level", "kind", "num"}
def keep_english(value: str, section: dict | None, key: str) -> bool:
"""Office addresses and stat figures stay as they are in every language."""
if "Phone:" in value:
return True
if section and section.get("kind") == "stats" and key == "title" and re.match(r"^[\d.,]+", value):
return True
return False
def walk(node, path=(), section=None):
if isinstance(node, dict):
if "kind" in node:
section = node
for k, v in node.items():
if k in SKIP_KEYS:
continue
yield from walk(v, path + (k,), section)
elif isinstance(node, list):
for i, v in enumerate(node):
yield from walk(v, path + (str(i),), section)
elif isinstance(node, str) and node.strip():
if not keep_english(node, section, path[-1]):
yield ".".join(path), node
def set_path(node, path: str, value: str):
parts = path.split(".")
for p in parts[:-1]:
node = node[int(p)] if isinstance(node, list) else node[p]
last = parts[-1]
if isinstance(node, list):
node[int(last)] = value
else:
node[last] = value
def english_pages():
return sorted((PAGES / "en").glob("*.json"))
def extract(out_dir: str):
out = pathlib.Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
for f in english_pages():
flat = dict(walk(json.loads(f.read_text())))
(out / f.name).write_text(json.dumps(flat, ensure_ascii=False, indent=1) + "\n")
print(f"{f.name}: {len(flat)} strings")
def apply(locale: str, src_dir: str):
src = pathlib.Path(src_dir)
dest = PAGES / locale
dest.mkdir(parents=True, exist_ok=True)
problems = 0
for f in english_pages():
tf = src / f.name
if not tf.exists():
print(f"skip {f.name}: no translation yet")
continue
page = json.loads(f.read_text())
wanted = dict(walk(page))
got = json.loads(tf.read_text())
missing = [k for k in wanted if k not in got]
extra = [k for k in got if k not in wanted]
if missing or extra:
problems += 1
print(f"{f.name}: missing {missing[:5]} extra {extra[:5]}")
continue
out = copy.deepcopy(page)
for k, v in got.items():
set_path(out, k, v)
(dest / f.name).write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n")
print(f"{locale}/{f.name}: ok")
if problems:
sys.exit(1)
def ident(locale: str, name: str) -> str:
return locale + "_" + re.sub(r"\W", "_", name)
def index():
names = [f.stem for f in english_pages()]
lines = ["// Generated by scripts/i18n.py index — do not edit by hand.", ""]
table = []
for locale in LOCALES:
entries = []
for n in names:
if (PAGES / locale / f"{n}.json").exists():
lines.append(f'import {ident(locale, n)} from "./{locale}/{n}.json";')
entries.append(f' "{n}": {ident(locale, n)},')
else:
entries.append(f' "{n}": {ident("en", n)},')
table.append(f" {locale}: {{\n" + "\n".join(entries) + "\n },")
lines += ["", "export const content = {", *table, "};", ""]
(PAGES / "index.ts").write_text("\n".join(lines))
print(f"index.ts: {len(names)} pages × {len(LOCALES)} locales")
if __name__ == "__main__":
cmd, *args = sys.argv[1:]
{"extract": extract, "apply": apply, "index": index}[cmd](*args)