Adds a simple legal-archive folder that preserves the full text of the
Terms, Privacy Policy, and Impressum at each version, plus a small
renderer script so future snapshots are mechanical:
python3 scripts/render-legal.py terms > docs/legal-archive/terms-YYYY-MM-DD.md
Why:
- Terms are per-user contract. users.terms_version maps each user to a
file in this folder so we can always tell what text they agreed to.
- Privacy is informational but GDPR Art. 13/14 still expects us to be
able to say what the policy disclosed on a given date.
- Impressum archived for symmetry; cheap.
How it works:
- scripts/render-legal.py reads the TSX source, resolves operator.*
placeholders from operator.ts and TERMS_VERSION / PRIVACY_LAST_UPDATED
from legal.ts, strips JSX tags, and prints clean markdown to stdout.
- A README in the folder explains when to bump and when to add a
snapshot (material change only, not typo fixes).
Seeded entries:
- terms-2026-04-19.md
- privacy-2026-04-19.md
- imprint-2026-04-19.md
No automation / CI check yet — trust-based process documented in the
folder's README. Revisit if discipline slips.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
4 KiB
Python
Executable file
122 lines
4 KiB
Python
Executable file
#!/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())
|