From 7697d865a39f49452e028771d5f4d3c9b0c5d511 Mon Sep 17 00:00:00 2001 From: Eric Muehlstein Date: Fri, 16 Oct 2026 23:23:06 -0500 Subject: [PATCH] site: add a Spanish translation of the landing and downloads pages The site's audience for the downloads page is Chicago Offline neighbours picking up a prebuilt codeplug, not developers, and a large share of Chicagoland is Spanish-dominant. The CLI stays English-only: its users are operators already reading YAML and CHIRP in English, and its messages are what gets pasted into issues. The Spanish tree mirrors the English one under site/es/, so relative links work the same from either side: - site/es/index.html is a hand-maintained translation of site/index.html. gen_radio_table.py now fills the radio table in every page listed in PAGES, translating only the handful of strings it emits itself (sr labels, the hw badge title, the codeplug link). --check covers both. - build_site_artifacts.py renders the downloads page once per language. The artifacts themselves are published once, under codeplugs/; the es/codeplugs/ page links back across to them. Source name/description come from an optional per-language "i18n" block in codeplug-sources.json and fall back to the English text. - Both pages carry a language switch in the header plus canonical and hreflang alternates; x-default is English. Radio names, format names, and channel/zone labels are deliberately not translated: those appear on the radio and must match what is used on-air. --- .gitignore | 3 +- site/codeplug-sources.json | 18 +- site/es/index.html | 592 +++++++++++++++++++++++++++++++++ site/index.html | 4 + site/style.css | 3 + tests/test_site_codeplugs.py | 74 +++++ tests/test_site_radio_table.py | 19 ++ tools/build_site_artifacts.py | 177 +++++++--- tools/gen_radio_table.py | 100 ++++-- 9 files changed, 924 insertions(+), 66 deletions(-) create mode 100644 site/es/index.html diff --git a/.gitignore b/.gitignore index 8283515..e2fae28 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ build/ .artifacts/ # Built by tools/build_site_artifacts.py from the source repositories. Codeplugs # are disposable artifacts, so CI regenerates them rather than serving a commit. -site/codeplugs/ \ No newline at end of file +site/codeplugs/ +site/*/codeplugs/ \ No newline at end of file diff --git a/site/codeplug-sources.json b/site/codeplug-sources.json index c3bfea9..71b9292 100644 --- a/site/codeplug-sources.json +++ b/site/codeplug-sources.json @@ -19,13 +19,23 @@ "", "Which file formats a profile produces is NOT listed here. It comes from", "the radio's row in site/radios.json, so a radio cannot advertise an", - "export column in the support table and then fail to offer the file." + "export column in the support table and then fail to offer the file.", + "", + "'i18n' holds per-language overrides of 'name' and 'description' for the", + "translated downloads pages; a language without an entry falls back to", + "the English text." ], "sources": [ { "id": "use-cases", "name": "Use-case starter codeplugs", "description": "Buy a supported radio and make it behave like the radio you wanted: a GMRS handheld, a PMR446 handheld, a MURS handheld, a marine VHF receiver, or all of them at once. Built from published ssrf-lite data only, with no personal DMR IDs. GMRS needs an FCC licence and the marine channels are receive-only; see the reference sheet in each download.", + "i18n": { + "es": { + "name": "Codeplugs iniciales por caso de uso", + "description": "Compra una radio compatible y haz que se comporte como la radio que querías: un portátil GMRS, un portátil PMR446, un portátil MURS, un receptor VHF marino, o todos a la vez. Compilados solo con datos publicados de ssrf-lite, sin IDs DMR personales. GMRS requiere una licencia de la FCC y los canales marinos son solo de recepción; consulta la hoja de referencia en cada descarga." + } + }, "repo": "https://github.com/Chicago-Offline/codeplugger", "profiles": [ "examples/profiles/baofeng_bf888s/gmrs.yml", @@ -40,6 +50,12 @@ "id": "chioff-shared", "name": "Chicago Offline community net", "description": "Shared GMRS and MURS channels for the Chicago Offline community net. Instance-agnostic: no personal DMR IDs, no per-operator settings.", + "i18n": { + "es": { + "name": "Red comunitaria de Chicago Offline", + "description": "Canales GMRS y MURS compartidos de la red comunitaria de Chicago Offline. Independientes de la instancia: sin IDs DMR personales, sin ajustes por operador." + } + }, "repo": "https://github.com/Chicago-Offline/chioff-codeplugger-profiles-shared", "checkout": "chioff-codeplugger-profiles-shared", "ssrf_roots": [ diff --git a/site/es/index.html b/site/es/index.html new file mode 100644 index 0000000..ac28eeb --- /dev/null +++ b/site/es/index.html @@ -0,0 +1,592 @@ + + + + + + + + + + + + + + + + + + codeplugger — un compilador de codeplugs para flotas de radios + + + + +
+ +
+ +
+
+
+
+

Código abierto · Chicago Offline

+

