Localize website in all supported languages and refresh site icons

This commit is contained in:
Hakan Seven committed 2026-09-06 13:32:37 +03:00
1 parent 2ebad14586
commit 4a5adf90a5
36 files changed
+2478 -334

No files matched your search

+1 -4
View File
@@ -5,13 +5,10 @@ output="${1:-dist}"
test -f "$output/app/index.html"
mkdir -p "$output/assets"
install -m 0644 index.html "$output/index.html"
install -m 0644 site/site.css "$output/site.css"
install -m 0644 site/robots.txt "$output/robots.txt"
install -m 0644 site/sitemap.xml "$output/sitemap.xml"
install -m 0644 site/site.webmanifest "$output/site.webmanifest"
install -m 0644 site/CNAME "$output/CNAME"
install -m 0644 site/og.png "$output/og.png"
install -m 0644 site/workspace.png "$output/assets/workspace.png"
install -m 0644 site/modeling.png "$output/assets/modeling.png"
install -m 0644 assets/logo.svg "$output/assets/logo.svg"
python3 scripts/build-site.py "$output"
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Build the static website using the application's supported locales."""
import hashlib
import json
import re
import shutil
import sys
from html import escape
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BASE = "https://www.opencadstudio.com"
REPO = "https://github.com/HakanSeven12/OpenCADStudio"
def locale_path(locale):
return f"/{locale}/"
def load_catalogs():
catalogs = {p.stem: json.loads(p.read_text()) for p in sorted((ROOT / "site/locales").glob("*.json"))}
supported = {p.parent.name for p in (ROOT / "locales").glob("*/opencadstudio.ftl")}
if catalogs.keys() != supported:
raise ValueError(f"Website locale mismatch: {catalogs.keys() ^ supported}")
source = catalogs["en-US"]
for locale, messages in catalogs.items():
if messages.keys() != source.keys():
raise ValueError(f"{locale}: translation key mismatch: {messages.keys() ^ source.keys()}")
for key, value in messages.items():
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{locale}: empty translation for {key}")
if set(re.findall(r"\{\w+\}", value)) != set(re.findall(r"\{\w+\}", source[key])):
raise ValueError(f"{locale}: placeholder mismatch for {key}")
return catalogs
def build(output):
catalogs = load_catalogs()
template = (ROOT / "index.html").read_text()
(output / "assets").mkdir(parents=True, exist_ok=True)
def asset(source):
fingerprint = hashlib.sha256(source.read_bytes()).hexdigest()[:12]
path = f"/assets/{source.stem}-{fingerprint}{source.suffix}"
shutil.copyfile(source, output / path.lstrip("/"))
return path
logo = asset(ROOT / "assets/logo.svg")
shutil.copyfile(ROOT / "assets/logo.svg", output / "assets/logo.svg")
ico = asset(ROOT / "site/favicon.ico")
touch = asset(ROOT / "site/apple-touch-icon.png")
icons = (f'<link rel="icon" href="{ico}" sizes="16x16 32x32 48x48" />\n'
f'<link rel="icon" href="{logo}" type="image/svg+xml" sizes="any" />\n'
f'<link rel="apple-touch-icon" href="{touch}" sizes="180x180" />')
manifest = json.loads((ROOT / "site/site.webmanifest").read_text())
manifest["icons"] = [{"src": logo, "sizes": "any", "type": "image/svg+xml"}]
for size in (192, 512):
manifest["icons"].append({"src": asset(ROOT / f"site/icon-{size}.png"), "sizes": f"{size}x{size}", "type": "image/png"})
shutil.copyfile(ROOT / "site/favicon.ico", output / "favicon.ico")
shutil.copyfile(ROOT / "site/apple-touch-icon.png", output / "apple-touch-icon.png")
css_version = hashlib.sha256((ROOT / "site/site.css").read_bytes()).hexdigest()[:12]
script = "const SITE_LOCALES = " + json.dumps(list(catalogs)) + ";\n" + (ROOT / "site/site.js").read_text()
(output / "site.js").write_text(script, encoding="utf-8")
js_version = hashlib.sha256(script.encode()).hexdigest()[:12]
alternates = '\n'.join(f'<link rel="alternate" hreflang="{lang}" href="{BASE}{locale_path(lang)}" />' for lang in catalogs)
alternates += f'\n<link rel="alternate" hreflang="x-default" href="{BASE}/" />'
for locale, messages in catalogs.items():
path = locale_path(locale)
destination = output / path.lstrip("/")
destination.mkdir(parents=True, exist_ok=True)
language_links = '\n'.join(
f'<a href="{locale_path(lang)}" lang="{lang}" hreflang="{lang}" dir="auto"'
+ (' aria-current="page"' if lang == locale else '')
+ f'>{escape(labels["name"])}</a>' for lang, labels in catalogs.items()
)
schema = {
"@context": "https://schema.org", "@type": "SoftwareApplication",
"name": "Open CAD Studio", "url": BASE + path, "inLanguage": locale,
"description": messages["description"], "applicationCategory": "DesignApplication",
"operatingSystem": "Windows, Linux, macOS, Web", "isAccessibleForFree": True,
"license": "https://www.gnu.org/licenses/gpl-3.0.html", "codeRepository": REPO,
"downloadUrl": REPO + "/releases/latest", "installUrl": BASE + "/app/",
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
}
values = {key: escape(value) for key, value in messages.items()}
values.update(locale=locale, direction="rtl" if locale == "ar-SA" else "ltr",
path=path, url=BASE + path, og_locale=locale.replace("-", "_"),
logo=logo, icons=icons, css_version=css_version, js_version=js_version, alternates=alternates,
languages=escape(messages["languages"].format(count=len(catalogs))),
language_links=language_links,
schema=json.dumps(schema, ensure_ascii=False).replace("<", "\\u003c"))
rendered = re.sub(r"\{\{(\w+)\}\}", lambda match: values[match[1]], template)
(destination / "index.html").write_text(rendered, encoding="utf-8")
localized_manifest = {**manifest, "lang": locale, "dir": values["direction"],
"description": messages["description"], "start_url": path,
"shortcuts": [{"name": messages["launch"], "url": "/app/"}]}
(destination / "site.webmanifest").write_text(json.dumps(localized_manifest, ensure_ascii=False, indent=2) + '\n', encoding="utf-8")
for filename in ("index.html", "site.webmanifest"):
shutil.copyfile(output / "en-US" / filename, output / filename)
urls = f'<url><loc>{BASE}/</loc></url>\n' + ''.join(f'<url><loc>{BASE}{locale_path(locale)}</loc></url>\n' for locale in catalogs)
(output / "sitemap.xml").write_text('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' + urls + '</urlset>\n')
app = output / "app/index.html"
if app.exists():
html = re.sub(r'<link\b(?=[^>]*\brel=["\'](?:shortcut )?icon["\'])[^>]*>', '', app.read_text())
app.write_text(html.replace('</head>', icons + '\n</head>'), encoding="utf-8")
print(f"Built {len(catalogs)} website languages and current application icons")
if __name__ == "__main__":
build(Path(sys.argv[1] if len(sys.argv) > 1 else "dist"))
+22 -3
View File
@@ -163,6 +163,7 @@ def render_svg(
releases: list[tuple[str, datetime, int]],
theme: str,
chart: str,
labels: dict | None = None,
) -> str:
colors = THEMES[theme]
now = datetime.now(timezone.utc)
@@ -190,6 +191,17 @@ def render_svg(
point_color = colors["download_point"]
last_label = f"{releases[-1][0]} · {releases[-1][2]}"
if labels:
title = labels["stars" if stars else "downloads"]
legend = title
axis_label = labels["stars" if stars else "chart_downloads"]
subtitle = labels["chart_updated"].format(date=now.strftime("%Y-%m-%d"))
if stars:
subtitle = f"{len(dates)} · {subtitle}"
last_label = str(len(dates))
else:
subtitle = labels["chart_totals"].format(count=f"{total_downloads:,}", releases=len(releases)) + " · " + subtitle
start = points[0][0] - timedelta(days=2)
end = max(now, points[-1][0]) + timedelta(days=2)
duration = max((end - start).total_seconds(), 1)
@@ -243,7 +255,9 @@ def render_svg(
for index in range(5):
moment = start + (end - start) * (index / 4)
x = x_position(moment)
if index in (0, 4):
if labels:
label = moment.strftime("%Y-%m")
elif index in (0, 4):
label = moment.strftime("%b %Y")
else:
label = moment.strftime("%b")
@@ -281,8 +295,8 @@ def render_svg(
<text x="{PLOT_LEFT}" y="34" fill="{colors["text"]}" font-size="21" font-weight="700">{escape(title)}</text>
<text x="{PLOT_LEFT}" y="58" fill="{colors["muted"]}" font-size="13">{escape(subtitle)}</text>
<line x1="{PLOT_LEFT}" y1="82" x2="{PLOT_LEFT + 24}" y2="82" stroke="{line_color}" stroke-width="3" />
<text x="{PLOT_LEFT + 32}" y="86" fill="{colors["text"]}" font-size="12">{legend}</text>
<text x="{PLOT_LEFT}" y="{PLOT_TOP - 8}" fill="{line_color}" font-size="12">{axis_label}</text>
<text x="{PLOT_LEFT + 32}" y="86" fill="{colors["text"]}" font-size="12">{escape(legend)}</text>
<text x="{PLOT_LEFT}" y="{PLOT_TOP - 8}" fill="{line_color}" font-size="12">{escape(axis_label)}</text>
{''.join(grid)}
{''.join(x_labels)}
{area}
@@ -303,6 +317,7 @@ def main() -> None:
)
parser.add_argument("--output-dir", type=Path, default=Path("dist"))
args = parser.parse_args()
catalogs = {path.stem: json.loads(path.read_text()) for path in (Path(__file__).resolve().parents[1] / "site/locales").glob("*.json")}
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
dates = fetch_star_dates(args.repository, token)
@@ -315,6 +330,10 @@ def main() -> None:
render_svg(args.repository, dates, releases, theme, chart),
encoding="utf-8",
)
for locale, labels in catalogs.items():
localized = args.output_dir / locale / output.name
localized.parent.mkdir(parents=True, exist_ok=True)
localized.write_text(render_svg(args.repository, dates, releases, theme, chart, labels), encoding="utf-8")
print(
f"growth history: {len(dates)} stars, "
f"{sum(downloads for _, _, downloads in releases)} downloads"
+46
View File
@@ -0,0 +1,46 @@
// Run with node scripts/test_site.cjs dist/site.js.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync(process.argv[2] || 'dist/site.js', 'utf8');
function visit(language, pathname = '/', saved, blocked = false) {
let redirected, click;
const context = {
navigator: { language },
location: { pathname, search: '?ref=test', hash: '#formats', replace: url => { redirected = url; } },
document: { addEventListener: (type, listener) => { click = listener; } },
localStorage: {
getItem: () => { if (blocked) throw new Error('Storage disabled'); return saved; },
setItem: (key, value) => { if (blocked) throw new Error('Storage disabled'); saved = value; },
},
};
vm.runInNewContext(source, context);
return { context, redirected, choose: tag => click({ target: { closest: () => ({ hreflang: tag }) } }), saved: () => saved };
}
for (const [language, expected] of Object.entries({
'tr-TR': 'tr-TR', 'tr-CY': 'tr-TR', 'TR': 'tr-TR', 'pt-PT': 'pt-BR',
'zh-Hant': 'zh-TW', 'zh-Hant-HK': 'zh-TW', 'zh-HK': 'zh-TW', 'zh-MO': 'zh-TW',
'zh-Hans-SG': 'zh-CN', 'en-GB': 'en-US', 'sv-SE': 'en-US', '': 'en-US',
})) {
const result = visit(language);
assert.equal(result.context.preferredLocale(language), expected);
assert.equal(result.redirected, expected === 'en-US' ? undefined : `/${expected}/?ref=test#formats`);
}
const context = visit('en-US').context;
for (const locale of vm.runInNewContext('SITE_LOCALES', context)) {
assert.equal(context.preferredLocale(locale), locale);
}
assert.equal(visit('tr-TR', '/', 'en-US').redirected, undefined);
assert.equal(visit('sv-SE', '/', 'tr-TR').redirected, '/tr-TR/?ref=test#formats');
assert.equal(visit('tr-TR', '/', '../../unsafe').redirected, '/tr-TR/?ref=test#formats');
assert.equal(visit('tr-TR', '/en-US/', undefined, true).redirected, undefined);
assert.equal(visit('en-US', '/tr-TR/').redirected, undefined);
assert.equal(visit('tr-TR', '/', undefined, true).redirected, '/tr-TR/?ref=test#formats');
assert.equal(visit('tr-TR', '/index.html').redirected, '/tr-TR/?ref=test#formats');
const choice = visit('en-US');
choice.choose('tr-TR');
assert.equal(choice.saved(), 'tr-TR');
visit('en-US', '/', undefined, true).choose('en-US');
console.log('Website language matching, redirects, manual choices and blocked storage passed');
+88
View File
@@ -0,0 +1,88 @@
"""Run with python3 scripts/test_site.py (Python and Node standard libraries)."""
from datetime import datetime, timezone
from html.parser import HTMLParser
import importlib.util
import json
from pathlib import Path
import re
import subprocess
import tempfile
from urllib.parse import urlsplit
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[1]
class Page(HTMLParser):
def __init__(self, html):
super().__init__()
self.tags = []
self.ids = set()
self.feed(html)
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
self.tags.append((tag, attrs))
if 'id' in attrs:
assert attrs['id'] not in self.ids, attrs['id']
self.ids.add(attrs['id'])
with tempfile.TemporaryDirectory() as directory:
output = Path(directory)
(output / 'app').mkdir()
(output / 'app/index.html').write_text('<html><head><link rel="icon" href="/old.svg" integrity="old" /></head><body>App</body></html>')
subprocess.run(['sh', 'scripts/assemble-site.sh', str(output)], cwd=ROOT, check=True)
catalogs = {p.stem: json.loads(p.read_text()) for p in (ROOT / 'site/locales').glob('*.json')}
supported = set(re.findall(r'#\[serde\(rename = "([a-z]{2}-[A-Z]{2})"\)\]', (ROOT / 'src/i18n.rs').read_text()))
assert catalogs.keys() == supported
spec = importlib.util.spec_from_file_location('charts', ROOT / 'scripts/generate-star-history.py')
charts = importlib.util.module_from_spec(spec)
spec.loader.exec_module(charts)
date = datetime(2026, 1, 1, tzinfo=timezone.utc)
for locale, messages in catalogs.items():
for chart in ('stars', 'downloads'):
svg = charts.render_svg('HakanSeven12/OpenCADStudio', [date], [('v1', date, 10)], 'dark', chart, messages)
tree = ET.fromstring(svg)
assert tree.find('{http://www.w3.org/2000/svg}title').text == messages['stars' if chart == 'stars' else 'downloads']
(output / locale / f'{chart[:-1]}-history-dark.svg').write_text(svg)
html = (output / locale / 'index.html').read_text()
assert '{{' not in html and '{count}' not in html, locale
page = Page(html)
assert ('html', {'lang': locale, 'dir': 'rtl' if locale == 'ar-SA' else 'ltr'}) in page.tags
links = [attrs for tag, attrs in page.tags if tag == 'link']
assert {link['hreflang'] for link in links if link.get('rel') == 'alternate'} == supported | {'x-default'}
assert next(link['href'] for link in links if link.get('rel') == 'canonical') == f'https://www.opencadstudio.com/{locale}/'
choices = [attrs for tag, attrs in page.tags if tag == 'a' and 'hreflang' in attrs]
assert {choice['hreflang'] for choice in choices} == supported
assert [choice['hreflang'] for choice in choices if choice.get('aria-current') == 'page'] == [locale]
schema = json.loads(re.search(r'<script type="application/ld\+json">(.*?)</script>', html).group(1))
assert schema['inLanguage'] == locale and schema['description'] == messages['description']
for tag, attrs in page.tags:
if tag == 'img':
assert 'alt' in attrs and 'width' in attrs and 'height' in attrs
for attr in ('src', 'href'):
target = attrs.get(attr, '')
if target.startswith('#'):
assert target[1:] in page.ids, target
elif target.startswith('/'):
path = output / urlsplit(target).path.lstrip('/')
assert path.exists(), (locale, target)
manifest = json.loads((output / locale / 'site.webmanifest').read_text())
assert manifest['lang'] == locale and manifest['start_url'] == f'/{locale}/'
for icon in manifest['icons']:
assert (output / icon['src'].lstrip('/')).exists()
if locale != 'en-US':
for key, value in messages.items():
if len(catalogs['en-US'][key]) > 40:
assert value != catalogs['en-US'][key], (locale, key)
app = (output / 'app/index.html').read_text()
assert 'old.svg' not in app and 'integrity="old"' not in app and '>App<' in app
logo = next(attrs['href'] for tag, attrs in Page(app).tags if attrs.get('type') == 'image/svg+xml')
assert (output / logo.lstrip('/')).read_bytes() == (ROOT / 'assets/logo.svg').read_bytes()
sitemap = ET.parse(output / 'sitemap.xml')
assert len(sitemap.getroot()) == len(supported) + 1
assert (output / 'index.html').read_bytes() == (output / 'en-US/index.html').read_bytes()
subprocess.run(['node', 'scripts/test_site.cjs', str(output / 'site.js')], cwd=ROOT, check=True)
print(f'All {len(supported)} translations, metadata, local links, charts, manifests and icons passed')