From 4a5adf90a5b0621f489f48e17002419d49b6b587 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 6 Sep 2026 13:32:37 +0300 Subject: [PATCH] Localize website in all supported languages and refresh site icons --- .github/workflows/pages.yml | 31 ++- index.html | 365 +++++++------------------------ scripts/assemble-site.sh | 5 +- scripts/build-site.py | 112 ++++++++++ scripts/generate-star-history.py | 25 ++- scripts/test_site.cjs | 46 ++++ scripts/test_site.py | 88 ++++++++ site/README.md | 14 ++ site/apple-touch-icon.png | Bin 0 -> 4265 bytes site/favicon.ico | Bin 0 -> 4573 bytes site/icon-192.png | Bin 0 -> 4541 bytes site/icon-512.png | Bin 0 -> 11747 bytes site/locales/ar-SA.json | 95 ++++++++ site/locales/bg-BG.json | 95 ++++++++ site/locales/cs-CZ.json | 95 ++++++++ site/locales/de-DE.json | 95 ++++++++ site/locales/el-GR.json | 95 ++++++++ site/locales/en-US.json | 95 ++++++++ site/locales/es-ES.json | 95 ++++++++ site/locales/fi-FI.json | 95 ++++++++ site/locales/fr-FR.json | 95 ++++++++ site/locales/hi-IN.json | 95 ++++++++ site/locales/hu-HU.json | 95 ++++++++ site/locales/it-IT.json | 95 ++++++++ site/locales/ja-JP.json | 95 ++++++++ site/locales/ko-KR.json | 95 ++++++++ site/locales/nl-NL.json | 95 ++++++++ site/locales/pl-PL.json | 95 ++++++++ site/locales/pt-BR.json | 95 ++++++++ site/locales/ru-RU.json | 95 ++++++++ site/locales/tr-TR.json | 95 ++++++++ site/locales/zh-CN.json | 95 ++++++++ site/locales/zh-TW.json | 95 ++++++++ site/site.css | 98 +++++---- site/site.js | 25 +++ site/sitemap.xml | 8 - 36 files changed, 2478 insertions(+), 334 deletions(-) create mode 100644 scripts/build-site.py create mode 100644 scripts/test_site.cjs create mode 100644 scripts/test_site.py create mode 100644 site/README.md create mode 100644 site/apple-touch-icon.png create mode 100644 site/favicon.ico create mode 100644 site/icon-192.png create mode 100644 site/icon-512.png create mode 100644 site/locales/ar-SA.json create mode 100644 site/locales/bg-BG.json create mode 100644 site/locales/cs-CZ.json create mode 100644 site/locales/de-DE.json create mode 100644 site/locales/el-GR.json create mode 100644 site/locales/en-US.json create mode 100644 site/locales/es-ES.json create mode 100644 site/locales/fi-FI.json create mode 100644 site/locales/fr-FR.json create mode 100644 site/locales/hi-IN.json create mode 100644 site/locales/hu-HU.json create mode 100644 site/locales/it-IT.json create mode 100644 site/locales/ja-JP.json create mode 100644 site/locales/ko-KR.json create mode 100644 site/locales/nl-NL.json create mode 100644 site/locales/pl-PL.json create mode 100644 site/locales/pt-BR.json create mode 100644 site/locales/ru-RU.json create mode 100644 site/locales/tr-TR.json create mode 100644 site/locales/zh-CN.json create mode 100644 site/locales/zh-TW.json create mode 100644 site/site.js delete mode 100644 site/sitemap.xml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 2ab469196..165bef8ba 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,6 +2,18 @@ name: Deploy web (GitHub Pages) run-name: ${{ github.event.release.tag_name || github.ref_name }} Web on: + push: + branches: [main] + paths: + - index.html + - site/** + - assets/logo.svg + - scripts/assemble-site.sh + - scripts/build-site.py + - scripts/test_site.py + - scripts/test_site.cjs + - scripts/generate-star-history.py + - .github/workflows/pages.yml release: types: [published] workflow_call: @@ -92,8 +104,23 @@ jobs: - name: Build web bundle run: trunk build --locked --release --public-url /app/ --dist dist/app --html-output index.html web-app.html + - name: Check out current website + uses: actions/checkout@v4 + with: + ref: main + path: website + + - name: Validate website + working-directory: website + run: python3 scripts/test_site.py + - name: Assemble landing page - run: sh scripts/assemble-site.sh + working-directory: website + run: sh scripts/assemble-site.sh "$GITHUB_WORKSPACE/dist" + + - name: Record website source + working-directory: website + run: git rev-parse HEAD > "$GITHUB_WORKSPACE/dist/site-version.txt" - name: Record release version and source env: @@ -229,7 +256,7 @@ jobs: - name: Generate project growth charts env: GITHUB_TOKEN: ${{ secrets.OCS_GITHUB_TOKEN }} - run: python3 scripts/generate-star-history.py --output-dir dist + run: python3 website/scripts/generate-star-history.py --output-dir dist # Browsers cannot reliably fetch YouTube playlist/oEmbed responses because # of CORS. Build a same-origin listing and thumbnail directory for the diff --git a/index.html b/index.html index 4b00cffea..43222afd4 100644 --- a/index.html +++ b/index.html @@ -1,373 +1,178 @@ - + - Open CAD Studio — Open-Source DWG & DXF CAD - + {{title}} + - - - - + + {{alternates}} + {{icons}} + + + - - - - + + + + - + - - + + - - + - - + -
-

Open-source CAD · Desktop + Web

-

Native DWG & DXF.
One open CAD studio.

-

- Create technical drawings, layouts and 3D models with a shared Rust core. - Start in your browser or install the desktop app for your full production workflow. -

+

{{eyebrow}}

+

{{hero_title}}
{{hero_emphasis}}

+

{{hero_lead}}

-
    -
  • GPL-3.0 licensed
  • -
  • Windows · Linux · macOS
  • -
  • 20 interface languages
  • +

    {{no_account}}

    +
      +
    • {{license}}
    • Windows · Linux · macOS
    • {{languages}}
- -
-
- -
+
-
DWG + DXFRead and write
-
2D + 3DOne workspace
-
GPUAccelerated viewport
-
RustShared editing core
+
DWG + DXF{{read_write}}
+
2D + 3D{{one_workspace}}
+
GPU{{gpu}}
+
Rust{{core}}
-
-

Built for real drawing work

-

From the first line to the final layout.

-

One focused environment for drafting, documentation and solid modeling.

+

{{cap_eyebrow}}

+

{{cap_title}}

{{cap_lead}}

-
01
-

Precise drafting

-

Draw with intent.

-

- Lines, polylines, curves, splines, hatches, object snaps, tracking, - layers, blocks and external references stay close at hand. -

-
- +
-
-
02
-

Documentation

-

Layouts that communicate.

-

- Text, dimensions, leaders, tolerances, tables, paper space, viewports - and plot styles turn geometry into deliverables. -

+
02

{{doc_label}}

{{doc_title}}

{{doc_text}}

-
-
03
-

Open workflow

-

Your drawings stay yours.

-

- Work with native files, inspect the source and choose the browser or - desktop workflow that fits the job. -

+
03

{{open_label}}

{{open_title}}

{{open_text}}

-
-

Kernel-backed 3D

-

Solid modeling, not a visual shortcut.

-

- Build primitives and shape them with extrusion, revolution, sweep, - loft and Boolean operations. The geometry kernel resolves the model; - the GPU viewport keeps it responsive. -

-
    -
  • Orthographic and perspective cameras
  • -
  • ACIS entity tessellation
  • -
  • OBJ import and mesh export workflows
  • -
+

{{model_label}}

{{model_title}}

{{model_text}}

+
  • {{camera}}
  • {{tessellation}}
  • {{mesh_workflow}}
- A 3D mechanical model shown in two Open CAD Studio viewports -
Model spaceKernel → Tessellation → GPU
+ {{model_alt}} +
{{model_space}}{{model_caption}}
-
-
-

One core, two ways to work

-

Start instantly. Scale up when you need to.

-
- +

{{editions_label}}

{{editions_title}}

-
Web
-

Open a drawing in your browser.

-

- No installation. Select a drawing from your device, edit with the - shared core and save the result as a local download. -

- Launch the web app +
{{web}}

{{web_title}}

{{web_text}}

{{web_limit}}

+ {{launch}}
-
-
-

File workflows

-

Meet drawings where they are.

-

- Native drawing formats, recovery files, survey data, meshes, plots - and property exports fit into a practical project workflow. -

-
-
-
DWGRead · Write · R14–2018 targets
-
DXFRead · Write · R14–2018 targets
-
BAK / SV$Drawing recovery
-
OBJ / STLMesh import and export
-
PDFDesktop plotting
-
CSVProperty extraction
+

{{formats_label}}

{{formats_title}}

{{formats_text}}

+
+
DWG{{format_versions}}
DXF{{format_versions}}
+
BAK / SV${{recovery}}
OBJ / STL{{mesh_formats}}
+
PDF{{pdf}}
CSV{{csv}}
-
-
-

Project growth

-

Built openly. Growing in public.

-

Stars and release downloads are refreshed whenever a new version is published.

-
- +

{{growth_label}}

{{growth_title}}

{{growth_text}}

- - Open CAD Studio star history - -
- Stars - View stargazers ↗ -
+ {{stars}} +
{{stars}}{{stars_link}} ↗
-
- - Open CAD Studio release download history - -
- Release downloads - View releases ↗ -
+ {{downloads}} +
{{downloads}}{{releases_link}} ↗
-
-
-

Common questions

-

Before you open your first drawing.

-
+

{{faq_label}}

{{faq_title}}

-
- Is Open CAD Studio free? -

Yes. The project is open source and distributed under the GPL-3.0 license.

-
-
- Can I use it without installing anything? -

Yes. The web app runs in a modern browser and saves edited drawings as local downloads.

-
-
- Which desktop platforms are supported? -

Release packages are available for Windows, Linux and Apple Silicon macOS.

-
-
- Is the project ready for critical production files? -

The project is under active development. Keep backups and report reproducible problems on GitHub.

-
+
{{faq_free}}

{{faq_free_answer}}

+
{{faq_web}}

{{faq_web_answer}}

+
{{faq_platforms}}

{{faq_platforms_answer}}

+
{{faq_production}}

{{faq_production_answer}}

-
-
-

Built in the open

-

Use it. Inspect it. Help shape it.

-

- Follow development, report reproducible issues, contribute code or - support the work that keeps an independent CAD project moving. -

-
- +

{{community_label}}

{{community_title}}

{{community_text}}

+
- diff --git a/scripts/assemble-site.sh b/scripts/assemble-site.sh index 6e1855d8f..b614e2003 100644 --- a/scripts/assemble-site.sh +++ b/scripts/assemble-site.sh @@ -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" diff --git a/scripts/build-site.py b/scripts/build-site.py new file mode 100644 index 000000000..6519f975b --- /dev/null +++ b/scripts/build-site.py @@ -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'\n' + f'\n' + f'') + 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'' for lang in catalogs) + alternates += f'\n' + 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'{escape(labels["name"])}' 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'{BASE}/\n' + ''.join(f'{BASE}{locale_path(locale)}\n' for locale in catalogs) + (output / "sitemap.xml").write_text('\n\n' + urls + '\n') + + app = output / "app/index.html" + if app.exists(): + html = re.sub(r']*\brel=["\'](?:shortcut )?icon["\'])[^>]*>', '', app.read_text()) + app.write_text(html.replace('', icons + '\n'), 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")) diff --git a/scripts/generate-star-history.py b/scripts/generate-star-history.py index 31a51752f..e0b98ebab 100644 --- a/scripts/generate-star-history.py +++ b/scripts/generate-star-history.py @@ -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( {escape(title)} {escape(subtitle)} - {legend} - {axis_label} + {escape(legend)} + {escape(axis_label)} {''.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" diff --git a/scripts/test_site.cjs b/scripts/test_site.cjs new file mode 100644 index 000000000..72ad7b554 --- /dev/null +++ b/scripts/test_site.cjs @@ -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'); diff --git a/scripts/test_site.py b/scripts/test_site.py new file mode 100644 index 000000000..2e33ea2ba --- /dev/null +++ b/scripts/test_site.py @@ -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('App') + 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'', 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') diff --git a/site/README.md b/site/README.md new file mode 100644 index 000000000..b96725a53 --- /dev/null +++ b/site/README.md @@ -0,0 +1,14 @@ +# Website + +`index.html` is the template; `site/locales/*.json` supplies all 21 application languages. +Run `sh scripts/assemble-site.sh` after the web build, then +`python3 scripts/generate-star-history.py --output-dir dist` for the charts. +Run `python3 scripts/test_site.py` to check translations, links, metadata and language selection. + +The root page follows the browser language, falling back to English. Language links +use explicit locale paths and remember manual choices when browser storage is available. +Arabic uses a right-to-left layout. Icons are exported from `assets/logo.svg`; +update the PNG/ICO files in this directory when the logo changes. + +GitHub Pages publishes website changes from `main` while building the app from the +latest release. Each deployment records both sources separately. diff --git a/site/apple-touch-icon.png b/site/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..53bc90110a550241d40def93bc8b2633ec6d1e44 GIT binary patch literal 4265 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 literal 0 HcmV?d00001 diff --git a/site/favicon.ico b/site/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a98da5409b6ae04ee2957be2db7aedc4c23916e4 GIT binary patch literal 4573 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 PJ3IPkQ?Kd&-i`hPIEi>5 literal 0 HcmV?d00001 diff --git a/site/icon-192.png b/site/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..f5f7f3bda085952ab5fd50678a18aaf473e3e1c2 GIT binary patch literal 4541 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 p:last-child, .formats-grid > div > p:last-child, .model-copy > p { max-width: 640px; color: var(--muted); font-size: 17px; line-height: 1.7; } -.capability-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.capability-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } .capability-card { position: relative; min-height: 340px; padding: 34px; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: linear-gradient(145deg, rgba(22,33,45,.9), rgba(14,21,29,.9)); } .capability-wide { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr .8fr; align-items: center; min-height: 410px; } .capability-card h3, .edition-card h3 { max-width: 560px; margin-bottom: 18px; font-size: clamp(28px, 3vw, 42px); line-height: 1.08; letter-spacing: -.035em; } -.capability-card p:not(.card-kicker) { max-width: 570px; color: var(--muted); font-size: 15px; line-height: 1.72; } -.card-index { position: absolute; top: 25px; right: 28px; color: rgba(89,195,255,.42); font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } -.card-kicker, .edition-label { margin-bottom: 20px; color: var(--cyan); font: 700 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .13em; text-transform: uppercase; } +.capability-card p:not(.card-kicker) { max-width: 570px; color: var(--muted); font-size: 1rem; line-height: 1.72; } +.card-index { position: absolute; top: 25px; inset-inline-end: 28px; color: rgba(89,195,255,.42); font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.card-kicker, .edition-label { margin-bottom: 20px; color: var(--cyan); font: 700 .875rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .13em; text-transform: uppercase; } .card-accent { background: linear-gradient(145deg, rgba(19,46,62,.92), rgba(11,22,30,.92)); } .drafting-mark { position: relative; width: min(310px, 80%); aspect-ratio: 1; justify-self: center; border: 1px solid rgba(89,195,255,.18); border-radius: 50%; } .drafting-mark .axis { position: absolute; background: rgba(89,195,255,.25); } @@ -132,54 +132,54 @@ h2 { margin-bottom: 20px; font-size: clamp(38px, 4.4vw, 60px); line-height: 1.04 .model-grid { display: grid; grid-template-columns: 1fr; gap: 48px; } .model-copy { max-width: 760px; } .model-copy h2 { max-width: 580px; } -.check-list { display: grid; gap: 13px; margin: 30px 0 0; padding: 0; list-style: none; color: var(--muted); font-size: 14px; } +.check-list { display: grid; gap: 13px; margin: 30px 0 0; padding: 0; list-style: none; color: var(--muted); font-size: .875rem; } .check-list li::before { content: "+"; display: inline-block; width: 24px; color: var(--cyan); font-family: ui-monospace, monospace; } .model-frame { width: 100%; min-width: 0; margin: 0; overflow: hidden; border: 1px solid var(--line-strong); border-radius: 8px; background: #13191f; } .model-frame img { width: 100%; height: auto; } -.model-frame figcaption { display: flex; justify-content: space-between; gap: 20px; padding: 14px 17px; border-top: 1px solid var(--line); color: var(--quiet); font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.model-frame figcaption { display: flex; justify-content: space-between; gap: 20px; padding: 14px 17px; border-top: 1px solid var(--line); color: var(--quiet); font: .875rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } .model-frame figcaption strong { color: var(--cyan); font-weight: 550; } -.edition-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.edition-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } .edition-card { min-height: 390px; padding: 38px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); } .edition-primary { border-color: rgba(89,195,255,.36); background: linear-gradient(145deg, rgba(19,52,69,.85), rgba(13,24,33,.94)); } .edition-card p { max-width: 520px; color: var(--muted); line-height: 1.7; } -.text-link { display: inline-flex; gap: 8px; margin-top: 36px; color: var(--cyan); font-size: 14px; font-weight: 720; } +.text-link { display: inline-flex; gap: 8px; margin-top: 36px; color: var(--cyan); font-size: .875rem; font-weight: 720; } .text-link:hover { color: #91daff; } .formats-section { border-block: 1px solid var(--line); background: linear-gradient(180deg, rgba(17,25,35,.75), rgba(11,16,22,.6)); } -.formats-grid { display: grid; grid-template-columns: .75fr 1.25fr; gap: 90px; align-items: start; } +.formats-grid { display: grid; grid-template-columns: minmax(0, .75fr) minmax(0, 1.25fr); gap: 90px; align-items: start; } .format-list { border-top: 1px solid var(--line-strong); } .format-list div { display: grid; grid-template-columns: 160px 1fr; gap: 24px; padding: 20px 0; border-bottom: 1px solid var(--line); } .format-list strong { color: var(--ink); font: 700 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .05em; } -.format-list span { color: var(--muted); font-size: 14px; } +.format-list span { color: var(--muted); font-size: .875rem; } .growth-section { border-bottom: 1px solid var(--line); background: #0e151d; } .growth-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } .growth-card { width: 100%; margin: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); } .growth-card > a { display: block; } .growth-card img { width: 100%; height: auto; } -.growth-card figcaption { display: flex; justify-content: space-between; gap: 20px; padding: 15px 18px; border-top: 1px solid var(--line); color: var(--muted); font-size: 13px; } +.growth-card figcaption { display: flex; justify-content: space-between; gap: 20px; padding: 15px 18px; border-top: 1px solid var(--line); color: var(--muted); font-size: .875rem; } .growth-card figcaption span { color: var(--ink); font-weight: 680; } .growth-card figcaption a { color: var(--cyan); font-weight: 680; } .growth-card figcaption a:hover { color: #91daff; } -.faq-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.faq-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } .faq-grid details { border: 1px solid var(--line); border-radius: 7px; background: rgba(17,25,35,.72); } -.faq-grid summary { padding: 21px 24px; cursor: pointer; font-size: 14px; font-weight: 680; } -.faq-grid details p { margin: 0; padding: 0 24px 22px; color: var(--muted); font-size: 14px; line-height: 1.65; } +.faq-grid summary { padding: 21px 24px; cursor: pointer; font-size: .875rem; font-weight: 680; } +.faq-grid details p { margin: 0; padding: 0 24px 22px; color: var(--muted); font-size: 1rem; line-height: 1.65; } .community-cta { display: grid; grid-template-columns: 1.2fr .8fr; gap: 60px; align-items: end; margin-bottom: 118px; padding: 58px; border: 1px solid rgba(89,195,255,.28); border-radius: 10px; background: linear-gradient(130deg, #142635, #0f1821 70%); } .community-cta h2 { max-width: 680px; } .community-cta p:last-child { max-width: 700px; color: var(--muted); line-height: 1.7; } .cta-links { display: flex; flex-direction: column; gap: 12px; } .site-footer { border-top: 1px solid var(--line); background: #080c11; } -.footer-grid { min-height: 150px; display: grid; grid-template-columns: auto 1fr auto; gap: 34px; align-items: center; } -.footer-grid p { margin: 0; color: var(--quiet); font-size: 12px; } -.footer-grid nav { display: flex; gap: 22px; color: var(--muted); font-size: 12px; } +.footer-grid { min-height: 150px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 34px; align-items: center; } +.footer-grid p { margin: 0; color: var(--quiet); font-size: .875rem; } +.footer-grid nav { display: flex; gap: 22px; color: var(--muted); font-size: .875rem; } @media (max-width: 980px) { - .nav-shell nav { display: none; } - .nav-shell .button { margin-left: auto; } + .nav-shell .primary-nav { display: none; } + .nav-shell .language-picker { margin-inline-start: auto; } .hero { padding-top: 80px; } .formats-grid { grid-template-columns: 1fr; gap: 48px; } .community-cta { grid-template-columns: 1fr; } @@ -190,15 +190,15 @@ h2 { margin-bottom: 20px; font-size: clamp(38px, 4.4vw, 60px); line-height: 1.04 @media (max-width: 700px) { :root { --shell: min(100% - 28px, 1180px); } .nav-shell { min-height: 64px; } - .brand { font-size: 14px; }.brand img { width: 34px; height: 34px; } - .nav-shell .button-small { min-height: 36px; padding-inline: 11px; font-size: 11px; } + .brand { font-size: .875rem; }.brand img { width: 34px; height: 34px; } + .nav-shell > .button-small { width: 100%; min-height: 44px; } .hero { padding-block: 64px 58px; } h1 { font-size: clamp(42px, 13vw, 62px); } .hero-lead { font-size: 16px; } .hero-actions .button { width: 100%; } .hero-notes { display: grid; gap: 8px; }.hero-notes li::before { display: none; } .visual-badge { right: 10px; bottom: 10px; } - .signal-grid { grid-template-columns: 1fr 1fr; } + .signal-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .signal-grid div { padding: 19px 16px; border-bottom: 1px solid var(--line); } .section { padding-block: 82px; } .capability-grid, .edition-grid, .faq-grid { grid-template-columns: 1fr; } @@ -218,3 +218,27 @@ h2 { margin-bottom: 20px; font-size: clamp(38px, 4.4vw, 60px); line-height: 1.04 html { scroll-behavior: auto; } *, *::before, *::after { transition-duration: .01ms !important; } } + +:focus-visible { outline: 2px solid var(--cyan); outline-offset: 5px; } +.language-picker { position: relative; font-size: .875rem; } +.language-picker summary { padding: 10px 12px; min-height: 44px; border: 1px solid var(--line-strong); border-radius: 6px; cursor: pointer; } +.language-picker nav { position: absolute; inset-inline-end: 0; top: calc(100% + 8px); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; width: min(350px, calc(100vw - 28px)); max-height: 65vh; overflow-y: auto; padding: 8px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); box-shadow: 0 16px 48px #0008; } +.language-picker nav a { display: flex; align-items: center; min-height: 44px; padding: 8px; border-radius: 4px; } +.language-picker nav a:hover, .language-picker nav a[aria-current] { background: var(--panel-2); color: var(--cyan); } +.launch-note { color: var(--cyan); font-size: .875rem; line-height: 1.5; } +.command-examples { display: flex; flex-wrap: wrap; gap: 8px; } +.command-examples code { padding: 5px 8px; border: 1px solid var(--line-strong); border-radius: 4px; color: var(--cyan); font-size: .875rem; } +.release-link { display: block; width: fit-content; margin-top: 18px; color: var(--muted); text-decoration: underline; text-underline-offset: 4px; font-size: .875rem; } +:lang(ar), :lang(hi) { letter-spacing: normal; line-height: 1.5; } +:lang(ar) .eyebrow, :lang(hi) .eyebrow { letter-spacing: normal; } +[dir="rtl"] .brand { flex-direction: row-reverse; } +@media (max-width: 700px) { + .language-picker { position: static; } + .language-picker nav { inset-inline: 14px; top: calc(100% - 4px); width: auto; } + .nav-shell { position: relative; gap: 10px; } + .language-picker summary { padding-inline: 8px; } + .brand { font-size: .875rem; gap: 8px; } + .capability-wide { min-height: 0; } + .capability-wide > div:not(.card-index) { position: relative; z-index: 1; } + .drafting-mark { display: none; } +} diff --git a/site/site.js b/site/site.js new file mode 100644 index 000000000..f449f0727 --- /dev/null +++ b/site/site.js @@ -0,0 +1,25 @@ +function preferredLocale(language) { + const tag = (language || "").toLowerCase(); + const exact = SITE_LOCALES.find(locale => locale.toLowerCase() === tag); + if (exact) return exact; + const base = tag.split("-")[0]; + if (base === "zh") { + return /(?:^|-)hant(?:-|$)|(?:^|-)(tw|hk|mo)(?:-|$)/.test(tag) ? "zh-TW" : "zh-CN"; + } + return SITE_LOCALES.find(locale => locale.split("-")[0] === base) || "en-US"; +} + +(() => { + const storageKey = "ocs.site.language"; + let saved; + try { saved = localStorage.getItem(storageKey); } catch {} + const locale = SITE_LOCALES.includes(saved) ? saved : preferredLocale(navigator.language); + if ((location.pathname === "/" || location.pathname === "/index.html") && locale !== "en-US") { + location.replace(`/${locale}/${location.search}${location.hash}`); + } + document.addEventListener("click", event => { + const link = event.target.closest(".language-picker a[hreflang]"); + if (!link) return; + try { localStorage.setItem(storageKey, link.hreflang); } catch {} + }); +})(); diff --git a/site/sitemap.xml b/site/sitemap.xml deleted file mode 100644 index dd19b534a..000000000 --- a/site/sitemap.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - https://www.opencadstudio.com/ - weekly - 1.0 - -