Programar una radio debería ser una compilación, no una sesión de CPS.

+

+ codeplugger combina datos de RF compartidos con tu perfil bajo + control de versiones para compilar un codeplug validado y neutral + de radio. Expórtalo, o escríbelo directamente en una radio compatible. +

+ +
+ logotipo de codeplugger: una radio portátil con un glifo de código en la pantalla y clavijas de enchufe en la base +
+
+ +
+
+

Cómo funciona

+

+ Los hechos y las preferencias se mantienen separados. Los datos de RF + describen lo que existe; un perfil describe lo que quieres en una + radio concreta. Todo lo intermedio es determinista y revisable. +

+
+
+

Datos SSRF

+

Planes de canales, repetidores y talkgroups públicos de SSRF-Lite, combinados con capas compartidas o privadas por ID de registro estable.

+
+
+

Perfil

+

Tu política en YAML: qué asignaciones, en qué orden, en qué zonas, con listas de escaneo, grupos RX, contactos y ajustes de la radio. Los perfiles pueden heredar unos de otros.

+
+
+

Resolver y validar

+

codeplugger une las entradas y comprueba cada canal contra las capacidades de la radio de destino: bandas, modos, longitud de nombres, límites de zonas y listas, capacidad dependiente del firmware.

+
+
+

Exportar o programar

+

Exporta CHIRP CSV o qdmr YAML; aplica un perfil a una línea base P4 decodificada para TOML; o usa un backend con confirmación para escribir en una radio compatible.

+
+
+
+
+ +
+
+

Codeplugs comunitarios precompilados

+

+ CI los compila a partir de perfiles comunitarios públicos y datos de + RF a medida que cambian las fuentes, y luego publica archivos de + importación, referencias imprimibles y sumas de verificación SHA-256. + No hace falta instalar codeplugger. +

+ +
+
+ +
+
+

Radios compatibles

+

