diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 684f5c94..ea338d7d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,7 +23,12 @@ jobs: python-version: 3.x - run: pip install zensical working-directory: docs - - run: zensical build --clean + - name: Prepare release documentation + run: >- + python docs/scripts/prepare_release.py + --version "$(jq --raw-output .version custom_components/uix/manifest.json)" + --source-revision "${{ github.sha }}" + - run: zensical build --clean --config-file mkdocs.release.yml working-directory: docs - uses: actions/upload-pages-artifact@v5 with: diff --git a/.gitignore b/.gitignore index a04d9e1a..7b44fc3d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ __pycache__/ .venv/ docs/site/ docs/.cache/ +docs/mkdocs.release.yml +docs/source/uix-docs.json *.egg-info/ # Test artifacts — actual screenshots are transient; baselines are committed. diff --git a/AGENTS.md b/AGENTS.md index 02506259..48d963c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,26 @@ tests/ docs/ +## External documentation translations + +- The English documentation in this repository is canonical. Do not add or + accept translated Markdown copies here. +- Translation curators register independently hosted translations by changing + only `docs/translations.json`. Do not edit generated release files or add a + translation directly to `docs/mkdocs.yml`. +- Each registry entry must use a lowercase ISO 639-1 language code, a native + language name, and public HTTPS site and metadata URLs. +- A translation fork uses `docs/site.json` to set its language, native name, + public site URL, and canonical English URL before running the same docs + release workflow. Preserve this workflow's fork compatibility. +- A translation must publish the `uix-docs.json` metadata contract described in + `docs/source/contributing.md`. The documentation release workflow decides + whether it is current enough to display; unavailable, invalid, or stale + translations must be omitted with a workflow warning rather than blocking + publication. +- Do not change the metadata schema, the one-minor-version eligibility policy, + or the generated footer version without explicit maintainer approval. + If modifying: - Templates → docs/source/using/templates.md diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d818107f..370bf54b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,8 +1,9 @@ site_name: UI eXtension -site_url: https://uix.lf.technology +site_url: https://uix.lf.technology # UIX_RELEASE_SITE_URL docs_dir: source theme: name: material + language: en # UIX_RELEASE_LANGUAGE variant: classic features: - navigation.tabs @@ -32,6 +33,9 @@ theme: custom_dir: overrides extra: generator: false + # Replaced in docs/mkdocs.release.yml by docs/scripts/prepare_release.py. + alternate: [] # UIX_RELEASE_ALTERNATES +copyright: "Documentation generated against UIX (local build)" # UIX_RELEASE_COPYRIGHT plugins: - search - git-revision-date-localized: diff --git a/docs/scripts/prepare_release.py b/docs/scripts/prepare_release.py new file mode 100644 index 00000000..d009f756 --- /dev/null +++ b/docs/scripts/prepare_release.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""Prepare the UIX documentation release configuration and version contract. + +External translations are deliberately optional: a bad, stale, or unavailable +translation is omitted from the language selector without blocking publication +of the canonical English documentation. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import ipaddress +import json +import re +import socket +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +MAX_METADATA_BYTES = 64 * 1024 +METADATA_SCHEMA = 1 +SEMVER = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) +LANGUAGE_CODE = re.compile(r"^[a-z]{2}$") + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + """Do not allow a registry URL to silently change its destination.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + raise urllib.error.HTTPError( + req.full_url, code, "Redirects are not permitted", headers, fp + ) + + +def fail(message: str) -> None: + raise ValueError(message) + + +def parse_version(value: str) -> tuple[int, int, int]: + match = SEMVER.fullmatch(value) + if not match: + fail(f"invalid semantic version {value!r}") + return tuple(int(part) for part in match.group(1, 2, 3)) + + +def validate_https_url(value: Any, field: str) -> str: + if not isinstance(value, str) or not value: + fail(f"{field} must be a non-empty URL") + + parsed = urllib.parse.urlsplit(value) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.fragment + ): + fail(f"{field} must be an absolute HTTPS URL without credentials or fragments") + return value + + +def validate_public_host(url: str) -> None: + """Reject DNS targets that resolve only to private or local addresses.""" + + host = urllib.parse.urlsplit(url).hostname + assert host is not None + try: + addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) + except socket.gaierror as error: + fail(f"could not resolve metadata host {host!r}: {error}") + + if not addresses: + fail(f"metadata host {host!r} has no addresses") + + for _, _, _, _, sockaddr in addresses: + if not ipaddress.ip_address(sockaddr[0]).is_global: + fail(f"metadata host {host!r} resolves to a non-public address") + + +def fetch_metadata(url: str) -> dict[str, Any]: + validate_public_host(url) + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "UIX-docs-release-check/1", + }, + ) + opener = urllib.request.build_opener(NoRedirect) + try: + with opener.open(request, timeout=10) as response: + if response.status != 200: + fail(f"metadata request returned HTTP {response.status}") + payload = response.read(MAX_METADATA_BYTES + 1) + except (OSError, urllib.error.URLError, urllib.error.HTTPError) as error: + fail(f"could not fetch metadata: {error}") + + if len(payload) > MAX_METADATA_BYTES: + fail("metadata response exceeds 64 KiB") + try: + metadata = json.loads(payload) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + fail(f"metadata is not valid JSON: {error}") + if not isinstance(metadata, dict): + fail("metadata must be a JSON object") + return metadata + + +def check_site(url: str) -> None: + """Confirm that the language selector destination is reachable.""" + + validate_public_host(url) + request = urllib.request.Request( + url, + headers={ + "Range": "bytes=0-0", + "User-Agent": "UIX-docs-release-check/1", + }, + ) + opener = urllib.request.build_opener(NoRedirect) + try: + with opener.open(request, timeout=10) as response: + if response.status not in (200, 206): + fail(f"site request returned HTTP {response.status}") + except (OSError, urllib.error.URLError, urllib.error.HTTPError) as error: + fail(f"could not reach translation site: {error}") + + +def load_registry(path: Path) -> list[dict[str, str]]: + try: + registry = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + warning("registry", f"could not read translation registry: {error}") + return [] + + if not isinstance(registry, dict) or registry.get("schema") != METADATA_SCHEMA: + warning("registry", f"{path} must use schema {METADATA_SCHEMA}") + return [] + languages = registry.get("languages") + if not isinstance(languages, list): + warning("registry", f"{path} must contain a languages array") + return [] + + entries: list[dict[str, str]] = [] + codes: set[str] = set() + for index, language in enumerate(languages, start=1): + entry_name = str(index) + try: + if not isinstance(language, dict): + fail(f"languages[{index}] must be an object") + code = language.get("code") + name = language.get("name") + url = language.get("url") + metadata_url = language.get("metadata_url") + if isinstance(code, str): + entry_name = code + if not isinstance(code, str) or not LANGUAGE_CODE.fullmatch(code): + fail(f"languages[{index}].code must be a lowercase ISO 639-1 code") + if code == "en": + fail("English is the canonical site and must not be in the registry") + if code in codes: + fail(f"languages[{index}].code duplicates {code!r}") + if not isinstance(name, str) or not name.strip(): + fail(f"languages[{index}].name must be a non-empty string") + entry = { + "code": code, + "name": name.strip(), + "url": validate_https_url(url, f"languages[{index}].url"), + "metadata_url": validate_https_url( + metadata_url, f"languages[{index}].metadata_url" + ), + } + except ValueError as error: + warning(entry_name, str(error)) + continue + codes.add(code) + entries.append(entry) + return entries + + +def load_site_config(path: Path) -> dict[str, str]: + try: + site = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + fail(f"could not read documentation site configuration: {error}") + + if not isinstance(site, dict) or site.get("schema") != METADATA_SCHEMA: + fail(f"{path} must use schema {METADATA_SCHEMA}") + language = site.get("language") + name = site.get("name") + if not isinstance(language, str) or not LANGUAGE_CODE.fullmatch(language): + fail(f"{path}.language must be a lowercase ISO 639-1 code") + if not isinstance(name, str) or not name.strip(): + fail(f"{path}.name must be a non-empty string") + return { + "language": language, + "name": name.strip(), + "site_url": validate_https_url(site.get("site_url"), f"{path}.site_url"), + "canonical_url": validate_https_url( + site.get("canonical_url"), f"{path}.canonical_url" + ), + } + + +def is_eligible(canonical: tuple[int, int, int], translation: tuple[int, int, int]) -> bool: + """Allow the current minor and one previous minor of the same major.""" + + canonical_major, canonical_minor, _ = canonical + translation_major, translation_minor, _ = translation + return ( + translation_major == canonical_major + and canonical_minor - 1 <= translation_minor <= canonical_minor + ) + + +def warning(code: str, message: str) -> None: + print(f"::warning title=Translation {code} excluded::{message}") + + +def accepted_languages( + entries: list[dict[str, str]], canonical_version: tuple[int, int, int] +) -> list[dict[str, str]]: + accepted: list[dict[str, str]] = [] + for entry in entries: + try: + check_site(entry["url"]) + metadata = fetch_metadata(entry["metadata_url"]) + if metadata.get("schema") != METADATA_SCHEMA: + fail(f"metadata schema must be {METADATA_SCHEMA}") + if metadata.get("project") != "uix": + fail("metadata project must be 'uix'") + if metadata.get("language") != entry["code"]: + fail("metadata language does not match the registry") + version = metadata.get("docs_version") + if not isinstance(version, str): + fail("metadata docs_version must be a semantic version") + translation_version = parse_version(version) + if not is_eligible(canonical_version, translation_version): + fail( + f"documentation version {version} is not within the supported " + "major/current-or-previous-minor range" + ) + except ValueError as error: + warning(entry["code"], str(error)) + continue + + accepted.append(entry) + print(f"Including translation {entry['code']} ({version}).") + return accepted + + +def yaml_string(value: str) -> str: + """JSON string syntax is also a safe, unambiguous YAML scalar.""" + + return json.dumps(value, ensure_ascii=False) + + +def render_alternates(site: dict[str, str], languages: list[dict[str, str]]) -> str: + lines = [" alternate:"] + all_languages: list[dict[str, str]] = [] + if site["language"] != "en": + all_languages.append( + {"name": "English", "url": site["canonical_url"], "code": "en"} + ) + all_languages.append( + {"name": site["name"], "url": site["site_url"], "code": site["language"]} + ) + known_codes = {site["language"]} + for language in languages: + if language["code"] not in known_codes: + all_languages.append(language) + known_codes.add(language["code"]) + for language in all_languages: + lines.extend( + ( + f" - name: {yaml_string(language['name'])}", + f" link: {yaml_string(language['url'])}", + f" lang: {yaml_string(language['code'])}", + ) + ) + return "\n".join(lines) + + +def render_config( + source: Path, output: Path, site: dict[str, str], version: str, languages: list[dict[str, str]] +) -> None: + content = source.read_text(encoding="utf-8") + site_url, site_url_count = re.subn( + r"(?m)^site_url: .* # UIX_RELEASE_SITE_URL$", + f"site_url: {yaml_string(site['site_url'])}", + content, + ) + language, language_count = re.subn( + r"(?m)^ language: .* # UIX_RELEASE_LANGUAGE$", + f" language: {yaml_string(site['language'])}", + site_url, + ) + alternate, alternate_count = re.subn( + r"(?m)^ alternate: \[\] # UIX_RELEASE_ALTERNATES$", + render_alternates(site, languages), + language, + ) + copyright, copyright_count = re.subn( + r'(?m)^copyright: ".*" # UIX_RELEASE_COPYRIGHT$', + f"copyright: {yaml_string(f'Documentation generated against UIX {version}')}", + alternate, + ) + if ( + site_url_count != 1 + or language_count != 1 + or alternate_count != 1 + or copyright_count != 1 + ): + fail("docs/mkdocs.yml release markers are missing or duplicated") + output.write_text(copyright, encoding="utf-8") + + +def write_metadata(path: Path, version: str, revision: str, site: dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "schema": METADATA_SCHEMA, + "project": "uix", + "language": site["language"], + "docs_version": version, + "source_revision": revision, + "site_url": site["site_url"], + "canonical_url": site["canonical_url"], + "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(), + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--source-revision", required=True) + parser.add_argument("--site", type=Path, default=Path("docs/site.json")) + parser.add_argument("--registry", type=Path, default=Path("docs/translations.json")) + parser.add_argument("--config", type=Path, default=Path("docs/mkdocs.yml")) + parser.add_argument("--output", type=Path, default=Path("docs/mkdocs.release.yml")) + parser.add_argument( + "--metadata-output", type=Path, default=Path("docs/source/uix-docs.json") + ) + args = parser.parse_args() + + try: + canonical_version = parse_version(args.version) + site = load_site_config(args.site) + entries = load_registry(args.registry) + languages = accepted_languages(entries, canonical_version) + render_config(args.config, args.output, site, args.version, languages) + write_metadata(args.metadata_output, args.version, args.source_revision, site) + except ValueError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/site.json b/docs/site.json new file mode 100644 index 00000000..ea9d5659 --- /dev/null +++ b/docs/site.json @@ -0,0 +1,7 @@ +{ + "schema": 1, + "language": "en", + "name": "English", + "site_url": "https://uix.lf.technology", + "canonical_url": "https://uix.lf.technology" +} diff --git a/docs/source/contributing.md b/docs/source/contributing.md index ee40cfb9..e32c40fb 100644 --- a/docs/source/contributing.md +++ b/docs/source/contributing.md @@ -58,6 +58,65 @@ Documentation is where every UIX user can contribute. As long as you have python - Documentation website will then be available at `http://localhost:8000` - You can run zensical at another bound ip address and/or port using `--dev-addr`. e.g. `zensical serve localhost:9000` to run on port 9000. +### External documentation translations + +Translations are hosted independently, rather than as translated Markdown in this repository. The canonical English documentation lists an external translation only when its published metadata confirms that it is current enough for the UIX version being released. + +#### Registering a translation + +First, fork this repository and translate the documentation in your fork. Configure its public documentation site in `docs/site.json`, then use the **Deploy MkDocs to GitHub Pages** workflow from the Actions tab to publish it. GitHub Pages must be enabled for the fork and configured to deploy from GitHub Actions. + +For a German translation hosted at `https://example.github.io/uix-de/`, the fork's `docs/site.json` would be: + +```json +{ + "schema": 1, + "language": "de", + "name": "Deutsch", + "site_url": "https://example.github.io/uix-de/", + "canonical_url": "https://uix.lf.technology" +} +``` + +The workflow reads this file, configures Zensical's language and site URL, writes the translation's `uix-docs.json`, and includes a footer identifying the UIX version the translated docs were generated against. A translation fork therefore uses the same workflow as the canonical documentation; no separate publishing setup is required. + +Once the translation site is publicly available, submit an upstream PR that adds one entry to the `languages` array in [`docs/translations.json`](https://github.com/Lint-Free-Technology/uix/blob/master/docs/translations.json): + +```json +{ + "schema": 1, + "languages": [ + { + "code": "de", + "name": "Deutsch", + "url": "https://docs.example.org/uix/de/", + "metadata_url": "https://docs.example.org/uix/de/uix-docs.json" + } + ] +} +``` + +- `code` must be a lowercase ISO 639-1 language code and must not be `en`. +- `name` is the language name shown to readers, ideally in that language. +- `url` is the translation's public home page. +- `metadata_url` is the location of the `uix-docs.json` file described below. + +Both URLs must be final public HTTPS URLs; redirects are not followed. This upstream PR must change only `docs/translations.json`; do not add translated Markdown, generated documentation, or build files to the canonical UIX repository. The documentation workflow will report a warning and leave the translation out of the selector until the translation site satisfies the checks. + +The translation site must publish `uix-docs.json` at the registered metadata URL. Its contract is: + +```json +{ + "schema": 1, + "project": "uix", + "language": "de", + "docs_version": "8.2.0", + "source_revision": "v8.2.0" +} +``` + +At release time, UIX checks both the registered site and metadata URLs, then includes translations only when their major version matches and their minor version is the current or immediately previous minor. Invalid registry entries and unavailable, malformed, future, or older translations are emitted as workflow warnings and omitted from the language selector; they never block publication of the English documentation. Translation sites should also display the UIX version their documentation was generated against in their footer. + ## Submitting pull requests - **DO NOT** include `uix.js` in your commits in a pull request. The resource file will be built on release. As UIX is an integration it can't use release assets as `uix.js` needs to be in the `custom_components/uix` folder. diff --git a/docs/translations.json b/docs/translations.json new file mode 100644 index 00000000..d278fc9f --- /dev/null +++ b/docs/translations.json @@ -0,0 +1,4 @@ +{ + "schema": 1, + "languages": [] +}