Convert render-legal to TypeScript

The repo is a Node/TS stack, so a Python renderer is out of place.
Port the script to TypeScript, runnable via
`node --experimental-strip-types scripts/render-legal.ts <doc>`, and
update the README to match.

Verified byte-identical output against the Python version before
removing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 07:52:53 +02:00
parent d52828543a
commit 2abc207c2f
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
3 changed files with 133 additions and 129 deletions

View file

@ -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 <doc> > docs/legal-archive/<doc>-YYYY-MM-DD.md
node --experimental-strip-types scripts/render-legal.ts <doc> \
> docs/legal-archive/<doc>-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 `<doc>` is one of `terms`, `privacy`, `imprint`.

View file

@ -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 (
("&apos;", "'"),
("&amp;", "&"),
("&lt;", "<"),
("&gt;", ">"),
("&quot;", '"'),
("&nbsp;", " "),
):
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())

126
scripts/render-legal.ts Executable file
View file

@ -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<string, string> {
const src = readFileSync(OPERATOR_FILE, "utf8");
const fields = ["name", "street", "postalCode", "city", "country", "email", "responsiblePerson"];
const out: Record<string, string> = {};
for (const f of fields) {
const m = src.match(new RegExp(`${f}:\\s*"([^"]+)"`));
out[f] = m ? m[1]! : "";
}
return out;
}
function loadLegalConstants(): Record<string, string> {
const src = readFileSync(LEGAL_FILE, "utf8");
const out: Record<string, string> = {};
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<string, string>, legal: Record<string, string>): 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]> = [
["&apos;", "'"],
["&amp;", "&"],
["&lt;", "<"],
["&gt;", ">"],
["&quot;", '"'],
["&nbsp;", " "],
];
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());