+ La tabla combina las capacidades de cada radio con los detalles de + exportación y verificación en hardware registrados en el repositorio. + Su generador comprueba que cada entrada + radios/*/capabilities.json tenga una fila correspondiente. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RadioModosCHIRP CSV exportaciónImportar en qdmr YAMLImportar en p64tool TOMLImportar en CPS del fabricante archivos propietariosEscritura directa sin interfaz, a la radio
Baofeng DM-32 DM-32UVDMR · FM · AM RXNo compatibleCompatibleNo compatibleNo compatible dmrconfhw
Baofeng DM-1701 DM-1701 / RT84DMR · FMNo compatibleCompatibleNo compatibleNo compatible dmrconfhw
Retevis MateTalk P4 UHF DMRDMR · FMNo compatibleNo compatibleCompatibleNo compatible p64toolhw
Baofeng UV-5R Mini UV-17Pro familyFM · AM RXCompatibleNo compatibleNo compatibleNo compatible chirp-writer
Baofeng BF-C50 70 cm, no displayFMCompatibleNo compatibleNo compatibleNo compatible no CHIRP driver
Baofeng BF-888S UHF, no displayFMCompatibleNo compatibleNo compatibleNo compatible chirp-writer
Baofeng BF-1909 UHF, no displayFMCompatibleNo compatibleNo compatibleNo compatible chirp-writer
Baofeng W31E UHF, no displayFMCompatibleNo compatibleNo compatibleNo compatible chirp-writer
Ailunce HA2 Dual-band analogFM · AM RXCompatibleNo compatibleNo compatibleNo compatible chirp-writer
Retevis C64 Dual-band analogFMhwNo compatibleNo compatibleNo compatible chirp-writer
Retevis RT3S (OpenGD77) MD-UV380 family, OpenGD77DMR · FMNo compatibleCompatibleNo compatibleNo compatible dmrconfhw
Baofeng DM-1701 (OpenGD77) MD-UV380 family, OpenGD77DMR · FMNo compatibleCompatibleNo compatibleNo compatible dmrconf
Vero VR-N76 Dual-band, BluetoothFM · AM RXNo compatibleNo compatibleNo compatibleNo compatible benlink
BTECH UV-Pro Dual-band, BluetoothFM · AM RXNo compatibleNo compatibleNo compatibleNo compatible benlink
Yaesu FT-270R 2 m submersibleFM as VX-170No compatibleNo compatibleNo compatible chirp-writer
Yaesu FT-270R (MARS/CAP) TX-modded variantFM as VX-170No compatibleNo compatibleNo compatible chirp-writer
Yaesu FT-277R 70 cm submersibleFM as VX-177hwNo compatibleNo compatibleNo compatible chirp-writer
Yaesu FT-277R (MARS/CAP) TX-modded variantFM as VX-177No compatibleNo compatibleNo compatible chirp-writer
Yaesu FT-2800M 2 m mobileFMhwNo compatibleNo compatibleNo compatible chirp-writerhw
Yaesu FT-2800M (extended TX) TX-modded variantFMCompatibleNo compatibleNo compatibleNo compatible chirp-writer
Yaesu VX-6R Tri-band submersibleFM · AM RX as VX-6No compatibleNo compatibleNo compatible chirp-writer
TYT TH-9800 Quad-band mobileFM · AM RX experimental driverNo compatibleNo compatibleNo compatible chirp-writer
TYT TH-9800 (extended TX) TX-modded variantFM · AM RX experimental driverNo compatibleNo compatibleNo compatible chirp-writer
Quansheng UV-K5 (F4HWN) Quansheng UV-K5 (F4HWN)FM · AM RXCompatibleNo compatibleNo compatibleNo compatible chirp-writerhw
+
+

+ hw verificado en una radio física · + compatible hoy · + no aplicable o no implementado +

+
+
+

CHIRP CSV

+

Canales FM analógicos y AM de solo recepción en el orden del perfil, comprobados contra los límites de la radio. Importa y carga en CHIRP donde exista un controlador, o usa chirp-writer en las radios compatibles.

+
+
+

qdmr YAML

+

Codeplug extensible de qdmr con zonas, listas de escaneo, listas de grupos RX, contactos y varios IDs DMR. Los ajustes nativos de la radio pasan a través de extensions.qdmr.

+
+
+

p64tool TOML

+

Se aplica sobre una línea base de p64tool decode para que los ajustes no tocados sobrevivan, y luego se vuelve a codificar para la P4.

+
+
+

plan benlink

+

Un plan de canales declarativo para portátiles con protocolo Benshi, que no tienen CPS de escritorio ni controlador CHIRP: la única vía de entrada es Bluetooth. Las zonas se asignan a las regiones de la radio, y las regiones que el plan omite nunca se escriben.

+
+
+

Escritura directa

+

Las escrituras requieren confirmación explícita y comprobaciones de seguridad propias de cada backend. La copia de seguridad y la relectura varían según el backend; las operaciones pueden registrarse. Backends: dmrconf, p64tool, chirp-writer y benlink por Bluetooth.

+
+
+
+ Planificado: ampliar la cobertura de radios y formatos de exportación sin cambiar la resolución central de perfiles. +
+
+
+ +
+
+

Funciones

+

Hecho para quienes mantienen más de una radio coherente y quieren saber exactamente qué entró en cada una.

+
+
+

Compilaciones deterministas

+

Las mismas entradas siempre producen el mismo codeplug, hasta un SHA-256 estable. Los archivos generados son artefactos desechables, nunca una fuente de datos.

+
+
+

Validación de capacidades

+

Bandas, rangos TX frente a solo RX, modos, ancho de banda, longitud de nombres, límites de zonas y listas, y capacidad de contactos dependiente del firmware, clasificados como aviso / advertencia / crítico, con todos los críticos informados en una sola ejecución.

+
+
+

Escrituras con confirmación

+

Confirmación explícita, comprobaciones de identidad propias de cada backend y registros de escritura; verificación previa y relectura donde esté disponible.

+
+
+

Zonas, listas de escaneo, grupos RX, contactos

+

Los talkgroups son hechos SSRF; la pertenencia y el contacto TX, grupo RX y lista de escaneo por asignación son política del perfil, todo aplicado contra los límites de la radio.

+
+
+

Herencia de perfiles

+

Comparte un perfil base y deriva variantes con extends, zones_only y omit_zones. Un perfil base que no nombra ninguna radio es reutilizable en cualquier radio con capacidad para él. Revisa el resultado combinado con --print-merged-profile.

+
+
+

IDs DMR de identidad múltiple

+

Una instancia de radio puede llevar varios IDs DMR; los perfiles vinculan uno por zona o por asignación y los exportadores emiten solo las identidades realmente usadas.

+
+
+

Registro de flota y simulación

+

Un registro de instancias asocia radios físicas a perfiles. codeplugger-fleet resuelve y valida cada radio e informa de su estado antes de escribir nada.

+
+
+

Hechos de canal derivados

+

Ancho de banda a partir del designador de emisión de la UIT, modo AM para asignaciones de banda aérea de solo recepción, nombres de pantalla sobrescritos desde capas.

+
+
+

Artefactos de referencia

+

Referencias de canales en HTML y Markdown más registros de operación por instancia de radio, para que lo que hay en la radio esté siempre documentado.

+
+
+

Extensiones con espacio de nombres

+

Los ajustes nativos de cada exportador viven bajo extensions.<backend> a nivel de perfil o de asignación, manteniendo el esquema central neutral de radio.

+
+
+
+
+ +
+
+

Cómo fluyen los datos

+

+ Los datos de RF públicos y las capas alimentan una compilación basada en perfiles; exporta un archivo o programa una radio compatible. +

+
+flowchart LR
+  rf["📡 rf-survey"] -- "informes de recepción" --> ssrf["🗃 ssrf-lite"]
+  ov["capas · compartidas + privadas"] --> ssrf
+  ssrf -- "datos de RF validados" --> cp["⚙ codeplugger"]
+  prof["👤 perfiles"] --> cp
+  cp -- "exportar: CHIRP CSV · qdmr YAML · P4 TOML · plan benlink" --> files(["archivos de radio"])
+  files --> radios(["📻 radios"])
+  cp -- "escribir: dmrconf · p64tool · chirp-writer · benlink" --> radios
+  click rf "https://chicago-offline.github.io/rf-survey/" _blank
+  click ssrf "https://chicago-offline.github.io/ssrf-lite/" _blank
+  click cp "https://chicago-offline.github.io/codeplugger/" _blank
+  click prof "https://chicago-offline.github.io/chioff-codeplugger-profiles-shared/" _blank
+        
+
+
+ +
+ +
+ +
+
+

Inicio rápido

+

Clona codeplugger y usa uv. La instalación trae sus propios datos de RF, así que un caso de uso no necesita nada más; para tus propios perfiles conviene tener ssrf-lite y un repositorio de perfiles clonados al lado.

+
+
+

Programar una radio nueva

+
# qué es compatible, y qué puede ir en cada radio
+uv run codeplugger radios
+uv run codeplugger use-cases
+
+# compilar, sin tocar el hardware
+uv run codeplugger build \
+  --radio baofeng_bf888s --use-case gmrs
+
+# o los canales de un grupo comunitario, por nombre
+uv run codeplugger build \
+  --radio baofeng_bf888s --profile chioff
+
+# luego escribirlo, con la confirmación del backend
+uv run codeplugger program \
+  --radio baofeng_bf888s --use-case gmrs \
+  --port /dev/cu.usbserial-10 --confirm
+
+
+

Validar un perfil

+
uv run codeplugger-profile ruta/al/perfil.yml \
+  --ssrf-root ../ssrf-lite/ssrf \
+  --ssrf-root ../chioff-ssrf-shared/ssrf
+
+
+

Exportar para una radio

+
# radios analógicas: importar en CHIRP
+uv run codeplugger-profile perfil.yml \
+  --ssrf-root ../ssrf-lite/ssrf \
+  --output-format chirp-csv > salida.csv
+
+# DM-32: qdmr YAML, luego escribir con dmrconf
+uv run codeplugger-profile perfil.yml \
+  --ssrf-root ../ssrf-lite/ssrf \
+  --output-format qdmr-yaml > codeplug.yaml
+
+
+

Comprobar toda la flota

+
uv run codeplugger-fleet \
+  --instance-registry ../mis-perfiles/instances.yml \
+  --profiles-root ../mis-perfiles/profiles \
+  --ssrf-root ../ssrf-lite/ssrf
+
+
+

Un perfil mínimo

+
version: "0.1"
+id: "dm32_reference"
+name: "DM-32 reference"
+radio: "baofeng_dm32"
+radio_instance: "dm32_green_01"
+zones:
+  - id: "community"
+    name: "Community"
+    assignments:
+      - "asg_chio_all"
+      - "asg_chio_road"
+      - "asg_chio_repeater"
+
+
+
+
+
+ + + + + + diff --git a/site/index.html b/site/index.html index 660ce8a..5da0abd 100644 --- a/site/index.html +++ b/site/index.html @@ -5,6 +5,9 @@ + + + @@ -27,6 +30,7 @@ Ecosystem Quick start + Español GitHub ↗ diff --git a/site/style.css b/site/style.css index dbb4384..03000e0 100644 --- a/site/style.css +++ b/site/style.css @@ -74,6 +74,9 @@ header nav a { header nav a:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; } .repo-link { margin-left: auto; color: var(--text-dim); text-decoration: none; white-space: nowrap; } .repo-link:hover { color: var(--accent); } +.lang { margin-left: auto; color: var(--text-dim); text-decoration: none; white-space: nowrap; font-size: 0.92rem; } +.lang:hover { color: var(--accent); } +.lang + .repo-link { margin-left: 0; } /* hero */ .hero { padding: clamp(3rem, 8vw, 6rem) 0 clamp(2.5rem, 6vw, 4rem); } diff --git a/tests/test_site_codeplugs.py b/tests/test_site_codeplugs.py index de14c5e..897751e 100644 --- a/tests/test_site_codeplugs.py +++ b/tests/test_site_codeplugs.py @@ -184,3 +184,77 @@ def test_every_configured_radio_is_publishable(): (REPO_ROOT / "radios" / radio_id / "capabilities.json").read_text() ) assert build.formats_for_radio(radio_id, rows[radio_id], caps) + + +def test_source_translations_only_override_known_fields(): + for source in build.load_sources(): + for lang, text in source.get("i18n", {}).items(): + assert lang in build.STRINGS, f"{source['id']}: no page for {lang!r}" + assert set(text) <= {"name", "description"}, f"{source['id']}: {lang}" + + +def test_every_language_has_every_format_label(): + suffixes = {s for _fmt, s in build.SOURCE_FORMATS.values()} + suffixes |= {".benlink.json", ".html", ".resolved.json"} + for lang, s in build.STRINGS.items(): + assert set(s["formats"]) >= suffixes, lang + + +_MANIFEST = { + "generated_at": "2026-01-01 00:00 UTC", + "sources": [ + { + "id": "shared", + "name": "Shared net", + "description": "English blurb.", + "i18n": {"es": {"name": "Red compartida", "description": "Texto en español."}}, + "repo": "https://example.invalid/profiles", + "revisions": {"profiles": "abcdef1234567890"}, + "profiles": [ + { + "profile_id": "first", + "profile_name": "First", + "radio": "radio_a", + "radio_name": "Radio A", + "dir": "shared/radio_a/first", + "zones": 1, + "channels": 2, + "resolved_sha256": "0" * 64, + "files": [{"name": "first.csv", "sha256": "f" * 64, "bytes": 1}], + } + ], + } + ], +} + + +def test_english_page_links_files_beside_it(): + page = build.render_page(_MANIFEST, "en") + assert '' in page + assert 'href="shared/radio_a/first/first.csv"' in page + assert 'href="shared/radio_a/first/SHA256SUMS"' in page + assert "1 zone · 2 channels" in page + assert "Shared net" in page and "Red compartida" not in page + assert 'href="../es/codeplugs/"' in page + assert 'hreflang="x-default" href="https://codeplugger.chicagooffline.com/codeplugs/"' in page + + +def test_spanish_page_links_back_into_the_english_files(): + """Only one copy of the artifacts is published; es/ links across to it.""" + page = build.render_page(_MANIFEST, "es") + assert '' in page + assert 'href="../../codeplugs/shared/radio_a/first/first.csv"' in page + assert 'href="../../codeplugs/shared/radio_a/first/SHA256SUMS"' in page + assert 'href="../../style.css"' in page + assert "1 zona · 2 canales" in page + assert "Red compartida" in page and "Texto en español." in page + assert "English blurb." not in page + assert 'href="../../codeplugs/"' in page # language switch + assert "Importar en CHIRP" in page + + +def test_language_without_source_translation_falls_back_to_english(): + manifest = json.loads(json.dumps(_MANIFEST)) + manifest["sources"][0].pop("i18n") + page = build.render_page(manifest, "es") + assert "Shared net" in page and "English blurb." in page diff --git a/tests/test_site_radio_table.py b/tests/test_site_radio_table.py index 6866230..51d2157 100644 --- a/tests/test_site_radio_table.py +++ b/tests/test_site_radio_table.py @@ -35,6 +35,25 @@ def test_committed_index_html_is_generated(): ) +@pytest.mark.parametrize("lang", sorted(gen.PAGES)) +def test_every_language_page_is_generated(lang): + """Each translated landing page carries a current table in its language.""" + path = gen.PAGES[lang] + current = path.read_text() + assert current == gen.render_index(current, lang=lang), ( + f"{path.relative_to(REPO_ROOT)} is stale -- run: python tools/gen_radio_table.py" + ) + assert f'' in current + assert gen.STRINGS[lang]["hw"] in current + + +def test_spanish_table_has_no_english_generator_strings(): + tbody = gen.build_tbody(lang="es") + assert "Not supported" not in tbody + assert "Verified on a physical radio" not in tbody + assert gen.STRINGS["es"]["not_supported"] in tbody + + def test_every_radio_has_a_site_row(): caps = gen.load_capabilities() listed = {row["id"] for row in gen.load_rows()} diff --git a/tools/build_site_artifacts.py b/tools/build_site_artifacts.py index a034db4..1c0bcf6 100644 --- a/tools/build_site_artifacts.py +++ b/tools/build_site_artifacts.py @@ -278,6 +278,8 @@ def build_source(source: dict, checkout_root: Path, radio_rows: dict, out_root: "id": source_id, "name": source["name"], "description": source.get("description"), + # Optional per-language overrides of name/description, keyed by lang. + "i18n": source.get("i18n", {}), "repo": source.get("repo"), "revisions": revisions, "profiles": built, @@ -288,32 +290,115 @@ def build_source(source: dict, checkout_root: Path, radio_rows: dict, out_root: # downloads page # -------------------------------------------------------------------------- +SITE_URL = "https://codeplugger.chicagooffline.com/" + +# One downloads page per language. ``page`` is the path under the site root; +# the Spanish tree mirrors the English one under ``es/`` so relative links +# between the two index pages work the same way in both. +PAGES = { + "en": "codeplugs/", + "es": "es/codeplugs/", +} + +STRINGS = { + "en": { + "switch": ("es", "Español"), + "title": "Community codeplugs — codeplugger", + "description": ( + "Prebuilt community codeplugs compiled by codeplugger. Download a " + "file and import it -- no clone, no install." + ), + "generated": "Generated", + "heading": "Community codeplugs", + "lead": ( + "Prebuilt and validated by CI from public RF data and public profiles. " + "Download the file for your radio and import it — no clone, no " + "install. These are build artifacts: they are regenerated from source " + "on every change, so program your radio from a fresh download rather " + "than editing one." + ), + "profiles_link": "Profiles ↗", + "built_from": "Built from", + "file": "File", + "purpose": "What it is for", + "resolved_sha": "Resolved codeplug SHA-256", + "zone": ("zone", "zones"), + "channel": ("channel", "channels"), + "formats": { + ".csv": ("CHIRP CSV", "Import in CHIRP, then upload to the radio."), + ".qdmr.yaml": ("qdmr YAML", "Open in qdmr, or write with dmrconf."), + ".benlink.json": ("benlink plan", "Region plan for the benlink backend."), + ".html": ("Printable reference", "Channel list to print or keep on a phone."), + ".resolved.json": ( + "Resolved codeplug", + "Radio-neutral model the hash is taken over.", + ), + }, + }, + "es": { + "switch": ("en", "English"), + "title": "Codeplugs comunitarios — codeplugger", + "description": ( + "Codeplugs comunitarios precompilados por codeplugger. Descarga un " + "archivo e impórtalo: sin clonar, sin instalar." + ), + "generated": "Generado", + "heading": "Codeplugs comunitarios", + "lead": ( + "Precompilados y validados por CI a partir de datos de RF públicos y " + "perfiles públicos. Descarga el archivo para tu radio e impórtalo: sin " + "clonar, sin instalar. Son artefactos de compilación: se regeneran " + "desde la fuente con cada cambio, así que programa tu radio desde una " + "descarga reciente en lugar de editar una." + ), + "profiles_link": "Perfiles ↗", + "built_from": "Compilado desde", + "file": "Archivo", + "purpose": "Para qué sirve", + "resolved_sha": "SHA-256 del codeplug resuelto", + "zone": ("zona", "zonas"), + "channel": ("canal", "canales"), + "formats": { + ".csv": ("CHIRP CSV", "Importar en CHIRP y luego cargar en la radio."), + ".qdmr.yaml": ("qdmr YAML", "Abrir en qdmr o escribir con dmrconf."), + ".benlink.json": ("plan benlink", "Plan de regiones para el backend benlink."), + ".html": ( + "Referencia imprimible", + "Lista de canales para imprimir o guardar en el teléfono.", + ), + ".resolved.json": ( + "Codeplug resuelto", + "Modelo neutral de radio sobre el que se calcula el hash.", + ), + }, + }, +} def esc(value: Any) -> str: return html.escape(str(value if value is not None else "")) -def plural(count: int, noun: str) -> str: - return f"{count} {noun}" if count == 1 else f"{count} {noun}s" +def plural(count: int, noun: str | tuple[str, str]) -> str: + if isinstance(noun, str): + noun = (noun, noun + "s") + return f"{count} {noun[0]}" if count == 1 else f"{count} {noun[1]}" -FORMAT_LABELS = { - ".csv": ("CHIRP CSV", "Import in CHIRP, then upload to the radio."), - ".qdmr.yaml": ("qdmr YAML", "Open in qdmr, or write with dmrconf."), - ".benlink.json": ("benlink plan", "Region plan for the benlink backend."), - ".html": ("Printable reference", "Channel list to print or keep on a phone."), - ".resolved.json": ("Resolved codeplug", "Radio-neutral model the hash is taken over."), -} +def rel_to_root(page: str) -> str: + """Relative path from a page directory back to the site root.""" + return "../" * page.count("/") -def render_profile_card(entry: dict) -> str: +def render_profile_card(entry: dict, lang: str = "en", files: str = "") -> str: + s = STRINGS[lang] + base = f"{files}{esc(entry['dir'])}" rows = [] for f in entry["files"]: suffix = f["name"][len(entry["profile_id"]):] - label, blurb = FORMAT_LABELS.get(suffix, (suffix, "")) + label, blurb = s["formats"].get(suffix, (suffix, "")) rows.append( "" - f'{esc(label)}' + f'{esc(label)}' f"{esc(blurb)}" f'{esc(f["sha256"][:16])}…' "" @@ -322,72 +407,84 @@ def render_profile_card(entry: dict) -> str: f'
' f'

