370 lines
11 KiB
Python
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("&", "&")
|
|
.replace('"', """)
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
)
|
|
|
|
|
|
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()
|