feat(i18n): nine languages, each at its own address; release v6.3.0
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

The site now speaks German, French, Spanish, Ukrainian, Russian, Chinese,
Korean and Hindi alongside English — 623 strings per language, 5,607
translations, covering the landing page, the key exchange, the chat, group
calls and every error along the way.

Each locale is a real page at a real URL (/de/, /fr/, …), generated at build
time from locales/*.json. That is the whole point: the app is client-rendered,
so a language that only swaps strings at runtime has no address for a crawler
to index and no link anyone can share. Each page carries its own canonical, a
reciprocal hreflang cluster and translated schema.org.

The URL decides the language, always. A stored preference only applies where
the address does not say, and nothing ever redirects on Accept-Language —
that is how sites become invisible to search engines outside one country.

robots.txt and sitemap.xml did not exist before; both are generated now.

Bugs found on the way, each with a test that would have caught it:
  - partner logos used page-relative paths and 404'd from any /xx/ page
  - post-build stamped ?v= only into the root shell, leaving locales behind
  - "Back online" appeared on every page load, not just after being offline
  - update timestamps were hard-coded to US format for every reader
  - t() threw on a partial window, taking whole components down with it
This commit is contained in:
lockbitchat
2026-08-29 12:46:15 -04:00
parent 943e04e7ff
commit 98d42ac2fb
73 changed files with 33861 additions and 1299 deletions
+30 -2
View File
@@ -54,6 +54,33 @@ http {
# (e.g. qr-local.js ~1.2MB) when nothing changed, while still picking up
# new releases immediately.
~^/dist/ "no-cache, must-revalidate";
# The install prompt is a plain browser module and imports the dictionary by a
# bare path, so that import carries no ?v= to bust the cache with. Left on the
# default one-year immutable rule below, a visitor would keep the translations
# they first loaded for a year.
~^/src/i18n/ "no-cache, must-revalidate";
# Crawler-facing files must stay re-fetchable: the default below is a
# one-year immutable cache, which would freeze a sitemap for a year.
~^/robots\.txt$ "public, max-age=3600";
~^/sitemap\.xml$ "public, max-age=3600";
}
# Which app shell an unmatched navigation falls back to. A request under /de/ must
# land on /de/index.html, not the English one — otherwise every deep link into a
# localized page silently switches language. The entries between the markers are
# rewritten by scripts/build-i18n.js from locales/site.json.
map $uri $sb_shell {
default /index.html;
# BEGIN generated locale shells
~^/de/ /de/index.html;
~^/fr/ /fr/index.html;
~^/es/ /es/index.html;
~^/uk/ /uk/index.html;
~^/ru/ /ru/index.html;
~^/zh/ /zh/index.html;
~^/ko/ /ko/index.html;
~^/hi/ /hi/index.html;
# END generated locale shells
}
# CDN-Cache-Control is read by Cloudflare (and other CDNs) *independently* of the
@@ -71,6 +98,7 @@ http {
~^/meta\.json$ "no-store";
~^/config/ice-servers\.js$ "no-store";
~^/dist/ "no-store";
~^/src/i18n/ "no-store";
}
server {
@@ -99,13 +127,13 @@ http {
# Real asset files must return 404 when missing — never fall back to the
# HTML shell, which would be served with the wrong content type and break
# module/script loading (e.g. a missing config/ice-servers.js).
location ~* \.(js|mjs|jsx|css|json|map|woff2?|ttf|otf|png|jpe?g|gif|webp|svg|ico|mp3|mp4|webm)$ {
location ~* \.(js|mjs|jsx|css|json|map|xml|txt|woff2?|ttf|otf|png|jpe?g|gif|webp|svg|ico|mp3|mp4|webm)$ {
try_files $uri =404;
}
# SPA-style fallback so unknown navigation routes still load the app shell.
location / {
try_files $uri $uri/ /index.html;
try_files $uri $uri/ $sb_shell;
}
}
}