{esc(entry["profile_name"])}

' f'

{esc(entry["radio_name"] or entry["radio"])} · ' - f'{plural(entry["zones"], "zone")} · ' - f'{plural(entry["channels"], "channel")} · ' + f'{plural(entry["zones"], s["zone"])} · ' + f'{plural(entry["channels"], s["channel"])} · ' f'{esc(entry["profile_id"])}

' '
' - "" + f"" "" + "".join(rows) + "
FileWhat it is forSHA-256{s['file']}{s['purpose']}SHA-256
" - '

Resolved codeplug SHA-256 ' + f'

{s["resolved_sha"]} ' f'{esc(entry["resolved_sha256"])} · ' - f'SHA256SUMS

' + f'SHA256SUMS

' "
" ) -def render_page(manifest: dict) -> str: +def render_page(manifest: dict, lang: str = "en") -> str: + s = STRINGS[lang] + page = PAGES[lang] + root = rel_to_root(page) + # The download files live only under the English directory. + files = "" if lang == "en" else f"{root}{PAGES['en']}" + other_lang, other_label = s["switch"] + sections = [] for source in manifest["sources"]: + text = {**source, **source.get("i18n", {}).get(lang, {})} revisions = " · ".join( f"{esc(name)}@{esc((rev or 'unknown')[:12])}" for name, rev in sorted(source["revisions"].items()) ) repo = source.get("repo") link = ( - f' Profiles ↗' + f' {s["profiles_link"]}' if repo else "" ) sections.append( f'
' - f'

