diff --git a/docs/legal-archive/README.md b/docs/legal-archive/README.md new file mode 100644 index 0000000..6531452 --- /dev/null +++ b/docs/legal-archive/README.md @@ -0,0 +1,53 @@ +# Legal archive + +Frozen snapshots of the Terms of Service, Privacy Policy, and Impressum at +each version. Kept so we can always answer "what did the Terms / Privacy +text say on a given date" without digging through git blame. + +## Why + +- **Terms**: users accept a specific version at registration + (`users.terms_accepted_at` + `users.terms_version`). The version string + is the date in this folder — so there is always a file here matching + every value that exists in that column. +- **Privacy**: users don't "accept" a privacy policy, but GDPR Art. 13/14 + requires us to tell users how their data is processed *at the time* it's + processed. If a regulator or user asks what the policy said on + YYYY-MM-DD, this folder answers. +- **Impressum**: less legally critical, but free to snapshot for symmetry. + +## When to add a file + +Whenever any of these three texts change materially, run the snapshot step +as part of the change: + +1. Bump `TERMS_VERSION` / `PRIVACY_LAST_UPDATED` in + [`apps/journal/app/lib/legal.ts`](../../apps/journal/app/lib/legal.ts) + to today's date. +2. Re-render the affected page(s) into this folder as + `-YYYY-MM-DD.md`. +3. Commit the bump **and** the new snapshot in the same PR. + +A trivial wording tweak or typo fix does not need a new snapshot; only +changes that affect meaning / behaviour / purposes / third parties / legal +basis / retention / etc. + +## File naming + +`-YYYY-MM-DD.md` where `` is one of `terms`, `privacy`, +`imprint`, and the date matches the `Last updated` line in the +corresponding legal page on the day the snapshot was taken. + +## How to render + +The snapshots are plain markdown extracted from the TSX source by +stripping JSX tags / attributes and resolving the `operator.*` +placeholders. Same approach as the pbcopy export used during legal +reviews. One-liner (from repo root): + +```bash +python3 scripts/render-legal.py > docs/legal-archive/-YYYY-MM-DD.md +``` + +(If `scripts/render-legal.py` doesn't exist yet, the extraction logic is +in the commit that seeded this directory — see PR history.) diff --git a/docs/legal-archive/imprint-2026-04-19.md b/docs/legal-archive/imprint-2026-04-19.md new file mode 100644 index 0000000..670e0f7 --- /dev/null +++ b/docs/legal-archive/imprint-2026-04-19.md @@ -0,0 +1,83 @@ +# Impressum — trails.cool + +Impressum / Legal Notice + +Die deutsche Fassung ist maßgeblich. / The German version is authoritative. + +Anbieter nach § 5 TMG + +Ullrich Schäfer + +Mehringdamm 87 + +10965 Berlin + +Germany + +Kontakt + +E-Mail: + +legal@trails.cool + +Inhaltlich verantwortlich nach § 18 Abs. 2 MStV + +Ullrich Schäfer + +Mehringdamm 87 + +10965 Berlin + +Streitbeilegung + +Die Europäische Kommission stellt eine Plattform zur +Online-Streitbeilegung (OS) bereit: + +https://ec.europa.eu/consumers/odr + +. + +Wir sind nicht bereit oder verpflichtet, an +Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle +teilzunehmen. + +Alpha-Status + +trails.cool befindet sich in aktiver Entwicklung (Alpha). Inhalte, +Funktionen und Daten können sich jederzeit ändern oder entfallen. + +English version + +Service provider (§ 5 TMG) + +Ullrich Schäfer + +Mehringdamm 87 + +10965 Berlin + +Germany + +Contact + +Email: + +legal@trails.cool + +Content responsibility (§ 18 (2) MStV) + +Ullrich Schäfer, address as above. + +Dispute resolution + +The European Commission provides an online dispute resolution platform at + +https://ec.europa.eu/consumers/odr + +. We are not willing or obliged to participate in dispute resolution +proceedings before a consumer arbitration board. + +Alpha status + +trails.cool is under active development (alpha). Features and data +may change or be removed at any time. diff --git a/docs/legal-archive/privacy-2026-04-19.md b/docs/legal-archive/privacy-2026-04-19.md new file mode 100644 index 0000000..c8e9faa --- /dev/null +++ b/docs/legal-archive/privacy-2026-04-19.md @@ -0,0 +1,358 @@ +# Privacy Policy — trails.cool + +Datenschutzerklärung / Privacy Policy + +Stand / Last updated: 2026-04-19. Die deutsche Fassung ist maßgeblich. +The German version is authoritative; English summaries follow each +section. + +1. Verantwortlicher + +Verantwortlich für die Datenverarbeitung im Sinne der DSGVO ist: + +Ullrich Schäfer + +Mehringdamm 87 + +10965 Berlin + +Germany + +E-Mail: + +legal@trails.cool + +English. +Data controller under GDPR is the party named +above. Contact for any data-protection matter: legal@trails.cool. + +2. Erhobene Daten und Zwecke + +Wir verarbeiten nur die Daten, die für den Betrieb der jeweiligen +Funktion erforderlich sind. trails.cool besteht aus zwei Teilen: +dem +Journal +(trails.cool, mit Konto) und dem + +Planner +(planner.trails.cool, anonym). + +Kontodaten (Journal): +E-Mail-Adresse, +Benutzername, Anzeigename, Passkey-Public-Key. Zweck: +Kontoverwaltung und Anmeldung. + +Nutzerinhalte (Journal): +Routen (GPX-Daten, +Geometrie, Titel, Beschreibung) und Aktivitäten (Titel, +Beschreibung, Datum, Verknüpfung zu Routen). Zweck: Speicherung +und Anzeige innerhalb des Dienstes. + +Anmeldedaten (Journal): +kurzlebige Magic-Link-Token +(zur E-Mail-basierten Anmeldung). Zweck: Authentifizierung. + +Sitzungscookie (Journal): +eine zufällige +Sitzungs-ID nach dem Einloggen. Zweck: Authentifizierung während +der Sitzung. + +Planner-Sitzungsdaten: +anonyme Sitzungs-ID, +kollaborativer Zustand (Wegpunkte, Notizen). Keine Zuordnung zu +einer Person. Zweck: gemeinsames Planen von Routen. + +Server-Logfiles: +IP-Adresse, Zeitstempel, +HTTP-Methode, Pfad, Statuscode, User-Agent. Zweck: Sicherheit, +Betrieb, Fehlersuche. Details siehe Abschnitt 4. + +Fehlerdaten (Sentry): +Stacktraces, +Fehlermeldungen, Browser-/OS-Information aus dem User-Agent, +Performance-Metriken. Bei eingeloggten Journal-Nutzer:innen +zusätzlich die Nutzer-ID (keine E-Mail, kein Benutzername). +IP-Adressen werden nicht aktiv gespeichert, ebenso keine Cookies +und keine Formulareingaben. + +English. +The Journal stores only what you provide (account +details and your own routes/activities) plus short-lived auth +artefacts. The Planner is anonymous and holds only ephemeral +session state. Server logs and Sentry error data are covered +separately below. + +3. Rechtsgrundlagen + +Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung): + +Kontoführung, Anmeldung per Passkey oder Magic Link, Speicherung +und Anzeige der von Ihnen erstellten Routen und Aktivitäten, +Versand notwendiger Transaktions-E-Mails, Bereitstellung anonymer +Planner-Sitzungen. Ein Konto sowie die Bestätigung der +Nutzungsbedingungen sind Bestandteil dieses Nutzungsvertrags – +sie sind +keine +Einwilligung im Sinne von Art. 6 Abs. 1 +lit. a DSGVO. + +Art. 6 Abs. 1 lit. f DSGVO (berechtigte Interessen): + +kurzzeitige Server-Logfiles zur Sicherung des Betriebs und zur +Missbrauchsabwehr, Rate-Limiting, Fehlermonitoring über Sentry +zur Sicherstellung der Funktionsfähigkeit des Dienstes. + +English. +Contract (Art. 6(1)(b)) covers everything +account- and content-related; legitimate interests (Art. 6(1)(f)) +cover short-lived server logs, rate-limiting, and error monitoring. +We do +not +rely on consent for any of this. + +4. Server-Logfiles + +Beim Aufruf der Dienste werden automatisch technische Informationen +protokolliert: + +IP-Adresse + +Zeitstempel + +HTTP-Methode, Pfad, Statuscode + +User-Agent (Browser, Betriebssystem) + +Zweck: +Sicherheit, Betrieb und Fehlersuche. + +Rechtsgrundlage: +Art. 6 Abs. 1 lit. f DSGVO. + +Speicherdauer: +maximal 14 Tage, danach automatische +Löschung. Eine Zusammenführung dieser Daten mit anderen Datenquellen +findet nicht statt. + +English. +HTTP requests to our servers are logged (IP, +timestamp, method, path, status, user-agent) for up to 14 days for +operational and security purposes under Art. 6(1)(f), then deleted. + +5. Speicherdauer + +Konto und zugehörige Inhalte: bis zur Löschung durch Sie + +Planner-Sitzungen: automatische Löschung nach 7 Tagen Inaktivität + +Magic-Link-Token: 15 Minuten + +Server-Logfiles: maximal 14 Tage + +Fehlerdaten (Sentry): 90 Tage + +Hinweis Alpha: Während der Alpha-Phase behält sich der Betreiber +ausdrücklich vor, die Datenbank zurückzusetzen oder einzelne +Datensätze zu löschen. Dies kann zu Datenverlust führen, bevor Sie +eine Löschung veranlassen. Details dazu in den Nutzungsbedingungen. + +English. +Account and content kept until you delete them. +Ephemeral data (sessions, magic-link tokens, logs, Sentry events) +deleted automatically on the schedules above. Alpha caveat: the +operator may reset the database or delete individual records +during alpha, which can cause data loss before you request +deletion. See the Terms of Service. + +6. Empfänger und Drittanbieter + +Wir geben personenbezogene Daten nur an die unten genannten +Auftragsverarbeiter und Dritten weiter, und auch dort nur im +jeweils notwendigen Umfang. + +Sentry +(Functional Software Inc.) – Fehler- und +Performance-Monitoring. Was übermittelt wird: Stacktraces, +Fehlertext, Browser-/OS-Informationen aus dem User-Agent, +Performance-Daten; bei eingeloggten Journal-Nutzer:innen +zusätzlich die Nutzer-ID. IP-Adressen werden nicht aktiv +gespeichert ( +sendDefaultPii +ist deaktiviert), ebenso +keine Cookies und keine vollständigen HTTP-Header. Keine +Session-Replays. Sentry agiert als externer Dienstleister +(Auftragsverarbeiter) für die Fehlerbehandlung. Da Sentry ein +Anbieter mit Sitz in den USA ist, kann im Einzelfall eine +Übermittlung personenbezogener Daten in ein Drittland im Sinne +der Art. 44 ff. DSGVO stattfinden; wir stützen uns hierbei auf +die von Sentry bereitgestellten +Standardvertragsklauseln. + +OpenStreetMap +– Kartenkacheln werden beim Anzeigen +der Karten direkt vom Browser von OSM-Tile-Servern geladen. Dabei +werden +IP-Adresse und User-Agent +an OSM übertragen. +Dies ist notwendig, um überhaupt eine Karte darzustellen; es gilt +die + +OSM Foundation Privacy Policy + +. + +Overpass API +(POI-Daten) – POI-Abfragen laufen +serverseitig über unsere eigene Route +/api/overpass +. +Der Upstream-Dienst sieht nur unsere Server-IP, nicht die Ihrer +Nutzer:innen. Aktueller Upstream: +overpass.private.coffee + +, der ohne Query-Logs arbeitet. Eine selbst gehostete Instanz ist +geplant. + +BRouter +– Routenberechnung läuft auf einer von uns +selbst gehosteten Instanz. Keine Weitergabe an Dritte. + +E-Mail-Versand +– Transaktions-E-Mails (Magic +Link, Willkommensnachricht) werden über einen konfigurierten +externen SMTP-Dienst versendet. Dabei wird die E-Mail-Adresse +der Empfänger:in an diesen Dienst übergeben. Selbst-betriebene +Instanzen konfigurieren ihren eigenen Mailserver. + +Hosting +– Die Dienste werden in Rechenzentren +innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit +dem Hoster besteht. + +English. +Third parties and what they receive: Sentry (error +details, no IPs/cookies); OpenStreetMap tile servers +(your IP and user-agent, directly from your browser, to load map +tiles); Overpass (via our server-side proxy, so upstream only sees +our server); BRouter (self-hosted, no third party involved); SMTP +provider (your email address for magic link / welcome mail); +hosting provider in the EU under a DPA. + +7. Ihre Rechte + +Als betroffene Person stehen Ihnen die folgenden Rechte zu: + +Auskunft (Art. 15 DSGVO) + +Berichtigung (Art. 16 DSGVO) + +Löschung (Art. 17 DSGVO) + +Einschränkung der Verarbeitung (Art. 18 DSGVO) + +Datenübertragbarkeit (Art. 20 DSGVO) + +Widerspruch gegen Verarbeitung auf Basis berechtigter Interessen (Art. 21 DSGVO) + +Zur Ausübung dieser Rechte genügt eine formlose E-Mail an + +legal@trails.cool + +. Einen vollständigen Export Ihrer Routen und Aktivitäten können Sie +jederzeit direkt in den Kontoeinstellungen herunterladen (GPX bzw. +JSON). Ihr Konto samt Inhalten können Sie dort ebenfalls selbst +löschen. + +English. +You have the standard GDPR rights (access, +rectification, erasure, restriction, portability, objection). Email +us to exercise them. Data exports and account deletion are also +available directly in your account settings. + +8. Beschwerderecht + +Sie haben das Recht, sich bei einer Datenschutzaufsichtsbehörde zu +beschweren. Für uns zuständig ist: + +Berliner Beauftragte für Datenschutz und Informationsfreiheit + +Friedrichstr. 219 + +10969 Berlin + +www.datenschutz-berlin.de + +English. +You have the right to lodge a complaint with a +supervisory authority; ours is the Berlin data-protection +commissioner (address above). + +Privacy Manifest + +A plain-language summary of what we do, for readers who prefer +behaviour over paragraphs. Binding text is above. + +Planner (planner.trails.cool) + +Anonymous by design. No account, no identifier, nothing persisted +past the session. + +No cookies, no localStorage, no sessionStorage + +No browser-side error tracking (Sentry is not loaded) + +Session data is deleted automatically after 7 days of inactivity + +Journal (trails.cool) + +Holds only what you put in. Exportable any time, deletable any time. + +Account: email, username, display name, passkey public key + +Routes: GPX, geometry, title, description + +Activities: title, description, date, linked route + +GPX / JSON export available per object and overall + +Sentry + +Journal server: always on + +Journal browser: only after login, torn down on logout + +Planner: server only; the browser never loads Sentry + +IPs not actively stored, no cookies, no full headers ( +sendDefaultPii: false +) + +No replays (replay integration not installed, sample rates 0) + +User ID only (no email/username) on Journal-logged-in events + +What we don't do + +Sell data + +Show ads + +Build user profiles + +Run tracking pixels, analytics, or A/B tests + +Security + +Auth via passkey (WebAuthn) or magic link — no passwords stored + +HTTPS + HSTS preload on all origins; session cookies httpOnly + Secure + +CSP, X-Frame-Options, X-Content-Type-Options on every response + +Containers run non-root; host firewall restricts to HTTP/HTTPS/SSH + +Gitleaks + dependency audit on every PR + +Vulnerability reports: + +SECURITY.md diff --git a/docs/legal-archive/terms-2026-04-19.md b/docs/legal-archive/terms-2026-04-19.md new file mode 100644 index 0000000..6fce5f2 --- /dev/null +++ b/docs/legal-archive/terms-2026-04-19.md @@ -0,0 +1,157 @@ +# Terms of Service — trails.cool + +Nutzungsbedingungen / Terms of Service + +Stand / Last updated: 2026-04-19 • Alpha — subject to change. Die +deutsche Fassung ist maßgeblich. The German version is authoritative; +English summaries follow each section. + +1. Gegenstand und Alpha-Status + +trails.cool ist ein experimenteller, derzeit kostenloser Dienst zur Planung +und Dokumentation von Outdoor-Aktivitäten. Der Dienst befindet sich +in aktiver Entwicklung (Alpha). Funktionen, Schnittstellen und +gespeicherte Daten können jederzeit ohne Vorankündigung geändert, +unterbrochen, gelöscht oder eingestellt werden. Eine +Verfügbarkeitsgarantie besteht nicht. + +English. +trails.cool is an experimental service, +currently free of charge, in +active alpha development. Features, data, and availability can +change at any time without notice. No availability is guaranteed. + +2. Mindestalter + +Die Nutzung eines Journal-Kontos ist erst ab einem Alter von 16 +Jahren zulässig. Anonyme Nutzung des Planners unterliegt keiner +Altersbeschränkung. + +English. +A Journal account is available only to users +aged 16 or older. Anonymous use of the Planner has no age +requirement. + +3. Dienstverfügbarkeit + +Der Betreiber kann den Dienst jederzeit ändern, einschränken, +unterbrechen oder einstellen, auch einzelne Funktionen oder +einzelne Konten. Wartungs- und Ausfallzeiten sind möglich. + +English. +The operator may modify, limit, interrupt, or +discontinue the service (or parts of it, or individual accounts) +at any time. Downtime and maintenance windows may occur. + +4. Zurücksetzen der Datenbank + +Während der Alpha-Phase behält sich der Betreiber ausdrücklich das +Recht vor, die gesamte Datenbank zurückzusetzen oder einzelne +Datensätze ohne vorherige Benachrichtigung zu löschen. +Nutzer:innen sollten wichtige Routen und Aktivitäten selbstständig +über die Export-Funktion (GPX bzw. JSON) sichern. + +English. +During alpha, the operator may reset the entire +database or delete individual records without notice. Back up +anything important via the GPX / JSON export. + +5. Eigenverantwortung für Daten + +Nutzer:innen sind für die Sicherung ihrer Inhalte selbst +verantwortlich. GPX- und JSON-Exporte sind im Journal jederzeit +für jede Route und Aktivität verfügbar. Der Betreiber schuldet +keine Datenwiederherstellung. + +English. +You are responsible for backing up your own +content. Exports are always available in the Journal; the +operator owes no data recovery. + +6. Inhalte und Nutzungsrechte + +Die von Ihnen eingestellten Inhalte (Routen, Aktivitäten, +Beschreibungen, GPX-Dateien) bleiben Ihr Eigentum. Sie räumen dem +Betreiber lediglich das einfache, nicht übertragbare Recht ein, +diese Inhalte zum Zweck des Betriebs des Dienstes zu speichern, +zu verarbeiten und Ihnen wieder anzuzeigen. Eine darüber +hinausgehende Nutzung, Veröffentlichung oder Weitergabe findet +nicht statt. + +English. +You retain ownership of everything you upload. +You grant the operator a limited, non-transferable licence to +store, process, and display your content solely for operating the +service; no other use, publication, or sharing. + +7. Nutzungsregeln + +Keine Verbreitung rechtswidriger Inhalte + +Keine missbräuchliche Nutzung (Spam, hochfrequente automatisierte +Abfragen, Denial-of-Service) + +Kein Umgehen technischer Schutzmaßnahmen + +Kein massenhaftes automatisiertes Auslesen von Daten + +English. +No illegal content, no abuse (spam, flooding, +DoS), no circumvention of technical protections, no bulk +automated data extraction. + +8. Kontobeendigung + +Sie können Ihr Konto jederzeit über die Einstellungen löschen; +damit werden auch die zugehörigen Inhalte gelöscht. Der Betreiber +kann Konten bei Verstößen gegen diese Bedingungen nach +angemessener Interessenabwägung sperren oder löschen. + +English. +Delete your account (and its content) any time +via settings. The operator may suspend or delete accounts that +violate these terms, balancing interests reasonably. + +9. Haftung + +Der Dienst wird ohne Gewährleistung bereitgestellt. Der Betreiber +haftet uneingeschränkt bei Vorsatz und grober Fahrlässigkeit sowie +bei Verletzung von Leben, Körper oder Gesundheit. Im Übrigen ist +die Haftung für leicht fahrlässige Pflichtverletzungen auf den +vertragstypischen, vorhersehbaren Schaden bei Verletzung +wesentlicher Vertragspflichten (Kardinalpflichten) begrenzt. Eine +darüber hinausgehende Haftung – insbesondere für Datenverlust – +ist ausgeschlossen, soweit gesetzlich zulässig. + +English. +No warranty. Unlimited liability for intent, +gross negligence, and injury to life, body, or health. For +slight negligence, liability is limited to foreseeable damages +arising from breach of material contractual duties. Liability +beyond that — in particular for data loss — is excluded to the +extent permitted by law. + +10. Änderungen der Bedingungen + +Diese Nutzungsbedingungen können sich während der Alpha-Phase +ändern. Bei wesentlichen Änderungen werden registrierte +Nutzer:innen per E-Mail informiert und müssen die neuen +Bedingungen erneut bestätigen. + +English. +These terms may change during alpha. Registered +users will be emailed about material changes and prompted to +re-accept. + +11. Anwendbares Recht + +Es gilt deutsches Recht unter Ausschluss des UN-Kaufrechts. Für +Verbraucher:innen mit gewöhnlichem Aufenthalt in einem Mitgliedstaat +der Europäischen Union bleibt der zwingende Schutz des Rechts ihres +Aufenthaltslandes unberührt. + +English. +German law applies, excluding the UN Convention +on Contracts for the International Sale of Goods. Consumers +habitually residing in an EU member state retain the mandatory +protection of their national law. diff --git a/scripts/render-legal.py b/scripts/render-legal.py new file mode 100755 index 0000000..23beda7 --- /dev/null +++ b/scripts/render-legal.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Render a trails.cool legal page (Terms / Privacy / Impressum) from its TSX +source into plain markdown suitable for `docs/legal-archive/`. + +Usage (from repo root): + python3 scripts/render-legal.py terms > docs/legal-archive/terms-YYYY-MM-DD.md + python3 scripts/render-legal.py privacy > docs/legal-archive/privacy-YYYY-MM-DD.md + python3 scripts/render-legal.py imprint > docs/legal-archive/imprint-YYYY-MM-DD.md + +Strips JSX tags + attributes and resolves the `operator.*` placeholders +from `apps/journal/app/lib/operator.ts` so the output is a clean +human-readable snapshot. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +OPERATOR_FILE = REPO_ROOT / "apps/journal/app/lib/operator.ts" +LEGAL_FILE = REPO_ROOT / "apps/journal/app/lib/legal.ts" + +PAGES = { + "terms": REPO_ROOT / "apps/journal/app/routes/legal.terms.tsx", + "privacy": REPO_ROOT / "apps/journal/app/routes/legal.privacy.tsx", + "imprint": REPO_ROOT / "apps/journal/app/routes/legal.imprint.tsx", +} + +TITLES = { + "terms": "Terms of Service — trails.cool", + "privacy": "Privacy Policy — trails.cool", + "imprint": "Impressum — trails.cool", +} + + +def load_operator() -> dict[str, str]: + src = OPERATOR_FILE.read_text() + fields = ("name", "street", "postalCode", "city", "country", "email", "responsiblePerson") + return { + f: m.group(1) if (m := re.search(rf'{f}:\s*"([^"]+)"', src)) else "" + for f in fields + } + + +def load_legal_constants() -> dict[str, str]: + """Exported top-level `export const NAME = "value";` from legal.ts.""" + src = LEGAL_FILE.read_text() + return dict(re.findall(r'export const (\w+)\s*=\s*"([^"]+)"', src)) + + +def render(src: str, operator: dict[str, str], legal: dict[str, str]) -> str: + # Resolve operator placeholders + src = re.sub(r'\{operator\.address\.(\w+)\}', lambda m: operator.get(m.group(1), ""), src) + src = re.sub(r'\{operator\.(\w+)\}', lambda m: operator.get(m.group(1), ""), src) + + # Resolve legal.ts constants (TERMS_VERSION, PRIVACY_LAST_UPDATED, …) + for name, value in legal.items(): + src = re.sub(r'\{' + re.escape(name) + r'\}', value, src) + + # Unwrap template literals and explicit single-space expressions + src = re.sub(r'\{`(.*?)`\}', r'\1', src) + src = re.sub(r'\{"\s*"\}', ' ', src) + + # Strip common JSX attributes we don't want in prose + src = re.sub(r'\s(?:className|href|target|rel|id|name|content|htmlFor)="[^"]*"', '', src) + + # Drop any remaining {expr} blocks (unresolved expressions) + src = re.sub(r'\{[^{}]*\}', '', src) + + # Replace tags with newlines to preserve paragraph structure + src = re.sub(r'<[^>]+>', '\n', src) + + # HTML entity decode (minimal) + for entity, replacement in ( + ("'", "'"), + ("&", "&"), + ("<", "<"), + (">", ">"), + (""", '"'), + (" ", " "), + ): + src = src.replace(entity, replacement) + + # Collapse blank lines + lines = [ln.rstrip() for ln in src.splitlines()] + out: list[str] = [] + prev_blank = False + for ln in lines: + if ln.strip() == "": + if not prev_blank and out: + out.append("") + prev_blank = True + else: + out.append(ln.strip()) + prev_blank = False + return "\n".join(out).strip() + + +def main() -> int: + if len(sys.argv) != 2 or sys.argv[1] not in PAGES: + sys.stderr.write(f"Usage: {sys.argv[0]} {{{'|'.join(PAGES)}}}\n") + return 2 + + page = sys.argv[1] + operator = load_operator() + legal = load_legal_constants() + src = PAGES[page].read_text() + + # Slice out the JSX tree: everything between "return (" and the closing ");" + src = re.split(r'return \(\s*\n', src, maxsplit=1)[-1] + src = re.sub(r'\);\s*\}\s*$', '', src.rstrip()) + + body = render(src, operator, legal) + print(f"# {TITLES[page]}\n\n{body}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())