diff --git a/docs/legal-archive/README.md b/docs/legal-archive/README.md index 6531452..ffb7b98 100644 --- a/docs/legal-archive/README.md +++ b/docs/legal-archive/README.md @@ -40,14 +40,14 @@ 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): +Snapshots are extracted from the TSX source by stripping JSX tags / +attributes and resolving the `operator.*` placeholders plus the +constants from `apps/journal/app/lib/legal.ts`. One-liner (from repo +root): ```bash -python3 scripts/render-legal.py > docs/legal-archive/-YYYY-MM-DD.md +node --experimental-strip-types scripts/render-legal.ts \ + > 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.) +where `` is one of `terms`, `privacy`, `imprint`. diff --git a/scripts/render-legal.py b/scripts/render-legal.py deleted file mode 100755 index 23beda7..0000000 --- a/scripts/render-legal.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/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()) diff --git a/scripts/render-legal.ts b/scripts/render-legal.ts new file mode 100755 index 0000000..5558502 --- /dev/null +++ b/scripts/render-legal.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env -S node --experimental-strip-types +/** + * 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): + * node --experimental-strip-types scripts/render-legal.ts terms > docs/legal-archive/terms-YYYY-MM-DD.md + * node --experimental-strip-types scripts/render-legal.ts privacy > docs/legal-archive/privacy-YYYY-MM-DD.md + * node --experimental-strip-types scripts/render-legal.ts imprint > docs/legal-archive/imprint-YYYY-MM-DD.md + * + * Strips JSX tags + attributes and resolves `operator.*` placeholders from + * operator.ts plus `TERMS_VERSION` / `PRIVACY_LAST_UPDATED` from legal.ts so + * the output is a clean human-readable snapshot. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const OPERATOR_FILE = join(REPO_ROOT, "apps/journal/app/lib/operator.ts"); +const LEGAL_FILE = join(REPO_ROOT, "apps/journal/app/lib/legal.ts"); + +const PAGES = { + terms: { file: "apps/journal/app/routes/legal.terms.tsx", title: "Terms of Service — trails.cool" }, + privacy: { file: "apps/journal/app/routes/legal.privacy.tsx", title: "Privacy Policy — trails.cool" }, + imprint: { file: "apps/journal/app/routes/legal.imprint.tsx", title: "Impressum — trails.cool" }, +} as const; + +type Page = keyof typeof PAGES; + +function loadOperator(): Record { + const src = readFileSync(OPERATOR_FILE, "utf8"); + const fields = ["name", "street", "postalCode", "city", "country", "email", "responsiblePerson"]; + const out: Record = {}; + for (const f of fields) { + const m = src.match(new RegExp(`${f}:\\s*"([^"]+)"`)); + out[f] = m ? m[1]! : ""; + } + return out; +} + +function loadLegalConstants(): Record { + const src = readFileSync(LEGAL_FILE, "utf8"); + const out: Record = {}; + for (const m of src.matchAll(/export const (\w+)\s*=\s*"([^"]+)"/g)) { + out[m[1]!] = m[2]!; + } + return out; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function render(src: string, operator: Record, legal: Record): string { + // Resolve operator placeholders + src = src.replace(/\{operator\.address\.(\w+)\}/g, (_, k: string) => operator[k] ?? ""); + src = src.replace(/\{operator\.(\w+)\}/g, (_, k: string) => operator[k] ?? ""); + + // Resolve legal.ts constants + for (const [name, value] of Object.entries(legal)) { + src = src.replace(new RegExp(`\\{${escapeRegex(name)}\\}`, "g"), value); + } + + // Unwrap template literals and explicit single-space expressions + src = src.replace(/\{`(.*?)`\}/gs, "$1"); + src = src.replace(/\{"\s*"\}/g, " "); + + // Strip common JSX attributes + src = src.replace(/\s(?:className|href|target|rel|id|name|content|htmlFor)="[^"]*"/g, ""); + + // Drop any remaining {expr} blocks (unresolved expressions) + src = src.replace(/\{[^{}]*\}/g, ""); + + // Replace tags with newlines to preserve paragraph structure + src = src.replace(/<[^>]+>/g, "\n"); + + // HTML entity decode (minimal) + const entities: Array<[string, string]> = [ + ["'", "'"], + ["&", "&"], + ["<", "<"], + [">", ">"], + [""", '"'], + [" ", " "], + ]; + for (const [e, r] of entities) src = src.split(e).join(r); + + // Collapse blank lines + const lines = src.split("\n").map((l) => l.replace(/\s+$/, "")); + const out: string[] = []; + let prevBlank = false; + for (const l of lines) { + if (l.trim() === "") { + if (!prevBlank && out.length) out.push(""); + prevBlank = true; + } else { + out.push(l.trim()); + prevBlank = false; + } + } + return out.join("\n").trim(); +} + +function main(): number { + const arg = process.argv[2]; + if (!arg || !(arg in PAGES)) { + process.stderr.write(`Usage: render-legal.ts {${Object.keys(PAGES).join("|")}}\n`); + return 2; + } + const page = arg as Page; + const operator = loadOperator(); + const legal = loadLegalConstants(); + let src = readFileSync(join(REPO_ROOT, PAGES[page].file), "utf8"); + + // Slice out the JSX tree: everything between `return (` and the closing `);` + src = src.split(/return \(\s*\n/).slice(-1)[0]!; + src = src.replace(/\);\s*\}\s*$/, "").trimEnd(); + + const body = render(src, operator, legal); + process.stdout.write(`# ${PAGES[page].title}\n\n${body}\n`); + return 0; +} + +process.exit(main());