{esc(source["name"])}

' - f'

{esc(source.get("description"))}{link}

' + f'

{esc(text["name"])}

' + f'

{esc(text.get("description"))}{link}

' '
' - + "".join(render_profile_card(p) for p in source["profiles"]) + + "".join(render_profile_card(p, lang, files) for p in source["profiles"]) + "
" - f'

Built from {revisions}

' + f'

{s["built_from"]} {revisions}

' "
" ) + alternates = "\n".join( + f' ' + for code, path in PAGES.items() + ) return f""" - + - - Community codeplugs — codeplugger - - + + +{alternates} + + {s["title"]} + +
-

Generated {esc(manifest["generated_at"])}

-

Community codeplugs

+

{s["generated"]} {esc(manifest["generated_at"])}

+

{s["heading"]}

- Prebuilt and validated by CI from public RF data and public profiles. - Download the file for your radio and import it — no clone, no - install. These are build artifacts: they are regenerated from source - on every change, so program your radio from a fresh download rather - than editing one. + {s["lead"]}

{"".join(sections)} @@ -434,7 +531,13 @@ def main() -> int: (args.out / "index.json").write_text( json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) - (args.out / "index.html").write_text(render_page(manifest), encoding="utf-8") + # --out is the English codeplugs directory; the other languages' pages + # sit beside it at //codeplugs/ and link back into it. + site_root = args.out.parent + for lang, page in PAGES.items(): + page_dir = args.out if lang == "en" else site_root / page + page_dir.mkdir(parents=True, exist_ok=True) + (page_dir / "index.html").write_text(render_page(manifest, lang), encoding="utf-8") for source in manifest["sources"]: for entry in source["profiles"]: diff --git a/tools/gen_radio_table.py b/tools/gen_radio_table.py index b7029e1..2dec2c4 100644 --- a/tools/gen_radio_table.py +++ b/tools/gen_radio_table.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate the 'Supported radios' table in site/index.html. +"""Generate the 'Supported radios' table in site/index.html and site/es/index.html. The row set is authoritative from ``radios/*/capabilities.json``. Presentation detail that capabilities.json deliberately does not model (vendor blurb, 'hw' @@ -35,13 +35,43 @@ INDEX_HTML = SITE_DIR / "index.html" RADIOS_JSON = SITE_DIR / "radios.json" +# Every landing page carrying the table, by language. Each is hand-written +# copy around the same generated tbody; only the handful of strings the +# generator itself emits are translated here. +PAGES = { + "en": INDEX_HTML, + "es": SITE_DIR / "es" / "index.html", +} + +STRINGS = { + "en": { + "hw": "Verified on a physical radio", + "supported": "Supported", + "not_supported": "Not supported", + "codeplug": "Codeplug", + "codeplugs": "{n} codeplugs", + }, + "es": { + "hw": "Verificado en una radio física", + "supported": "Compatible", + "not_supported": "No compatible", + "codeplug": "Codeplug", + "codeplugs": "{n} codeplugs", + }, +} + BEGIN_MARKER = "" END_MARKER = "" ROW_INDENT = " " * 14 CELL_INDENT = " " * 16 -HW_BADGE = 'hw' + +def hw_badge(lang: str = "en") -> str: + return f'hw' + + +HW_BADGE = hw_badge("en") # Backends that a capabilities block implies. If a radio declares one of these # blocks it is the backend, and site/radios.json is not allowed to disagree. @@ -206,7 +236,8 @@ def normalize_cell(value, *, where: str) -> dict: raise GenError(f"{where}: cell must be a bool or an object, got {type(value).__name__}") -def render_cell(cell: dict) -> str: +def render_cell(cell: dict, lang: str = "en") -> str: + s = STRINGS[lang] supported = cell["supported"] cls = "yes" if supported else "no" glyph = "✓" if supported else "—" @@ -215,28 +246,32 @@ def render_cell(cell: dict) -> str: if cell["note"]: body += f' {cell["note"]}' if cell["hw"]: - body += HW_BADGE + body += hw_badge(lang) if not cell["note"] and not cell["hw"]: - label = "Supported" if supported else "Not supported" + label = s["supported"] if supported else s["not_supported"] body += f'{label}' return f'{body}' -def render_codeplug_link(entries: list[dict]) -> str: +def render_codeplug_link(entries: list[dict], lang: str = "en") -> str: """Link a radio to the codeplugs built for it on the downloads page. The anchor is the profile ID, which is the ``id`` the downloads page gives each card. Several codeplugs land on the first one; they sit together in - the same source section, so the rest are a scroll away. + the same source section, so the rest are a scroll away. The href is + relative, so from ``es/index.html`` it lands on the Spanish downloads page. """ + s = STRINGS[lang] anchor = entries[0]["profile_id"] - label = "Codeplug" if len(entries) == 1 else f"{len(entries)} codeplugs" + label = s["codeplug"] if len(entries) == 1 else s["codeplugs"].format(n=len(entries)) return f'{label}' -def render_row(cap: dict, row: dict, codeplugs: dict[str, list[dict]]) -> list[str]: +def render_row( + cap: dict, row: dict, codeplugs: dict[str, list[dict]], lang: str = "en" +) -> list[str]: radio_id = cap["id"] vendor = row.get("vendor") if not vendor: @@ -283,15 +318,15 @@ def render_row(cap: dict, row: dict, codeplugs: dict[str, list[dict]]) -> list[s name = cap["name"] entries = codeplugs.get(radio_id) - download = f" {render_codeplug_link(entries)}" if entries else "" + download = f" {render_codeplug_link(entries, lang)}" if entries else "" lines = [ f"{ROW_INDENT}", f'{CELL_INDENT}{name} ' f'{vendor}{download}', f'{CELL_INDENT}{derive_modes(cap)}', ] - lines += [CELL_INDENT + render_cell(cells[c]) for c in ARTIFACT_COLUMNS] - lines.append(CELL_INDENT + render_cell(headless)) + lines += [CELL_INDENT + render_cell(cells[c], lang) for c in ARTIFACT_COLUMNS] + lines.append(CELL_INDENT + render_cell(headless, lang)) lines.append(f"{ROW_INDENT}") return lines @@ -301,7 +336,7 @@ def render_row(cap: dict, row: dict, codeplugs: dict[str, list[dict]]) -> list[s # -------------------------------------------------------------------------- -def build_tbody(codeplugs: dict[str, list[dict]] | None = None) -> str: +def build_tbody(codeplugs: dict[str, list[dict]] | None = None, lang: str = "en") -> str: caps = load_capabilities() rows = load_rows() codeplugs = codeplugs or {} @@ -339,21 +374,23 @@ def build_tbody(codeplugs: dict[str, list[dict]] | None = None) -> str: lines: list[str] = [] for row in rows: - lines += render_row(caps[row["id"]], row, codeplugs) + lines += render_row(caps[row["id"]], row, codeplugs, lang) return "\n".join(lines) -def render_index(current: str, codeplugs: dict[str, list[dict]] | None = None) -> str: +def render_index( + current: str, codeplugs: dict[str, list[dict]] | None = None, lang: str = "en" +) -> str: try: head, rest = current.split(BEGIN_MARKER, 1) _, tail = rest.split(END_MARKER, 1) except ValueError as exc: raise GenError( - f"{INDEX_HTML} is missing the {BEGIN_MARKER} / {END_MARKER} markers" + f"{PAGES[lang]} is missing the {BEGIN_MARKER} / {END_MARKER} markers" ) from exc return ( - f"{head}{BEGIN_MARKER}\n{build_tbody(codeplugs)}\n{ROW_INDENT}{END_MARKER}{tail}" + f"{head}{BEGIN_MARKER}\n{build_tbody(codeplugs, lang)}\n{ROW_INDENT}{END_MARKER}{tail}" ) @@ -377,25 +414,34 @@ def main(argv: list[str] | None = None) -> int: parser.error("--check compares against the committed table, which has no links") codeplugs = load_codeplugs(args.codeplugs) if args.codeplugs else None - current = INDEX_HTML.read_text() - updated = render_index(current, codeplugs) + stale = [] + rewrote = [] + for lang, path in PAGES.items(): + current = path.read_text() + updated = render_index(current, codeplugs, lang) + if current == updated: + continue + rel = path.relative_to(REPO_ROOT) + if args.check: + stale.append(str(rel)) + else: + path.write_text(updated) + rewrote.append(str(rel)) if args.check: - if current != updated: + if stale: print( - "site/index.html is stale -- run: python tools/gen_radio_table.py", + f"{', '.join(stale)} stale -- run: python tools/gen_radio_table.py", file=sys.stderr, ) return 1 - print("site/index.html radio table is up to date") + print("site radio tables are up to date") return 0 - if current == updated: - print("site/index.html radio table already up to date") + if not rewrote: + print("site radio tables already up to date") return 0 - - INDEX_HTML.write_text(updated) - print(f"rewrote radio table in {INDEX_HTML.relative_to(REPO_ROOT)}") + print(f"rewrote radio table in {', '.join(rewrote)}") return 0