feat(tooling): port the docgen site generator to Ludic (no Python) (#41)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 16s
ci / build-and-test (push) Successful in 1m4s
commit-lint / conventional-commits (push) Successful in 3s
docs / build-and-deploy (push) Successful in 17s

Follow-up to #31: the doc/lint/grammar checks moved to Ludic there; this ports
the remaining docgen piece (gen.py / check.py / palette.py) so nothing in the
documentation pipeline is Python any more.

Three new `x` subcommands, all in Ludic and compiled by Ludic:

  - x docs-gen [--out DIR]  the static-site generator: parses docs/language/**
    front-matter + bodies (fences, Parameters:), builds the section/symbol
    model, reads the asset templates, and emits every page + ns/color/api pages
    + the landing page + ludic-highlight.js + symbols.json + .nojekyll.
  - x docs-check [DIR]      the coverage / integrity guard (required files, a
    page per inventory.json symbol, duplicate-token and one-dir-per-namespace
    guards, highlighter link targets).
  - x docs-palette          the named-colour source of truth: the palette table
    moved into tools/x/docgen.ludic, emitting emit_color.ludic (pointer, not
    ptr) + palette.json.

Verified against the Python oracle: `x docs-gen` reproduces all 466 output files
BYTE-FOR-BYTE (a Ludic json.dumps/html.escape/front-matter port — ordered dicts,
indent=2 vs compact, ensure_ascii \uXXXX, codepoint-aware truncation), and
`x docs-check` matches check.py's pass/fail output. Wired into `x test` as a
gate (docs-gen -> docs-check on a fresh site; docs-palette stays byte-identical).

CI swap: ci.yml and docs.yml call the Ludic generator; docs.yml drops the
python:3.12 container and bootstraps the toolchain from the IR seed instead.
tools/docgen/{gen,check,palette}.py deleted; only assets/ + inventory.json
remain. Completes #31's criterion 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 02:12:32 +03:00
parent 109d8c5b4f
commit 9ed0070039
13 changed files with 2027 additions and 946 deletions

View file

@ -10,7 +10,7 @@ docs/
language/<category>/<id>.md one file per symbol — keyword, type, phase,
builtin, namespace method, operator, annotation
language/<category>/_section.md section title + blurb + order
language/colors/palette.json the 221 named colors (generated by palette.py)
language/colors/palette.json the 221 named colors (generated by `x docs-palette`)
site/site.json landing-page messaging (hero, features, …)
site/snippets/*.ludic the code shown on the landing page (real programs)
tools/docgen/inventory.json the authoritative symbol set the coverage guard checks
@ -61,27 +61,31 @@ hovering any token shows a summary card from the real API data.
## Build & check
The generator and its guards are written in Ludic and run through `x` — there is
no Python in the pipeline. `assets/` (the CSS/HTML/JS templates) and
`inventory.json` are the only inputs the tools here still read directly.
```bash
python3 tools/docgen/gen.py --out build/pages # generate the whole site
python3 tools/docgen/check.py build/pages # coverage + duplicate-token + link guard
bin/x check-docs # parse every ```ludic doc fence (Ludic; no Python)
bin/x docs-gen --out build/pages # generate the whole site
bin/x docs-check build/pages # coverage + duplicate-token + link guard
bin/x check-docs # parse every ```ludic doc fence
```
`check.py` fails CI if any symbol in `inventory.json` lacks a page, if a token is
documented on two pages, or if a highlighter link points at a missing page — so
"every symbol is documented, autogenerated each time" is enforced. `validate.py`
needs a built `bin/ludicc`; the deploy CI is Python-only, so run it locally or in
a toolchain-enabled job.
No third-party dependencies — Python standard library only.
`docs-check` fails CI if any symbol in `inventory.json` lacks a page, if a token
is documented on two pages, or if a highlighter link points at a missing page —
so "every symbol is documented, autogenerated each time" is enforced. It needs a
built `bin/x` (bootstrapped from the IR seed with clang alone).
## Publish
`.forgejo/workflows/docs.yml` runs `gen.py` + `check.py` on every push to `main`
that touches `docs/**` or `tools/docgen/**`, and publishes the result to the
`pages` branch root. `index.html` + `.nojekyll` always stay at the root.
`.forgejo/workflows/docs.yml` bootstraps the toolchain from the IR seed and runs
`x docs-gen` + `x docs-check` on every push to `main` that touches `docs/**`,
`tools/docgen/**` or `tools/x/**`, and publishes the result to the `pages` branch
root. `index.html` + `.nojekyll` always stay at the root.
## Colors
`palette.json` is generated by `tools/docgen/palette.py` from a single `PALETTE`
table, which also generates `selfhost/emit_color.ludic`. Regenerate colors there.
`palette.json` and `selfhost/backend/stdlib/emit_color.ludic` are both generated
by `bin/x docs-palette` from a single palette table — the `pal_add(...)` rows in
`tools/x/docgen.ludic`. Edit the table there and regenerate; do not hand-edit the
generated files.

View file

@ -1,148 +0,0 @@
#!/usr/bin/env python3
"""check.py — coverage + integrity guard for the generated docs site.
Fails (exit 1) if:
* the pages contract is broken (index.html / api.html / .nojekyll missing);
* any symbol in tools/docgen/inventory.json lacks a source file AND a page;
* any per-symbol source file still carries only its one-line seed (i.e. was
scaffolded but never written up) — so "every symbol is really documented";
* a highlighter link target (page) does not exist in the output.
Usage: python3 tools/docgen/check.py [site-dir] (default build/pages)
"""
import json, os, re, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
LANG = os.path.join(ROOT, "docs", "language")
DOCGEN = os.path.join(ROOT, "tools", "docgen")
def parse_front(path):
t = open(path, encoding="utf-8").read()
meta = {}
if t.startswith("---"):
end = t.find("\n---", 3)
if end != -1:
for line in t[3:end].strip("\n").split("\n"):
if line.strip() and ":" in line:
k, v = line.split(":", 1); meta[k.strip()] = v.strip()
body = t[end+4:].strip()
else:
body = t
else:
body = t
return meta, body
def main(site):
problems, warnings = [], []
# 1) pages contract
for f in ("index.html", "api.html", ".nojekyll"):
if not os.path.exists(os.path.join(site, f)):
problems.append("missing required file: " + f)
# 2) coverage against the authoritative inventory
inv = json.load(open(os.path.join(DOCGEN, "inventory.json")))
# map id -> source path
id2src = {}
thin = []
for cat in sorted(os.listdir(LANG)):
cdir = os.path.join(LANG, cat)
if not os.path.isdir(cdir):
continue
for fn in os.listdir(cdir):
if not fn.endswith(".md") or fn == "_section.md":
continue
meta, body = parse_front(os.path.join(cdir, fn))
sid = meta.get("id", fn[:-3]); id2src[sid] = os.path.join(cdir, fn)
# a "thin" file = body (minus a Parameters block and its tip line) too short
tipline = meta.get("tip", "")
btext = re.sub(r"(?is)parameters:.*", "", body).strip()
btext = btext.replace(tipline, "").strip()
if len(btext) < 40:
thin.append(sid)
for cat, ids in inv.items():
for sid in ids:
if sid not in id2src:
problems.append("no source file for inventory symbol: %s (%s)" % (sid, cat))
elif site and not os.path.exists(os.path.join(site, sid + ".html")):
problems.append("no generated page for symbol: %s.html" % sid)
# 2b) duplicate token → symbol conflicts (same token documented on two pages)
tok2ids = {}
for cat in sorted(os.listdir(LANG)):
cdir = os.path.join(LANG, cat)
if not os.path.isdir(cdir):
continue
for fn in os.listdir(cdir):
if not fn.endswith(".md") or fn == "_section.md":
continue
meta, _ = parse_front(os.path.join(cdir, fn))
for tok in meta.get("tokens", "").split():
tok2ids.setdefault((meta.get("kind",""), tok), set()).add(meta.get("id",""))
for (kind, tok), ids in sorted(tok2ids.items()):
if len(ids) > 1:
problems.append("token %r (%s) documented on multiple pages: %s" % (tok, kind, ", ".join(sorted(ids))))
# 2c) one docs directory per namespace / section. Two dirs covering the same
# runtime namespace (e.g. a `network`/`networking` or `date`/`datetime`
# split) invite drift — a symbol documented in one and not the other — so
# fail if any `ns:` is declared from more than one directory, or if two
# sections share a section id or (case-folded) title.
ns2dirs, id2dirs, title2dirs = {}, {}, {}
for cat in sorted(os.listdir(LANG)):
cdir = os.path.join(LANG, cat)
if not os.path.isdir(cdir):
continue
sp = os.path.join(cdir, "_section.md")
if os.path.exists(sp):
smeta, _ = parse_front(sp)
id2dirs.setdefault(smeta.get("id", cat), set()).add(cat)
title2dirs.setdefault(smeta.get("title", "").strip().lower(), set()).add(cat)
for fn in os.listdir(cdir):
if not fn.endswith(".md") or fn == "_section.md":
continue
meta, _ = parse_front(os.path.join(cdir, fn))
ns = meta.get("ns", "")
if ns:
ns2dirs.setdefault(ns, set()).add(cat)
for ns, dirs in sorted(ns2dirs.items()):
if len(dirs) > 1:
problems.append("namespace %r documented from multiple dirs: %s" % (ns, ", ".join(sorted(dirs))))
for sid, dirs in sorted(id2dirs.items()):
if len(dirs) > 1:
problems.append("section id %r declared by multiple dirs: %s" % (sid, ", ".join(sorted(dirs))))
for title, dirs in sorted(title2dirs.items()):
if title and len(dirs) > 1:
problems.append("section title %r shared by multiple dirs: %s" % (title, ", ".join(sorted(dirs))))
# 3) thin (un-expanded) symbols — warn, not fail (lets infra land before prose)
if thin:
warnings.append("%d symbols still have only seed text: %s%s"
% (len(thin), ", ".join(sorted(thin)[:12]), " …" if len(thin) > 12 else ""))
# 4) highlighter targets exist
sj = os.path.join(site, "symbols.json")
if os.path.exists(sj):
h = json.load(open(sj))["highlight"]
targets = set()
for grp in ("keywords", "types", "phases", "annotations"):
targets |= set(h.get(grp, {}).values())
for grp in ("builtins", "nsmethods"):
targets |= set(v["id"] for v in h.get(grp, {}).values())
targets |= set(h.get("namespaces", {}).values())
for tgt in sorted(targets):
if not os.path.exists(os.path.join(site, tgt + ".html")):
problems.append("highlighter links to %s.html but it was not generated" % tgt)
for w in warnings:
print(" warning:", w)
if problems:
print("docs check FAILED:")
for p in problems:
print(" -", p)
return 1
print("docs check OK: %s (%d symbols documented)" % (site, len(id2src)))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "build", "pages")))

View file

@ -1,462 +0,0 @@
#!/usr/bin/env python3
"""gen.py — the Ludic documentation generator (v2).
Single source of truth:
docs/language/<category>/<id>.md one file per symbol (front-matter + body)
docs/language/<category>/_section.md section title/blurb/order
docs/language/colors/palette.json the named-color palette
docs/site/site.json landing-page messaging
docs/site/snippets/*.ludic real programs shown on the landing page
Outputs (into --out, default build/pages) — the whole pages-branch payload:
index.html the landing page
api.html the reference index, with fuzzy search
<id>.html ONE page per symbol (kw-handler.html, screen-clear.html…)
ns-<name>.html one overview page per namespace (Screen/Color/Input/…)
ludic-highlight.js the highlighter — its symbol tables, tips, per-item link
targets, per-parameter anchors and hover-card data all
generated from the sources above
symbols.json the machine-readable index (drives search + hover cards)
.nojekyll
Every keyword/type/phase/builtin/namespace-method/annotation/operator/color is
addressable by its own page; named parameters and named colors are addressable
by anchor. Python standard library only.
"""
import json, os, html, re, argparse
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
ASSETS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
LANG = os.path.join(ROOT, "docs", "language")
SITE = os.path.join(ROOT, "docs", "site")
HEADER_OFFSET = 84 # sticky nav height; used for scroll-margin
def esc(s): return html.escape(s or "", quote=False)
def escattr(s): return html.escape(s or "", quote=True)
def fill(tmpl, mapping):
for k, v in mapping.items():
tmpl = tmpl.replace("@@" + k + "@@", str(v))
return tmpl
# ---------------------------------------------------------------------------
# parsing
# ---------------------------------------------------------------------------
FENCE = re.compile(r"```ludic\n(.*?)\n```", re.S)
PARAM = re.compile(r"^-\s*`?(\w+)`?\s*[—-]+\s*(.*)$")
def parse_doc(path):
text = open(path, encoding="utf-8").read()
meta, body = {}, text
if text.startswith("---"):
end = text.find("\n---", 3)
if end != -1:
for line in text[3:end].strip("\n").split("\n"):
if line.strip() and ":" in line:
k, v = line.split(":", 1)
meta[k.strip()] = v.strip()
body = text[end + 4:].lstrip("\n")
return meta, body
def codeify(s):
return re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
def split_body(body):
examples = FENCE.findall(body)
body = FENCE.sub("", body)
# pull out a "Parameters:" block
params, keep = [], []
lines = body.split("\n")
i, n = 0, len(lines)
while i < n:
if lines[i].strip().lower().startswith("parameters:"):
i += 1
while i < n and lines[i].strip():
m = PARAM.match(lines[i].strip())
if m:
params.append({"name": m.group(1), "desc": codeify(m.group(2).strip())})
i += 1
else:
keep.append(lines[i]); i += 1
desc = "\n".join(keep).strip()
paras = [codeify(p.strip()) for p in re.split(r"\n\s*\n", desc) if p.strip()]
return "</p><p>".join(paras), examples, params
def load_sections():
sections = []
for cat in sorted(os.listdir(LANG)):
cdir = os.path.join(LANG, cat)
if not os.path.isdir(cdir):
continue
smeta, sblurb = {}, ""
sp = os.path.join(cdir, "_section.md")
if os.path.exists(sp):
smeta, sbody = parse_doc(sp)
sblurb = codeify(sbody.strip())
entries = []
for fn in sorted(os.listdir(cdir)):
if not fn.endswith(".md") or fn == "_section.md":
continue
meta, body = parse_doc(os.path.join(cdir, fn))
desc, examples, params = split_body(body)
meta["desc_html"] = desc
meta["examples"] = examples
meta["params"] = params
meta["tokens_list"] = meta.get("tokens", "").split()
meta["related_list"] = meta.get("related", "").split()
entries.append(meta)
entries.sort(key=lambda e: (int(e.get("order", 999)), e.get("name", "")))
# A namespace section is discovered, not hardcoded: it is any section
# whose symbols are `namespace-method`s, named by their `ns:` field. So
# adding a namespace is just adding its docs dir — gen.py needs no edit.
ns_name = next((e["ns"] for e in entries
if e.get("kind") == "namespace-method" and e.get("ns")), None)
sections.append({"id": smeta.get("id", cat), "title": smeta.get("title", cat.title()),
"order": int(smeta.get("order", 999)), "blurb": sblurb,
"cat": cat, "entries": entries, "ns_name": ns_name})
sections.sort(key=lambda s: (s["order"], s["title"]))
return sections
# ---------------------------------------------------------------------------
# symbol model for the highlighter + search + cards
# ---------------------------------------------------------------------------
def ns_page(nsname): return "ns-" + nsname.lower()
def build_symbols(sections, palette):
sym = {"keywords": {}, "types": {}, "phases": {}, "builtins": {},
"nsmethods": {}, "annotations": {}, "namespaces": {}, "tips": {},
"cards": {}, "colors_page": "ns-color"}
items = {} # id -> full record for search
for s in sections:
for e in s["entries"]:
eid = e["id"]; kind = e.get("kind", ""); tip = e.get("tip", "")
page = eid + ".html"
items[eid] = {"id": eid, "name": e.get("name", ""), "sig": e.get("sig", ""),
"kind": kind, "category": s["id"], "section": s["title"],
"tip": tip, "page": page}
sym["cards"][eid] = {"name": e.get("name",""), "sig": e.get("sig",""),
"tip": tip, "kind": kind, "page": page, "section": s["title"]}
for tok in e["tokens_list"]:
if kind == "keyword": sym["keywords"][tok] = eid
elif kind == "type": sym["types"][tok] = eid
elif kind == "phase": sym["phases"][tok] = eid
elif kind == "builtin":
sym["builtins"][tok] = {"id": eid, "params": [p["name"] for p in e["params"]]}
elif kind == "namespace-method":
sym["nsmethods"][tok] = {"id": eid, "params": [p["name"] for p in e["params"]]}
elif kind == "annotation":
sym["annotations"][tok] = eid
if tip: sym["tips"][tok] = tip
# namespaces are discovered from the ns methods present, plus Color; each
# namespace's tip falls back to its section blurb so no hardcoded list of
# namespaces is needed here when a new one is added.
ns_blurb = {s["ns_name"]: plain_text(s["blurb"]) for s in sections if s.get("ns_name")}
nsnames = set()
for tok in sym["nsmethods"]:
if "." in tok: nsnames.add(tok.split(".", 1)[0])
nsnames.add("Color")
for nn in sorted(nsnames):
sym["namespaces"][nn] = ns_page(nn)
sym["cards"][ns_page(nn)] = {"name": nn, "sig": nn + ".*", "kind": "namespace",
"tip": ns_tip(nn, ns_blurb), "page": ns_page(nn) + ".html",
"section": "Namespaces"}
return sym, items
def plain_text(s, limit=160):
"""The first sentence of a blurb, stripped of tags — for use as a one-line tip."""
t = re.sub(r"<[^>]+>", "", s or "").strip()
m = re.match(r"(.+?[.!?])(\s|$)", t)
if m: t = m.group(1)
return t if len(t) <= limit else t[:limit].rsplit(" ", 1)[0] + "…"
def ns_tip(nsname, ns_blurb):
"""A namespace's tip: an optional curated override, else its section blurb."""
return NS_TIP.get(nsname) or ns_blurb.get(nsname, "")
# Optional short overrides for a namespace's one-line tip. A namespace NOT
# listed here falls back to the first sentence of its section blurb (see
# ns_tip), so a newly added namespace needs no edit here. Color has no docs
# section of its own, so its override is required.
NS_TIP = {"Color": "The named color palette."}
# ---------------------------------------------------------------------------
# shared chrome
# ---------------------------------------------------------------------------
def nav_html(cfg, links=None, cta_href="api.html"):
out = []
for n in (links if links is not None else cfg["nav_links"]):
cls = 'class="nav-cta" ' if n.get("href") == cta_href else ""
out.append('<a %shref="%s">%s</a>' % (cls, n["href"], esc(n["label"])))
return "".join(out)
def head_html(title, desc, css_file):
css = open(os.path.join(ASSETS, css_file)).read()
return fill("""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@@TITLE@@</title>
<meta name="description" content="@@DESC@@">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
@@CSS@@
</style>
</head>""", {"TITLE": esc(title), "DESC": escattr(desc), "CSS": css})
def topbar(cfg):
refnav = nav_html(cfg, links=cfg.get("ref_nav_links", cfg["nav_links"]), cta_href=None) \
+ '<a class="nav-cta" href="%s">Source ↗</a>' % cfg["repo_url"]
return ('<header class="nav"><div class="wrap nav-in">'
'<a class="brand" href="index.html"><span class="logo">L</span> %s</a>'
'<button class="nav-toggle" aria-label="Toggle menu" aria-expanded="false">☰</button>'
'<nav class="nav-links">%s</nav></div></header>') % (esc(cfg["brand"]), refnav)
# ---------------------------------------------------------------------------
# per-item page
# ---------------------------------------------------------------------------
def render_params(e):
if not e["params"]:
return ""
rows = "".join(
'<div class="param" id="param-%s"><code class="pname">%s</code>'
'<span class="pdesc">%s</span></div>' % (p["name"], esc(p["name"]), p["desc"])
for p in e["params"])
return '<div class="params"><h2>Parameters</h2>%s</div>' % rows
def render_examples(e):
if not e["examples"]:
return ""
blocks = "".join('<pre data-lang="ludic">%s</pre>' % esc(code) for code in e["examples"])
return '<div class="examples"><h2>Example</h2>%s</div>' % blocks
def render_related(e, items):
rel = [r for r in e["related_list"] if r in items]
if not rel:
return ""
links = "".join('<a class="rel" href="%s.html">%s</a>' % (r, esc(items[r]["name"])) for r in rel)
return '<div class="related"><h2>Related</h2><div class="rel-row">%s</div></div>' % links
def item_page(e, section, cfg, items):
kindlabel = {"namespace-method": "method", "builtin": "function"}.get(e.get("kind",""), e.get("kind",""))
body = fill("""@@TOPBAR@@
<main class="wrap item">
<div class="crumbs"><a href="api.html">API Reference</a> <span>›</span> <a href="@@SECHREF@@">@@SECTION@@</a> <span>›</span> <span class="here">@@NAME@@</span></div>
<div class="item-head">
<span class="kind-badge kind-@@KIND@@">@@KINDLABEL@@</span>
<h1 id="top">@@NAME@@</h1>
</div>
<code class="sig">@@SIG@@</code>
<div class="desc"><p>@@DESC@@</p></div>
@@PARAMS@@
@@EXAMPLES@@
@@RELATED@@
<a class="back" href="api.html">← All symbols</a>
</main>
<script src="ludic-highlight.js"></script>
<script>Ludic.highlightAll(); Ludic.installCards(); Ludic.flashTarget();</script>
</body></html>""", {
"TOPBAR": topbar(cfg),
"SECHREF": ns_page(section["ns_name"]) + ".html" if section.get("ns_name") else "api.html#" + section["id"],
"SECTION": esc(section["title"]),
"NAME": esc(e.get("name","")),
"KIND": e.get("kind","").replace("namespace-method","method"),
"KINDLABEL": esc(kindlabel),
"SIG": esc(e.get("sig","")),
"DESC": e.get("desc_html","") or esc(e.get("tip","")),
"PARAMS": render_params(e),
"EXAMPLES": render_examples(e),
"RELATED": render_related(e, items),
})
return head_html(e.get("name","") + " — Ludic", e.get("tip",""), "item.css") + "\n<body>\n" + body
# ---------------------------------------------------------------------------
# namespace overview pages
# ---------------------------------------------------------------------------
def ns_overview_page(nsname, section, cfg):
rows = ""
for e in section["entries"]:
rows += ('<a class="ns-method" href="%s.html"><code class="nm-sig">%s</code>'
'<span class="nm-tip">%s</span></a>') % (e["id"], esc(e.get("sig","")), esc(e.get("tip","")))
body = fill("""@@TOPBAR@@
<main class="wrap item">
<div class="crumbs"><a href="api.html">API Reference</a> <span>›</span> <span class="here">@@NAME@@</span></div>
<div class="item-head"><span class="kind-badge kind-namespace">namespace</span><h1 id="top">@@NAME@@</h1></div>
<p class="ns-blurb">@@BLURB@@</p>
<div class="ns-methods">@@ROWS@@</div>
<a class="back" href="api.html">← All symbols</a>
</main>
<script src="ludic-highlight.js"></script>
<script>Ludic.installCards(); Ludic.flashTarget();</script>
</body></html>""", {"TOPBAR": topbar(cfg), "NAME": esc(nsname), "BLURB": section["blurb"], "ROWS": rows})
return head_html(nsname + " — Ludic", NS_TIP.get(nsname) or plain_text(section["blurb"]), "item.css") + "\n<body>\n" + body
def color_page(palette, cfg):
groups = ""
for grp in palette["groups"]:
sw = ""
for col in grp["colors"]:
h = col["hex"]; nm = col["name"]; aid = nm.lower()
sw += ('<div class="swatch" id="%s"><span class="chip" style="background:#%s"></span>'
'<span class="cname">Color.%s</span><span class="chex">#%s</span></div>'
) % (aid, h, esc(nm), h)
groups += '<div class="swatch-group"><h3>%s</h3><div class="swatch-row">%s</div></div>' % (esc(grp["name"]), sw)
body = fill("""@@TOPBAR@@
<main class="wrap item">
<div class="crumbs"><a href="api.html">API Reference</a> <span>›</span> <span class="here">Color</span></div>
<div class="item-head"><span class="kind-badge kind-namespace">namespace</span><h1 id="top">Color</h1></div>
<p class="ns-blurb">@@BLURB@@ Every <code>Color.Name</code> lowers to a plain <code>0xRRGGBB</code> integer at compile time — no runtime cost. @@COUNT@@ names are built in.</p>
@@GROUPS@@
<a class="back" href="api.html">← All symbols</a>
</main>
<script src="ludic-highlight.js"></script>
<script>Ludic.installCards(); Ludic.flashTarget();</script>
</body></html>""", {"TOPBAR": topbar(cfg), "BLURB": "The named color palette.",
"COUNT": palette["count"], "GROUPS": groups})
return head_html("Color — Ludic", "The Ludic named-color palette.", "item.css") + "\n<body>\n" + body
# ---------------------------------------------------------------------------
# api index with fuzzy search
# ---------------------------------------------------------------------------
def api_index(sections, cfg):
cards = ""
for s in sections:
if s["cat"] == "colors":
cards += ('<section class="idx-sec" data-sec="%s"><h2 id="%s">%s</h2>'
'<p class="sec-blurb">%s</p><div class="idx-grid">'
'<a class="idx-item" href="ns-color.html" data-name="Color" data-tip="The named color palette.">'
'<code>Color</code><span>The named color palette (221 names).</span></a></div></section>'
) % (s["id"], s["id"], esc(s["title"]), s["blurb"])
continue
rows = ""
for e in s["entries"]:
rows += ('<a class="idx-item" href="%s.html" data-name="%s" data-tip="%s"><code>%s</code><span>%s</span></a>'
) % (e["id"], escattr(e.get("name","")), escattr(e.get("tip","")),
esc(e.get("name","")), esc(e.get("tip","")))
cards += ('<section class="idx-sec" data-sec="%s"><h2 id="%s">%s</h2>'
'<p class="sec-blurb">%s</p><div class="idx-grid">%s</div></section>'
) % (s["id"], s["id"], esc(s["title"]), s["blurb"], rows)
body = fill("""@@TOPBAR@@
<main class="wrap ref">
<div class="ref-intro">
<div class="kicker">Reference</div>
<h1>API Reference</h1>
<p>Every keyword, type, phase, builtin, namespace method, annotation and color in Ludic — each on its own page. Search, or browse by section. In any code sample across this site, hover a token for a summary and click to jump to its page.</p>
<input id="search" class="search" type="search" placeholder="Search symbols… (e.g. handler, fill_rectangle, @Sync)" autocomplete="off" autofocus>
<div id="noresults" class="noresults" hidden>No symbols match.</div>
</div>
@@CARDS@@
</main>
<script src="ludic-highlight.js"></script>
<script>Ludic.installCards(); Ludic.installSearch();</script>
</body></html>""", {"TOPBAR": topbar(cfg), "CARDS": cards})
return head_html("Ludic — API Reference", "The complete Ludic API Reference: every keyword, type, builtin, namespace, annotation and color, each on its own page, with fuzzy search.", "item.css") + "\n<body>\n" + body
# ---------------------------------------------------------------------------
# landing page (data-driven, unchanged structure; highlighter now deep-links)
# ---------------------------------------------------------------------------
def read_snippet(rel): return open(os.path.join(ROOT, rel), encoding="utf-8").read().rstrip("\n")
def render_index(cfg):
css = open(os.path.join(ASSETS, "site.css")).read()
hero = cfg["hero"]
navlinks = nav_html(cfg)
hero_code = esc(read_snippet(hero["snippet"]))
stages = ""
for st in hero["pipeline"]:
hl = " hl" if st == "ludicc" else ""
stages += '<span class="stage%s">%s</span>' % (hl, esc(st))
if st != hero["pipeline"][-1]: stages += '<span class="arrow">→</span>'
feats = "".join('<div class="feat reveal"><div class="ico">%s</div><h3>%s</h3><p>%s</p></div>'
% (c["icon"], c["title"], c["html"]) for c in cfg["features"]["cards"])
phil = cfg["philosophy"]
phil_paras = "".join("<p>%s</p>" % p for p in phil["paras"])
phil_stats = "".join('<div class="stat"><div class="big">%s</div><div class="lbl">%s</div></div>'
% (s["big"], esc(s["lbl"])) for s in phil["stats"])
start = cfg["start"]
steps = "".join('<div class="step reveal"><div class="n">%d</div><div><h4>%s</h4><p>%s</p></div></div>'
% (i, s["title"], s["html"]) for i, s in enumerate(start["steps"], 1))
term = ""
for t in start["terminal"]:
if t.get("blank"): term += "\n"
elif "comment" in t: term += '<span class="t-com"># %s</span>\n' % esc(t["comment"])
elif "cmd" in t: term += '<span class="prompt">$</span> %s\n' % esc(t["cmd"])
elif "out" in t: term += '<span class="out">%s</span>\n' % esc(t["out"])
ed = cfg["editors"]
eds = "".join('<div class="ed"><span class="k">◆</span> %s</div>' % esc(x) for x in ed["list"])
samples = [{"name": s["name"], "label": s["label"], "note": s["note"], "code": read_snippet(s["file"])}
for s in cfg["showcase"]["samples"]]
footlinks = "".join('<a href="%s">%s</a>' % (n["href"], esc(n["label"])) for n in cfg["nav_links"])
footlinks += '<a href="%s">Source ↗</a>' % cfg["repo_url"]
m = cfg["meta"]
tmpl = open(os.path.join(ASSETS, "index.tmpl.html")).read()
return pct(tmpl, dict(
css=css, brand=esc(cfg["brand"]), tagline=esc(cfg["tagline"]), repo=cfg["repo_url"],
title=esc(m["title"]), desc=escattr(m["description"]), ogt=esc(m["og_title"]), ogd=escattr(m["og_description"]),
navlinks=navlinks, pill=esc(hero["pill"]), title_pre=esc(hero["title_pre"]), title_accent=esc(hero["title_accent"]),
lead=hero["lead"], pcta_h=hero["primary_cta"]["href"], pcta_l=esc(hero["primary_cta"]["label"]),
scta_h=hero["secondary_cta"]["href"], scta_l=esc(hero["secondary_cta"]["label"]),
hero_name=esc(hero["snippet_name"]), hero_code=hero_code, stages=stages, pipenote=hero["pipeline_note"],
feat_kicker=esc(cfg["features"]["kicker"]), feat_title=esc(cfg["features"]["title"]), feat_intro=cfg["features"]["intro"], feats=feats,
sc_kicker=esc(cfg["showcase"]["kicker"]), sc_title=esc(cfg["showcase"]["title"]), sc_intro=cfg["showcase"]["intro"],
phil_kicker=esc(phil["kicker"]), phil_title=esc(phil["title"]), phil_paras=phil_paras, phil_stats=phil_stats,
start_kicker=esc(start["kicker"]), start_title=esc(start["title"]), start_intro=esc(start["intro"]),
steps=steps, term_name=esc(start["terminal_name"]), term=term,
ed_kicker=esc(ed["kicker"]), ed_title=esc(ed["title"]), ed_intro=ed["intro"], eds=eds, ed_note=ed["note"],
footlinks=footlinks, samples=json.dumps(samples),
))
def pct(tmpl, mapping):
return re.sub(r"%\((\w+)\)s", lambda mm: mapping[mm.group(1)], tmpl)
# ---------------------------------------------------------------------------
def render_highlighter(symbols):
tmpl = open(os.path.join(ASSETS, "ludic-highlight.tmpl.js")).read()
return tmpl.replace("/*__SYMBOLS__*/{}", json.dumps(symbols, ensure_ascii=False))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=os.path.join(ROOT, "build", "pages"))
args = ap.parse_args()
out = args.out
os.makedirs(out, exist_ok=True)
sections = load_sections()
palette = json.load(open(os.path.join(LANG, "colors", "palette.json")))
symbols, items = build_symbols(sections, palette)
cfg = json.load(open(os.path.join(SITE, "site.json")))
W = lambda name, content: open(os.path.join(out, name), "w").write(content)
# per-item pages
npages = 0
for s in sections:
if s["cat"] == "colors":
continue
for e in s["entries"]:
W(e["id"] + ".html", item_page(e, s, cfg, items)); npages += 1
# namespace overview pages — one per discovered namespace section
for s in sections:
if s["ns_name"]:
W(ns_page(s["ns_name"]) + ".html", ns_overview_page(s["ns_name"], s, cfg)); npages += 1
W("ns-color.html", color_page(palette, cfg)); npages += 1
# index + landing + assets
W("api.html", api_index(sections, cfg))
W("index.html", render_index(cfg))
W("ludic-highlight.js", render_highlighter(symbols))
W("symbols.json", json.dumps({"items": items, "highlight": symbols}, indent=2, ensure_ascii=False))
W(".nojekyll", "")
n_entries = sum(len(s["entries"]) for s in sections)
print("docs generated -> %s" % out)
print(" sections: %d symbols: %d colors: %d item pages: %d"
% (len(sections), n_entries, palette["count"], npages))
print(" highlighter: %d kw / %d type / %d phase / %d builtin / %d ns-method / %d annot / %d ns"
% (len(symbols["keywords"]), len(symbols["types"]), len(symbols["phases"]),
len(symbols["builtins"]), len(symbols["nsmethods"]), len(symbols["annotations"]),
len(symbols["namespaces"])))
if __name__ == "__main__":
main()

View file

@ -1,298 +0,0 @@
#!/usr/bin/env python3
# Single source of truth for Ludic's named color palette.
# Emits: selfhost/backend/stdlib/emit_color.ludic (compiler lookup) and palette.json (docs).
import json, sys, os
# (Name, 0xRRGGBB, group). Names are PascalCase, unique. >200 entries.
PALETTE = [
# ---- Whites & off-whites ----
("White", 0xFFFFFF, "Whites"),
("Snow", 0xFFFAFA, "Whites"),
("Ivory", 0xFFFFF0, "Whites"),
("EggShellWhite", 0xF0EAD6, "Whites"),
("FloralWhite", 0xFFFAF0, "Whites"),
("SeaShell", 0xFFF5EE, "Whites"),
("Linen", 0xFAF0E6, "Whites"),
("AntiqueWhite", 0xFAEBD7, "Whites"),
("OldLace", 0xFDF5E6, "Whites"),
("Beige", 0xF5F5DC, "Whites"),
("Cream", 0xFFFDD0, "Whites"),
("Honeydew", 0xF0FFF0, "Whites"),
("MintCream", 0xF5FFFA, "Whites"),
("Azure", 0xF0FFFF, "Whites"),
("AliceBlue", 0xF0F8FF, "Whites"),
("GhostWhite", 0xF8F8FF, "Whites"),
("WhiteSmoke", 0xF5F5F5, "Whites"),
("Lavender", 0xE6E6FA, "Whites"),
("Bone", 0xE3DAC9, "Whites"),
("Parchment", 0xF1E9D2, "Whites"),
# ---- Grays & neutrals ----
("Gainsboro", 0xDCDCDC, "Grays"),
("LightGray", 0xD3D3D3, "Grays"),
("Silver", 0xC0C0C0, "Grays"),
("Ash", 0xB2BEB5, "Grays"),
("DarkGray", 0xA9A9A9, "Grays"),
("Gray", 0x808080, "Grays"),
("DimGray", 0x696969, "Grays"),
("Nickel", 0x727472, "Grays"),
("Slate", 0x708090, "Grays"),
("SlateGray", 0x708090, "Grays"),
("LightSlateGray", 0x778899, "Grays"),
("Gunmetal", 0x2A3439, "Grays"),
("Charcoal", 0x36454F, "Grays"),
("Graphite", 0x1C1C1C, "Grays"),
("Onyx", 0x353839, "Grays"),
("Jet", 0x343434, "Grays"),
("Black", 0x000000, "Grays"),
("EerieBlack", 0x1B1B1B, "Grays"),
("RaisinBlack", 0x242124, "Grays"),
("Ebony", 0x555D50, "Grays"),
# ---- Reds ----
("Red", 0xFF0000, "Reds"),
("Crimson", 0xDC143C, "Reds"),
("Scarlet", 0xFF2400, "Reds"),
("Vermilion", 0xE34234, "Reds"),
("FireBrick", 0xB22222, "Reds"),
("Cinnabar", 0xE44D2E, "Reds"),
("DarkRed", 0x8B0000, "Reds"),
("Maroon", 0x800000, "Reds"),
("Ruby", 0xE0115F, "Reds"),
("Cardinal", 0xC41E3A, "Reds"),
("IndianRed", 0xCD5C5C, "Reds"),
("Rust", 0xB7410E, "Reds"),
("Sangria", 0x92000A, "Reds"),
("Redwood", 0xA45A52, "Reds"),
("Cerise", 0xDE3163, "Reds"),
("Amaranth", 0xE52B50, "Reds"),
("Carmine", 0x960018, "Reds"),
("Chestnut", 0x954535, "Reds"),
("Brick", 0xCB4154, "Reds"),
("TerraCotta", 0xE2725B, "Reds"),
# ---- Pinks ----
("Pink", 0xFFC0CB, "Pinks"),
("LightPink", 0xFFB6C1, "Pinks"),
("HotPink", 0xFF69B4, "Pinks"),
("DeepPink", 0xFF1493, "Pinks"),
("PaleVioletRed", 0xDB7093, "Pinks"),
("Rose", 0xFF007F, "Pinks"),
("Blush", 0xDE5D83, "Pinks"),
("Salmon", 0xFA8072, "Pinks"),
("LightSalmon", 0xFFA07A, "Pinks"),
("DarkSalmon", 0xE9967A, "Pinks"),
("Coral", 0xFF7F50, "Pinks"),
("Watermelon", 0xFC6C85, "Pinks"),
("Flamingo", 0xFC8EAC, "Pinks"),
("Bubblegum", 0xFFC1CC, "Pinks"),
("Fuchsia", 0xFF00FF, "Pinks"),
("Magenta", 0xFF00FF, "Pinks"),
("Mauve", 0xE0B0FF, "Pinks"),
("Puce", 0xCC8899, "Pinks"),
("Thistle", 0xD8BFD8, "Pinks"),
("Orchid", 0xDA70D6, "Pinks"),
# ---- Oranges ----
("Orange", 0xFFA500, "Oranges"),
("DarkOrange", 0xFF8C00, "Oranges"),
("Tangerine", 0xF28500, "Oranges"),
("Pumpkin", 0xFF7518, "Oranges"),
("Apricot", 0xFBCEB1, "Oranges"),
("Peach", 0xFFE5B4, "Oranges"),
("Cantaloupe", 0xFFA62B, "Oranges"),
("Amber", 0xFFBF00, "Oranges"),
("Bronze", 0xCD7F32, "Oranges"),
("Copper", 0xB87333, "Oranges"),
("Marigold", 0xEAA221, "Oranges"),
("Carrot", 0xED9121, "Oranges"),
("Persimmon", 0xEC5800, "Oranges"),
("Papaya", 0xFF9E2C, "Oranges"),
("Sunset", 0xFAD6A5, "Oranges"),
# ---- Yellows ----
("Yellow", 0xFFFF00, "Yellows"),
("LightYellow", 0xFFFFE0, "Yellows"),
("Gold", 0xFFD700, "Yellows"),
("Goldenrod", 0xDAA520, "Yellows"),
("Lemon", 0xFFF700, "Yellows"),
("Canary", 0xFFEF00, "Yellows"),
("Mustard", 0xFFDB58, "Yellows"),
("Flax", 0xEEDC82, "Yellows"),
("Wheat", 0xF5DEB3, "Yellows"),
("Corn", 0xFBEC5D, "Yellows"),
("Dandelion", 0xF0E130, "Yellows"),
("Saffron", 0xF4C430, "Yellows"),
("Khaki", 0xF0E68C, "Yellows"),
("DarkKhaki", 0xBDB76B, "Yellows"),
("Straw", 0xE4D96F, "Yellows"),
# ---- Browns ----
("Brown", 0x8B4513, "Browns"),
("SaddleBrown", 0x8B4513, "Browns"),
("Sienna", 0xA0522D, "Browns"),
("Chocolate", 0xD2691E, "Browns"),
("Peru", 0xCD853F, "Browns"),
("Tan", 0xD2B48C, "Browns"),
("BurlyWood", 0xDEB887, "Browns"),
("Sand", 0xC2B280, "Browns"),
("Coffee", 0x6F4E37, "Browns"),
("Espresso", 0x4B3621, "Browns"),
("Mahogany", 0xC04000, "Browns"),
("Walnut", 0x773F1A, "Browns"),
("Umber", 0x635147, "Browns"),
("Sepia", 0x704214, "Browns"),
("Taupe", 0x483C32, "Browns"),
("Fawn", 0xE5AA70, "Browns"),
("Caramel", 0xC68E17, "Browns"),
("Cocoa", 0xD2691E, "Browns"),
("Hazel", 0x8E7618, "Browns"),
("Wenge", 0x645452, "Browns"),
# ---- Greens ----
("Green", 0x008000, "Greens"),
("Lime", 0x00FF00, "Greens"),
("LimeGreen", 0x32CD32, "Greens"),
("LawnGreen", 0x7CFC00, "Greens"),
("Chartreuse", 0x7FFF00, "Greens"),
("GreenYellow", 0xADFF2F, "Greens"),
("SpringGreen", 0x00FF7F, "Greens"),
("MintGreen", 0x98FF98, "Greens"),
("SeaGreen", 0x2E8B57, "Greens"),
("MediumSeaGreen", 0x3CB371, "Greens"),
("ForestGreen", 0x228B22, "Greens"),
("DarkGreen", 0x006400, "Greens"),
("OliveDrab", 0x6B8E23, "Greens"),
("Olive", 0x808000, "Greens"),
("Moss", 0x8A9A5B, "Greens"),
("Fern", 0x4F7942, "Greens"),
("Emerald", 0x50C878, "Greens"),
("Jade", 0x00A86B, "Greens"),
("Malachite", 0x0BDA51, "Greens"),
("Shamrock", 0x009E60, "Greens"),
("Pistachio", 0x93C572, "Greens"),
("Avocado", 0x568203, "Greens"),
("Pine", 0x01796F, "Greens"),
("Sage", 0x9CAF88, "Greens"),
("Kelly", 0x4CBB17, "Greens"),
("Hunter", 0x355E3B, "Greens"),
("Basil", 0x579229, "Greens"),
("Clover", 0x2E8B57, "Greens"),
("Juniper", 0x6D9A79, "Greens"),
("Neon", 0x39FF14, "Greens"),
# ---- Cyans / teals ----
("Cyan", 0x00FFFF, "Cyans"),
("Aqua", 0x00FFFF, "Cyans"),
("LightCyan", 0xE0FFFF, "Cyans"),
("PaleTurquoise", 0xAFEEEE, "Cyans"),
("Aquamarine", 0x7FFFD4, "Cyans"),
("Turquoise", 0x40E0D0, "Cyans"),
("MediumTurquoise",0x48D1CC, "Cyans"),
("DarkTurquoise", 0x00CED1, "Cyans"),
("Teal", 0x008080, "Cyans"),
("DarkCyan", 0x008B8B, "Cyans"),
("CadetBlue", 0x5F9EA0, "Cyans"),
("Lagoon", 0x018E8E, "Cyans"),
("Seafoam", 0x93E9BE, "Cyans"),
("Cerulean", 0x007BA7, "Cyans"),
("SkyBlueLight", 0x80DAEB, "Cyans"),
("Robin", 0x00CCCC, "Cyans"),
("Verdigris", 0x43B3AE, "Cyans"),
("Celadon", 0xACE1AF, "Cyans"),
# ---- Blues ----
("Blue", 0x0000FF, "Blues"),
("LightBlue", 0xADD8E6, "Blues"),
("PowderBlue", 0xB0E0E6, "Blues"),
("SkyBlue", 0x87CEEB, "Blues"),
("LightSkyBlue", 0x87CEFA, "Blues"),
("DeepSkyBlue", 0x00BFFF, "Blues"),
("DodgerBlue", 0x1E90FF, "Blues"),
("CornflowerBlue", 0x6495ED, "Blues"),
("SteelBlue", 0x4682B4, "Blues"),
("RoyalBlue", 0x4169E1, "Blues"),
("MediumBlue", 0x0000CD, "Blues"),
("DarkBlue", 0x00008B, "Blues"),
("Navy", 0x000080, "Blues"),
("MidnightBlue", 0x191970, "Blues"),
("Cobalt", 0x0047AB, "Blues"),
("Sapphire", 0x0F52BA, "Blues"),
("Denim", 0x1560BD, "Blues"),
("Indigo", 0x4B0082, "Blues"),
("Prussian", 0x003153, "Blues"),
("Ultramarine", 0x3F00FF, "Blues"),
("Periwinkle", 0xCCCCFF, "Blues"),
("Iris", 0x5A4FCF, "Blues"),
("Glaucous", 0x6082B6, "Blues"),
("Zaffre", 0x0014A8, "Blues"),
("Berry", 0x2E2D88, "Blues"),
# ---- Purples ----
("Purple", 0x800080, "Purples"),
("Violet", 0xEE82EE, "Purples"),
("DarkViolet", 0x9400D3, "Purples"),
("BlueViolet", 0x8A2BE2, "Purples"),
("MediumPurple", 0x9370DB, "Purples"),
("Amethyst", 0x9966CC, "Purples"),
("Plum", 0x8E4585, "Purples"),
("Eggplant", 0x614051, "Purples"),
("Grape", 0x6F2DA8, "Purples"),
("Wine", 0x722F37, "Purples"),
("Mulberry", 0xC54B8C, "Purples"),
("Lilac", 0xC8A2C8, "Purples"),
("Wisteria", 0xC9A0DC, "Purples"),
("Heliotrope", 0xDF73FF, "Purples"),
("Byzantium", 0x702963, "Purples"),
("Tyrian", 0x66023C, "Purples"),
("RebeccaPurple", 0x663399, "Purples"),
("Orchid2", 0xAF69EF, "Purples"),
]
def guard_check():
names = [p[0] for p in PALETTE]
dup = set(n for n in names if names.count(n) > 1)
if dup:
print("DUPLICATE NAMES:", dup, file=sys.stderr); sys.exit(1)
return names
def emit_ludic(path):
lines = []
lines.append("# ============================================================================")
lines.append("# emit_color.ludic — the named-color palette, resolved at compile time.")
lines.append("#")
lines.append("# `Color.Name` in a game lowers to a plain 0xRRGGBB int here: no runtime cost,")
lines.append("# no allocation, identical codegen to writing the hex by hand. Unknown names are")
lines.append("# a compile error (color_lookup returns -1, which emit_expr reports).")
lines.append("#")
lines.append("# GENERATED by scratchpad/palette.py from the single source-of-truth palette.")
lines.append("# Edit the palette there and regenerate; do not hand-edit this file.")
lines.append("# ============================================================================")
lines.append("")
lines.append("function color_lookup(name: pointer) -> int {")
for (nm, hexv, grp) in PALETTE:
lines.append(f' if (name == "{nm}") {{ return 0x{hexv:06X} }}')
lines.append(" return -1")
lines.append("}")
lines.append("")
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")
def emit_json(path):
groups = {}
order = []
for (nm, hexv, grp) in PALETTE:
if grp not in groups:
groups[grp] = []; order.append(grp)
groups[grp].append({"name": nm, "hex": f"{hexv:06X}"})
out = {"count": len(PALETTE), "groups": [{"name": g, "colors": groups[g]} for g in order]}
with open(path, "w") as f:
json.dump(out, f, indent=2)
if __name__ == "__main__":
names = guard_check()
root = os.path.dirname(os.path.abspath(__file__))
repo = "/Users/orkuncakilkaya/workspace/gpp"
emit_ludic(os.path.join(repo, "selfhost", "backend", "stdlib", "emit_color.ludic"))
emit_json(os.path.join(root, "palette.json"))
print(f"OK {len(PALETTE)} colors ({len(set(names))} unique names)")