feat(lang): routage préfixé /fr/ et /en/ avec redirect JS langue sur les chemins non préfixés

- page-redirect sur / et tous les chemins sans préfixe : localStorage zektyc-lang
  puis navigator.language, défaut fr ; fallback FR si la page n'a pas de version EN
- /en/ sert enfin la home EN (public/en/), fix de LANG_ROUTES.en
- home EN générée à partir d'i18n.json (ld+json Organization/FAQPage valides)
- i18n.js/components.js : switch de langue par préfixe + liens préfixés
- anciennes URLs /zmap/en/* : 301 vers /en/zmap/* ; pages EN zmap déplacées
  vers le layout <page>/en/ standard
- canonicals/hreflang fr-en-x-default sur toutes les pages statiques + sitemaps
  (équipe, zmap, archives, vitesses-par-route ajoutées) ; suppression du domaine
  obsolète zektyc.duckdns.org (robots, sitemap index, security.txt)
This commit is contained in:
riricdev 2026-09-06 16:12:16 +02:00
commit 370b3d8752
30 changed files with 566 additions and 124 deletions

View file

@ -73,11 +73,11 @@ const MAX_RULES_LEN = 2000
const HONEYPOT_MAX_IPS = 500
const HONEYPOT_CONFIG: SiteConfig = {
domain: "zektyc.duckdns.org",
domain: "riricdev.tail5ea5cd.ts.net",
siteName: "Zektyc",
dbName: "wordpress",
dbUser: "wp_admin",
adminEmail: "admin@zektyc.duckdns.org",
adminEmail: "admin@riricdev.tail5ea5cd.ts.net",
phpVersion: "7.4.33",
serverSoftware: "Apache/2.4.51 (Debian)",
serverName: "web-01",
@ -139,6 +139,48 @@ function html(body: string, cache = "no-cache") {
})
}
function langRedirectPage(rest: string): string {
const fr = `${SITE_URL}/fr${rest}`
const en = `${SITE_URL}/en${rest}`
const restJs = JSON.stringify(rest)
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, follow" />
<title>Zektyc</title>
<link rel="canonical" href="${fr}" />
<link rel="alternate" hreflang="fr" href="${fr}" />
<link rel="alternate" hreflang="en" href="${en}" />
<link rel="alternate" hreflang="x-default" href="${fr}" />
<meta http-equiv="refresh" content="0; url=${fr}" />
<script>(function(){
var rest=${restJs};
var target="/fr"+rest;
try{
var saved=localStorage.getItem("zektyc-lang");
if(saved==="en"||saved==="fr"){ target="/"+saved+rest; }
else {
var ln=(navigator.language||navigator.userLanguage||"").toLowerCase();
if(ln.indexOf("en")===0) target="/en"+rest;
}
}catch(e){}
if(target.indexOf("/en")===0){
fetch(target,{method:"HEAD",cache:"no-store"}).then(function(r){
location.replace(r.ok?target:"/fr"+rest);
}).catch(function(){ location.replace("/fr"+rest); });
} else {
location.replace(target);
}
})();</script>
</head>
<body>
<p>Redirection… / Redirecting…</p>
</body>
</html>`
}
function bearerToken(req: Request): string | null {
const h = req.headers.get("authorization")
return h ? (h.match(/^Bearer\s+(\S+)$/i)?.[1] ?? null) : null
@ -533,20 +575,20 @@ function errorPage(status: number, req?: Request): Response {
.replace(/__MSG__/g, isFr
? "La page que vous recherchez n\u2019existe pas, a \u00e9t\u00e9 d\u00e9plac\u00e9e ou une erreur s\u2019est produite."
: "The page you are looking for does not exist, has been moved, or an error occurred.")
.replace(/__HREF__/g, `/${isFr ? "" : "en/"}`)
.replace(/__HREF__/g, `/${isFr ? "fr" : "en"}/`)
.replace(/__BACK__/g, isFr ? "Retour \u00e0 l\u2019accueil" : "Back to home")
.replace(/__NEWS_HREF__/g, isFr ? "blog/" : "en/blog/")
.replace(/__NEWS_HREF__/g, isFr ? "fr/blog/" : "en/blog/")
.replace(/__NEWS__/g, isFr ? "Blog" : "Blog")
.replace(/__PROJECTS_HREF__/g, isFr ? "projets/" : "en/projets/")
.replace(/__PROJECTS_HREF__/g, isFr ? "fr/projets/" : "en/projets/")
.replace(/__PROJECTS__/g, isFr ? "Projets" : "Projects")
.replace(/__FR_ACTIVE__/g, isFr ? "active" : "")
.replace(/__EN_ACTIVE__/g, !isFr ? "active" : "")
.replace(/__FR_LABEL__/g, isFr ? "<strong>FR</strong>" : "FR")
.replace(/__EN_LABEL__/g, !isFr ? "<strong>EN</strong>" : "EN")
.replace(/__NOTE__/g, isFr ? "Ce site ne collecte rien. Aucun cookie, aucun tracker." : "This site collects nothing. No cookies, no trackers.")
.replace(/__TOS_HREF__/g, isFr ? "/tos" : "/en/tos")
.replace(/__TOS_HREF__/g, isFr ? "/fr/tos" : "/en/tos")
.replace(/__TOS__/g, isFr ? "Conditions d\u2019utilisation" : "Terms of Service")
.replace(/__PRIV_HREF__/g, isFr ? "/privacy-policy" : "/en/privacy-policy")
.replace(/__PRIV_HREF__/g, isFr ? "/fr/privacy-policy" : "/en/privacy-policy")
.replace(/__PRIV__/g, isFr ? "Confidentialit\u00e9" : "Privacy")
return new Response(body, {
@ -682,12 +724,16 @@ function newsIndex(lang: "fr" | "en"): string {
function writeSitemaps(articles: Article[]) {
const base: { fr: string; en: string; lm: string }[] = [
{ fr: "/fr/", en: "/en/", lm: "2026-08-14" },
{ fr: "/fr/", en: "/en/", lm: "2026-09-06" },
{ fr: "/fr/tos", en: "/en/tos", lm: "2026-08-14" },
{ fr: "/fr/privacy-policy", en: "/en/privacy-policy", lm: "2026-08-14" },
{ fr: "/fr/blog/", en: "/en/blog/", lm: "2026-08-14" },
{ fr: "/fr/fingerprint-test", en: "/en/fingerprint-test", lm: "2026-08-16" },
{ fr: "/fr/projets", en: "/en/projets", lm: "2026-08-17" },
{ fr: "/fr/equipe", en: "/en/equipe", lm: "2026-08-14" },
{ fr: "/fr/zmap", en: "/en/zmap", lm: "2026-08-17" },
{ fr: "/fr/zmap/archives", en: "/en/zmap/archives", lm: "2026-08-17" },
{ fr: "/fr/zmap/vitesses-par-route", en: "/en/zmap/road-speeds", lm: "2026-08-17" },
]
const art = articles.map((a) => ({ fr: `/fr/blog/${a.slug}/`, en: `/en/blog/${a.slug}/`, lm: a.modified || a.published }))
const pages = [...base, ...art].sort((x, y) => x.fr.localeCompare(y.fr))
@ -746,7 +792,7 @@ function clientIp(req: Request): string {
const LANG_ROUTES: Record<string, (rest: string) => string> = {
fr: (rest) => (rest ? "/" + rest : "/"),
en: (rest) => (rest ? "/" + rest.replace(/\/+$/, "") + "/en" : "/"),
en: (rest) => (rest ? "/" + rest.replace(/\/+$/, "") + "/en" : "/en"),
}
function resolveLangPath(path: string): string | null {
@ -764,6 +810,13 @@ function lookup(path: string) {
return resolved
}
function isHtmlFile(path: string): boolean {
try {
const resolved = lookup(path)
return !!resolved && resolved.endsWith(".html") && existsSync(resolved)
} catch { return false }
}
function matchArticle(p: string, articles: Article[]): { article: Article; lang: "fr" | "en" } | null {
const m = p.match(/^\/blog\/([a-z0-9-]+)(?:\/en)?\/?$/)
if (!m) return null
@ -773,6 +826,10 @@ function matchArticle(p: string, articles: Article[]): { article: Article; lang:
return article ? { article, lang } : null
}
function artPath(matcher: { article: Article; lang: "fr" | "en" }): string {
return `/blog/${matcher.article.slug}`
}
function serveAdminIndex() {
const f = file(normalize(join(root, "z-panel", "index.html")))
return f.exists().then((exists) =>
@ -1218,13 +1275,29 @@ server = serve({
if (page) return html(page, "public, max-age=300")
}
const p = resolveLangPath(pathname) ?? pathname
// Les chemins sans préfixe de langue (/fr/ ou /en/) sont des points d'entrée :
// on ne sert jamais de page ici, on renvoie la page-redirect qui choisit la langue
// (localStorage puis navigateur, sinon fr). Les assets, /api, /docs et le honeypot
// WordPress restent intacts (ils ne se résolvent pas en page HTML).
const articles = readArticles()
if (!/^\/(fr|en)(\/|$)/.test(pathname)) {
// Anciennes URLs EN de l'ère « /zmap/en/... » : 301 direct vers /en/zmap/...
const legacyEn = pathname.match(/^\/zmap\/en(?:\/(.*))?$/)
if (legacyEn) {
const sub = legacyEn[1] ? "/" + legacyEn[1].replace(/\/+$/, "") : ""
return new Response(null, { status: 301, headers: { Location: `/en/zmap${sub}/` } })
}
const artMatch = matchArticle(pathname, articles)
if (artMatch) return html(langRedirectPage(artPath(artMatch)), "no-cache")
if (isHtmlFile(pathname)) return html(langRedirectPage(pathname === "/" ? "/" : pathname), "no-cache")
}
const p = resolveLangPath(pathname) ?? pathname
const matched = matchArticle(p, articles)
if (matched) return html(articlePage(matched.article, matched.lang))
if (p === "/actualites" || p === "/actualites/") return new Response(null, { status: 301, headers: { Location: "/blog/" } })
if (p === "/actualites" || p === "/actualites/") return new Response(null, { status: 301, headers: { Location: "/fr/blog/" } })
if (p === "/actualites/en" || p === "/actualites/en/") return new Response(null, { status: 301, headers: { Location: "/en/blog/" } })
if (p.startsWith("/actualites")) return new Response(null, { status: 301, headers: { Location: "/blog/" } })
if (p.startsWith("/actualites")) return new Response(null, { status: 301, headers: { Location: "/fr/blog/" } })
const filePath = lookup(decodeURIComponent(p))
if (!filePath) return new Response("Forbidden", { status: 403 })

View file

@ -25,7 +25,7 @@ const RATE_MAX = 30
const RATE_WINDOW = 60_000
const UA =
"ZMapProxy/1.0 (zektyc.duckdns.org; contact: riric65@protonmail.com; OSM proxy)"
"ZMapProxy/1.0 (riricdev.tail5ea5cd.ts.net; contact: riric65@protonmail.com; OSM proxy)"
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {