428th-website-2026/scripts/build.py
2026-07-16 16:21:09 +02:00

370 lines
11 KiB
Python

#!/usr/bin/env python3
"""Build static localized HTML for 428th.com (ru / en / ua)."""
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "src"
DIST = ROOT
SITE = "https://428th.com"
LOCALES = [
{
"id": "ru",
"prefix": "",
"html_lang": "ru",
"hreflang": "ru",
"dict": "ru",
"og_locale": "ru_RU",
"label": "RU",
},
{
"id": "en",
"prefix": "/en",
"html_lang": "en",
"hreflang": "en",
"dict": "en",
"og_locale": "en_US",
"label": "EN",
},
{
"id": "ua",
"prefix": "/ua",
"html_lang": "uk",
"hreflang": "uk",
"dict": "uk",
"og_locale": "uk_UA",
"label": "UA",
},
]
PAGES = [
{
"id": "home",
"template": "index.html",
"title_key": "meta.title",
"desc_key": "meta.description",
"path_for": lambda prefix: f"{prefix}/" if prefix else "/",
"out_for": lambda prefix: (
DIST / "index.html"
if not prefix
else DIST / prefix.lstrip("/") / "index.html"
),
"depth_for": lambda prefix: 0 if not prefix else 1,
},
{
"id": "terminal",
"template": "terminal.html",
"title_key": "terminal.meta.title",
"desc_key": "terminal.meta.description",
"path_for": lambda prefix: f"{prefix}/terminal/" if prefix else "/terminal/",
"out_for": lambda prefix: (
DIST / "terminal" / "index.html"
if not prefix
else DIST / prefix.lstrip("/") / "terminal" / "index.html"
),
"depth_for": lambda prefix: 1 if not prefix else 2,
},
]
def asset_prefix(_depth: int) -> str:
"""Root-absolute assets — avoids FOUC on /terminal vs /terminal/ and deep locales."""
return "/"
def t(translations: dict, dict_key: str, key: str) -> str:
value = translations.get(dict_key, {}).get(key)
if value is None:
value = translations.get("ru", {}).get(key, "")
return value
def localize_urls(html: str, prefix: str) -> str:
if not prefix:
return html
html = re.sub(
r'href="/terminal/?"',
f'href="{prefix}/terminal/"',
html,
)
html = re.sub(
r'href="/#([^"]*)"',
lambda m: f'href="{prefix}/#{m.group(1)}"',
html,
)
return html
def fill_i18n(html: str, translations: dict, dict_key: str) -> str:
def repl_el(match: re.Match) -> str:
open_tag, key, close = match.group(1), match.group(2), match.group(4)
open_tag = re.sub(r'\sdata-i18n="[^"]+"', "", open_tag)
return f"{open_tag}{t(translations, dict_key, key)}{close}"
html = re.sub(
r'(<\w+\b[^>]*\sdata-i18n="([^"]+)"[^>]*>)(.*?)(</\w+>)',
repl_el,
html,
flags=re.DOTALL,
)
def repl_placeholder(match: re.Match) -> str:
before, key, after = match.group(1), match.group(2), match.group(3)
value = esc_attr(t(translations, dict_key, key))
return f'{before}placeholder="{value}"{after}'
html = re.sub(
r'(\s)data-i18n-placeholder="([^"]+)"([^>]*>)',
repl_placeholder,
html,
)
def repl_aria(match: re.Match) -> str:
before, key, after = match.group(1), match.group(2), match.group(3)
value = esc_attr(t(translations, dict_key, key))
after = re.sub(r'\saria-label="[^"]*"', "", after)
before = re.sub(r'\saria-label="[^"]*"', "", before)
return f'{before} aria-label="{value}"{after}'
html = re.sub(
r'(<\w+\b[^>]*)\sdata-i18n-aria="([^"]+)"([^>]*>)',
repl_aria,
html,
)
return html
def page_url(locale: dict, page: dict) -> str:
return SITE + page["path_for"](locale["prefix"])
def alternate_links(page: dict) -> str:
lines = []
for loc in LOCALES:
lines.append(
f'<link rel="alternate" hreflang="{loc["hreflang"]}" href="{page_url(loc, page)}">'
)
lines.append(
f'<link rel="alternate" hreflang="x-default" href="{page_url(LOCALES[0], page)}">'
)
return "\n".join(lines)
def lang_switch(locale: dict, page: dict) -> str:
parts = []
order = ["en", "ua", "ru"]
by_id = {loc["id"]: loc for loc in LOCALES}
for i, lid in enumerate(order):
loc = by_id[lid]
href = page["path_for"](loc["prefix"])
active = ' class="active"' if loc["id"] == locale["id"] else ""
aria = ' aria-current="page"' if loc["id"] == locale["id"] else ""
parts.append(
f'<a href="{href}" data-set-lang="{loc["id"]}"{active}{aria}>{loc["label"]}</a>'
)
if i < len(order) - 1:
parts.append(" //")
return "\n ".join(parts)
def esc_attr(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace('"', "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def head_meta(locale: dict, page: dict, translations: dict) -> str:
title = t(translations, locale["dict"], page["title_key"])
desc = t(translations, locale["dict"], page["desc_key"])
url = page_url(locale, page)
return f"""<title>{title}</title>
<meta name="description" content="{esc_attr(desc)}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{url}">
{alternate_links(page)}
<meta property="og:type" content="website">
<meta property="og:site_name" content="428th">
<meta property="og:locale" content="{locale["og_locale"]}">
<meta property="og:url" content="{url}">
<meta property="og:title" content="{esc_attr(title)}">
<meta property="og:description" content="{esc_attr(desc)}">
<meta property="og:image" content="{SITE}/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{esc_attr(title)}">
<meta name="twitter:description" content="{esc_attr(desc)}">
<meta name="twitter:image" content="{SITE}/og.png">
<meta name="theme-color" content="#C4622A">"""
LANG_REDIRECT_SCRIPT = r"""<script>
(function () {
var pageLang = document.documentElement.getAttribute('data-page-lang');
var saved = localStorage.getItem('lang');
if (saved === 'uk') { saved = 'ua'; localStorage.setItem('lang', 'ua'); }
function normalizePath(path) {
if (!path || path === '/') return '/';
return path.replace(/\/+$/, '') || '/';
}
function pathFor(lang) {
var path = location.pathname || '/';
var rest = path.replace(/^\/(en|ua)(?=\/|$)/, '') || '/';
if (rest === '/index.html') rest = '/';
rest = normalizePath(rest);
if (lang === 'ru') {
if (rest === '/') return '/';
if (rest === '/terminal') return '/terminal/';
return rest;
}
if (rest === '/') return '/' + lang + '/';
if (rest === '/terminal') return '/' + lang + '/terminal/';
return '/' + lang + rest;
}
function needsRedirect(target) {
return normalizePath(location.pathname) !== normalizePath(target);
}
if (saved === 'ru' || saved === 'en' || saved === 'ua') {
if (saved !== pageLang) {
var target = pathFor(saved);
if (needsRedirect(target)) location.replace(target);
}
return;
}
if (pageLang !== 'ru') {
localStorage.setItem('lang', pageLang);
return;
}
var browser = String(navigator.language || '').slice(0, 2).toLowerCase();
var preferred = 'ru';
if (browser === 'en') preferred = 'en';
else if (browser === 'uk' || browser === 'ua') preferred = 'ua';
if (preferred !== 'ru') {
var preferredTarget = pathFor(preferred);
if (needsRedirect(preferredTarget)) location.replace(preferredTarget);
} else {
localStorage.setItem('lang', 'ru');
}
})();
</script>"""
THEME_SCRIPT = """<script>
(function () {
var saved = localStorage.getItem('theme');
var theme = (saved === 'light' || saved === 'dark')
? saved
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>"""
def home_href(locale: dict) -> str:
return f"{locale['prefix']}/" if locale["prefix"] else "/"
def terminal_href(locale: dict) -> str:
return f"{locale['prefix']}/terminal/" if locale["prefix"] else "/terminal/"
def build_page(locale: dict, page: dict, translations: dict, template: str) -> str:
prefix = locale["prefix"]
depth = page["depth_for"](prefix)
assets = asset_prefix(depth)
html = template
html = html.replace("{{HTML_LANG}}", locale["html_lang"])
html = html.replace("{{PAGE_LANG}}", locale["id"])
html = html.replace("{{HEAD_META}}", head_meta(locale, page, translations))
html = html.replace("{{THEME_SCRIPT}}", THEME_SCRIPT)
html = html.replace("{{LANG_REDIRECT_SCRIPT}}", LANG_REDIRECT_SCRIPT)
html = html.replace("{{HOME_HREF}}", home_href(locale))
html = html.replace("{{TERMINAL_HREF}}", terminal_href(locale))
html = html.replace("{{ASSET_PREFIX}}", assets)
html = html.replace(
"{{FORM_STATUS_LOADING}}",
esc_attr(t(translations, locale["dict"], "form.statusLoading")),
)
html = html.replace(
"{{FORM_STATUS_SUCCESS}}",
esc_attr(t(translations, locale["dict"], "form.statusSuccess")),
)
html = fill_i18n(html, translations, locale["dict"])
html = localize_urls(html, prefix)
# After localize_urls so RU /terminal/ in the switch is not rewritten to /en/terminal/
html = html.replace("{{LANG_SWITCH}}", lang_switch(locale, page))
return html
def write_sitemap() -> None:
urls = []
for page in PAGES:
for loc in LOCALES:
loc_url = page_url(loc, page)
if page["id"] == "home" and loc["id"] == "ru":
priority = "1.0"
elif page["id"] == "home" or loc["id"] == "ru":
priority = "0.9"
else:
priority = "0.8"
urls.append(
f""" <url>
<loc>{loc_url}</loc>
<changefreq>weekly</changefreq>
<priority>{priority}</priority>
</url>"""
)
content = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
+ "\n".join(urls)
+ "\n</urlset>\n"
)
(DIST / "sitemap.xml").write_text(content, encoding="utf-8")
def main() -> None:
translations = json.loads((SRC / "translations.json").read_text(encoding="utf-8"))
templates = {
"index.html": (SRC / "index.html").read_text(encoding="utf-8"),
"terminal.html": (SRC / "terminal.html").read_text(encoding="utf-8"),
}
for page in PAGES:
for loc in LOCALES:
out = page["out_for"](loc["prefix"])
out.parent.mkdir(parents=True, exist_ok=True)
html = build_page(loc, page, translations, templates[page["template"]])
out.write_text(html, encoding="utf-8")
print(f"wrote {out.relative_to(DIST)}")
write_sitemap()
print("wrote sitemap.xml")
print("build ok")
if __name__ == "__main__":
main()