forked from 1b-agent/opencadstudio-upstream
Localize website in all supported languages and refresh site icons
This commit is contained in:
1 parent
2ebad14586
commit
4a5adf90a5
36 files changed
+2478
-334
No files matched your search
@@ -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"
|
||||
@@ -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"))
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
@@ -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')
|
||||
Reference in new issue
Block a user