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

@ -14,7 +14,7 @@ Closes #
- [ ] `ludic-fmt` leaves the touched files unchanged (2-space, LF, UTF-8).
- [ ] New/changed stdlib symbols are documented under `docs/language/**` and
registered in `tools/docgen/inventory.json`
(`python3 tools/docgen/check.py` passes).
(`bin/x docs-gen && bin/x docs-check build/pages` passes).
- [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org).
- [ ] No new C / Python / JS in tooling (Ludic only), and no generated
artifacts committed outside `build/` / `bin/`.

View file

@ -17,20 +17,20 @@ jobs:
# advertises `docker`, not the GitHub-ism `ubuntu-latest`.
runs-on: docker
# Reuse the runner's own base image (Debian bookworm with git + node already
# present) and add just the two things the toolchain needs: a modern clang
# (LLVM 16 — the IR uses opaque pointers, so clang 15+ is required) and
# python3 for the docs/vocabulary checks. A prebuilt image with these baked
# in is the obvious future speed-up (see issue #33's packaging work).
# present) and add just the one thing the toolchain needs: a modern clang
# (LLVM 16 — the IR uses opaque pointers, so clang 15+ is required). The docs
# generator and its guards are now Ludic, so the job carries no Python. A
# prebuilt image with clang baked in is the obvious future speed-up (see
# issue #33's packaging work).
container: node:20-bookworm
steps:
- name: Install clang-16 and python3
- name: Install clang-16
run: |
set -eu
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq --no-install-recommends clang-16 python3 git ca-certificates
apt-get install -y -qq --no-install-recommends clang-16 git ca-certificates
clang-16 --version | head -1
python3 --version
- name: Check out the triggering commit
run: |
@ -75,11 +75,12 @@ jobs:
- name: Docs cover the implementation
run: |
set -eu
# check-impl / check-vocabulary are written in Ludic and run through
# x — no Python. (check-vocabulary also runs in `x test-tools`.) The
# site generator (gen.py) is still Python; its port is tracked.
# The whole docs toolchain is written in Ludic and runs through x —
# no Python anywhere. check-impl / check-vocabulary / check-docs guard
# the sources; docs-gen builds the site and docs-check is its coverage
# + integrity guard. (check-vocabulary also runs in `x test-tools`.)
bin/x check-impl
bin/x check-vocabulary
bin/x check-docs
python3 tools/docgen/gen.py --out build/pages
python3 tools/docgen/check.py build/pages
bin/x docs-gen --out build/pages
bin/x docs-check build/pages

View file

@ -10,6 +10,7 @@ on:
paths:
- 'docs/**'
- 'tools/docgen/**'
- 'tools/x/**'
- '.forgejo/workflows/docs.yml'
workflow_dispatch: {}
@ -23,11 +24,20 @@ jobs:
# GitHub-ism this runner does not register, so a job requesting it sits in
# "Waiting" forever with "no online runner found matching this label".
runs-on: docker
# Run in a Python image: the generator is pure-Python stdlib, and this image
# already has git for the clone + publish. No node actions are used, so the
# job never depends on the runner's base image having python installed.
container: python:3.12
# The generator is now Ludic, so this builds the toolchain from its IR seed
# (clang assembles the seed into bin/ludicc, which compiles bin/x) exactly
# like the ci workflow, then runs `x docs-gen`. node:20-bookworm carries git
# for the clone + publish; clang-16 is the only extra the bootstrap needs.
container: node:20-bookworm
steps:
- name: Install clang-16
run: |
set -eu
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq --no-install-recommends clang-16 git ca-certificates
clang-16 --version | head -1
- name: Generate the documentation site
env:
SOURCE_REF: ${{ github.ref_name }}
@ -36,9 +46,19 @@ jobs:
git config --global --add safe.directory '*'
git clone --depth 1 --branch "${SOURCE_REF:-main}" \
https://git.workshopsoft.io/workshopsoft/ludic.git src
python3 --version
python3 src/tools/docgen/gen.py --out public
python3 src/tools/docgen/check.py public
cd src
# The toolchain is macOS-first; on this Linux runner it links against a
# tiny C-free IR shim supplying the Darwin stdout/stderr globals over
# glibc's, injected through LUDIC_CC. docs-gen is a pure CLI (no
# windowing), so the C-free bootstrap is all it needs.
export LUDIC_CC="clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll"
export LUDIC_HOME="$(pwd)"
mkdir -p bin
clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc
bin/ludicc tools/x/main.ludic -o bin/x
bin/x docs-gen --out ../public
bin/x docs-check ../public
cd ..
echo "--- generated files ---"
ls -la public

View file

@ -5,8 +5,8 @@
# no allocation, identical codegen to writing the hex by hand. Unknown names are
# a compile error (color_lookup returns -1, which emit_expr reports).
#
# GENERATED by scratchpad/palette.py from the single source-of-truth palette.
# Edit the palette there and regenerate; do not hand-edit this file.
# GENERATED by `x docs-palette` from the single source-of-truth palette table
# in tools/x/docgen.ludic. Edit the palette there and regenerate; do not hand-edit.
# ============================================================================
function color_lookup(name: pointer) -> int {

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)")

638
tools/x/docgen.ludic Normal file
View file

@ -0,0 +1,638 @@
# docgen.ludic — the Ludic documentation-site generator and its guards, ported
# off Python (gen.py / check.py / palette.py). Three x subcommands:
#
# x docs-palette emit selfhost/backend/stdlib/emit_color.ludic +
# docs/language/colors/palette.json from the palette
# table below (the named-colour source of truth).
# x docs-gen [--out DIR] parse docs/language/** + docs/site/*, read the asset
# templates, emit every page + symbols.json + the
# highlighter + .nojekyll into DIR (default build/pages).
# x docs-check [DIR] the coverage/integrity guard over a generated site.
#
# String work is over NUL-terminated byte buffers; helpers here mirror gen.py's
# html.escape / json.dumps / front-matter parsing closely enough to reproduce the
# Python generator's output byte-for-byte (verified against the oracle in x test).
# ---- a growable byte buffer (avoids O(n^2) string building) ------------------
property Sb { data: pointer = null, len: int = 0, cap: int = 0 }
function sb_new() -> Sb {
let s = new Sb
s.cap = 256
s.data = bytes(256)
s.len = 0
return s
}
function sb_grow(s: Sb, need: int) -> void {
if s.len + need + 1 <= s.cap { return }
var nc = s.cap * 2
while nc < s.len + need + 1 { nc = nc * 2 }
let nd = bytes(nc)
var i = 0
while i < s.len { nd[i] = s.data[i]; i = i + 1 }
s.data = nd
s.cap = nc
}
function sb_puts(s: Sb, t: pointer) -> void {
let n = slen(t)
sb_grow(s, n)
var i = 0
while i < n { s.data[s.len] = t[i]; s.len = s.len + 1; i = i + 1 }
}
function sb_putc(s: Sb, c: int) -> void {
sb_grow(s, 1)
s.data[s.len] = c
s.len = s.len + 1
}
function sb_str(s: Sb) -> pointer {
s.data[s.len] = 0
return s.data
}
# ---- HTML escaping (html.escape) --------------------------------------------
# quote=false escapes & < > ; quote=true also escapes " and ' (Python entities:
# &amp; &lt; &gt; &quot; &#x27;). Two-pass so a big code block stays O(n).
function esc_q(s: pointer, quote: bool) -> pointer {
if s == null { return "" }
let n = slen(s)
let b = sb_new()
var i = 0
while i < n {
let c = s[i]
if c == 38 { sb_puts(b, "&amp;") }
else if c == 60 { sb_puts(b, "&lt;") }
else if c == 62 { sb_puts(b, "&gt;") }
else if quote and c == 34 { sb_puts(b, "&quot;") }
else if quote and c == 39 { sb_puts(b, "&#x27;") }
else { sb_putc(b, c) }
i = i + 1
}
return sb_str(b)
}
function hesc(s: pointer) -> pointer { return esc_q(s, false) }
function hescattr(s: pointer) -> pointer { return esc_q(s, true) }
# ---- generic byte/character predicates --------------------------------------
# Python \s = [ \t\n\r\f\v]
function is_space_all(c: int) -> bool {
return c == 32 or c == 9 or c == 10 or c == 13 or c == 12 or c == 11
}
# Python \w = [A-Za-z0-9_]
function is_word(c: int) -> bool {
return (c >= 48 and c <= 57) or (c >= 65 and c <= 90) or (c >= 97 and c <= 122) or c == 95
}
# strip only leading '\n' (Python str.lstrip("\n"))
function lstrip_nl(s: pointer) -> pointer {
var i = 0
while s[i] == 10 { i = i + 1 }
return sslice(s, i, slen(s))
}
# lowercase ASCII A-Z
function lower_ascii(s: pointer) -> pointer {
let n = slen(s)
let b = bytes(n + 1)
var i = 0
while i < n {
var c = s[i]
if c >= 65 and c <= 90 { c = c + 32 }
b[i] = c
i = i + 1
}
b[n] = 0
return b
}
# Python str.title(): capitalise the first letter of each alpha run, lower the rest
function title_case(s: pointer) -> pointer {
let n = slen(s)
let b = bytes(n + 1)
var i = 0
var prev_alpha = false
while i < n {
var c = s[i]
let al = (c >= 65 and c <= 90) or (c >= 97 and c <= 122)
if al {
if prev_alpha { if c >= 65 and c <= 90 { c = c + 32 } }
else { if c >= 97 and c <= 122 { c = c - 32 } }
}
b[i] = c
prev_alpha = al
i = i + 1
}
b[n] = 0
return b
}
# lexicographic byte compare: <0, 0, >0
function str_cmp(a: pointer, b: pointer) -> int {
var i = 0
while a[i] != 0 and b[i] != 0 {
let ca = a[i] & 255
let cb = b[i] & 255
if ca != cb { return ca - cb }
i = i + 1
}
return (a[i] & 255) - (b[i] & 255)
}
function str_gt(a: pointer, b: pointer) -> bool { return str_cmp(a, b) > 0 }
# does s end with suffix suf?
function s_ends(s: pointer, suf: pointer) -> bool {
let ls = slen(s)
let lf = slen(suf)
if lf > ls { return false }
return s_starts_at(s, ls - lf, suf)
}
# split on runs of ASCII whitespace (Python str.split() with no arg)
function split_ws(s: pointer) -> []pointer {
let out = new []pointer
let n = slen(s)
var i = 0
while i < n {
while i < n and is_space_all(s[i]) { i = i + 1 }
var e = i
while e < n and not is_space_all(s[e]) { e = e + 1 }
if e > i { push(out, sslice(s, i, e)) }
i = e
}
return out
}
# stable insertion sort of a []pointer, ascending byte order (Python sorted())
function strs_sort(a: []pointer) -> void {
var i = 1
while i < len(a) {
let cur = a[i]
var j = i - 1
while j >= 0 and str_gt(a[j], cur) { a[j + 1] = a[j]; j = j - 1 }
a[j + 1] = cur
i = i + 1
}
}
# `<code>` backtick spans: `x` -> <code>x</code> (inner text NOT escaped).
# Mirrors codeify's re.sub(r"`([^`]+)`", ...): needs >=1 non-backtick inside.
function codeify(s: pointer) -> pointer {
let n = slen(s)
let b = sb_new()
var i = 0
while i < n {
if s[i] == 96 {
var j = i + 1
while j < n and s[j] != 96 { j = j + 1 }
if j < n and j > i + 1 {
sb_puts(b, "<code>")
var k = i + 1
while k < j { sb_putc(b, s[k]); k = k + 1 }
sb_puts(b, "</code>")
i = j + 1
} else {
sb_putc(b, 96)
i = i + 1
}
} else {
sb_putc(b, s[i])
i = i + 1
}
}
return sb_str(b)
}
# ---- a Python-json.dumps-compatible JSON writer over the JVal tree ----------
function jvs(s: pointer) -> JVal { let v = jval_new(JV_STR); v.s = s; return v }
function jvi(n: int) -> JVal { let v = jval_new(JV_NUM); v.num = n; return v }
function jvo() -> JVal { return jval_new(JV_OBJ) }
function jva() -> JVal { return jval_new(JV_ARR) }
# like Python dict assignment: update in place at an existing key (keeping its
# first-insertion position, taking the new value), else append. Matters where a
# token collides across kinds (e.g. `exit`/`string`/`fixed`) in sym["tips"].
function jvput(o: JVal, k: pointer, v: JVal) -> void {
var i = 0
while i < len(o.keys) { if o.keys[i] == k { o.kids[i] = v; return }; i = i + 1 }
push(o.keys, k)
push(o.kids, v)
}
function jvpush(a: JVal, v: JVal) -> void { push(a.kids, v) }
function hex_lower(n: int) -> int {
if n < 10 { return 48 + n }
return 87 + n
}
function jemit_u(b: Sb, cp: int) -> void {
sb_putc(b, 92); sb_putc(b, 117) # \u
sb_putc(b, hex_lower((cp >> 12) & 15))
sb_putc(b, hex_lower((cp >> 8) & 15))
sb_putc(b, hex_lower((cp >> 4) & 15))
sb_putc(b, hex_lower(cp & 15))
}
# emit the JSON-escaped content of s (no surrounding quotes). ensure_ascii: emit
# non-ASCII as \uXXXX (UTF-8 decoded, surrogate pairs for astral); else raw bytes.
function json_escape_into(b: Sb, s: pointer, ensure_ascii: bool) -> void {
let n = slen(s)
var i = 0
while i < n {
let c = s[i] & 255
if c == 34 { sb_putc(b, 92); sb_putc(b, 34); i = i + 1 }
else if c == 92 { sb_putc(b, 92); sb_putc(b, 92); i = i + 1 }
else if c == 8 { sb_putc(b, 92); sb_putc(b, 98); i = i + 1 }
else if c == 9 { sb_putc(b, 92); sb_putc(b, 116); i = i + 1 }
else if c == 10 { sb_putc(b, 92); sb_putc(b, 110); i = i + 1 }
else if c == 12 { sb_putc(b, 92); sb_putc(b, 102); i = i + 1 }
else if c == 13 { sb_putc(b, 92); sb_putc(b, 114); i = i + 1 }
else if c < 32 {
sb_putc(b, 92); sb_putc(b, 117); sb_putc(b, 48); sb_putc(b, 48)
sb_putc(b, hex_lower((c >> 4) & 15)); sb_putc(b, hex_lower(c & 15))
i = i + 1
}
else if c < 128 { sb_putc(b, c); i = i + 1 }
else if not ensure_ascii { sb_putc(b, c); i = i + 1 }
else {
var cp = 0
var nb = 1
if c < 224 { cp = c - 192; nb = 2 }
else if c < 240 { cp = c - 224; nb = 3 }
else { cp = c - 240; nb = 4 }
var k = 1
while k < nb and i + k < n { cp = cp * 64 + ((s[i + k] & 255) - 128); k = k + 1 }
i = i + nb
if cp <= 65535 { jemit_u(b, cp) }
else {
let c2 = cp - 65536
jemit_u(b, 55296 + (c2 >> 10))
jemit_u(b, 56320 + (c2 & 1023))
}
}
}
}
function json_indent(b: Sb, depth: int) -> void {
var i = 0
while i < depth { sb_putc(b, 32); sb_putc(b, 32); i = i + 1 }
}
# pretty=true reproduces json.dumps(indent=2); pretty=false the compact default
# (item sep ", ", key sep ": "). ensure_ascii toggles \uXXXX for non-ASCII.
function jwrite(b: Sb, v: JVal, pretty: bool, depth: int, ea: bool) -> void {
if v.t == JV_NULL { sb_puts(b, "null"); return }
if v.t == JV_BOOL {
if v.b == 1 { sb_puts(b, "true") } else { sb_puts(b, "false") }
return
}
if v.t == JV_NUM { sb_puts(b, string(v.num)); return }
if v.t == JV_STR { sb_putc(b, 34); json_escape_into(b, v.s, ea); sb_putc(b, 34); return }
if v.t == JV_ARR {
let m = len(v.kids)
if m == 0 { sb_puts(b, "[]"); return }
sb_putc(b, 91)
var i = 0
while i < m {
if pretty { sb_putc(b, 10); json_indent(b, depth + 1) }
jwrite(b, v.kids[i], pretty, depth + 1, ea)
if i < m - 1 { sb_putc(b, 44); if not pretty { sb_putc(b, 32) } }
i = i + 1
}
if pretty { sb_putc(b, 10); json_indent(b, depth) }
sb_putc(b, 93)
return
}
let m = len(v.keys)
if m == 0 { sb_puts(b, "{}"); return }
sb_putc(b, 123)
var i = 0
while i < m {
if pretty { sb_putc(b, 10); json_indent(b, depth + 1) }
sb_putc(b, 34); json_escape_into(b, v.keys[i], ea); sb_putc(b, 34)
sb_putc(b, 58); sb_putc(b, 32)
jwrite(b, v.kids[i], pretty, depth + 1, ea)
if i < m - 1 { sb_putc(b, 44); if not pretty { sb_putc(b, 32) } }
i = i + 1
}
if pretty { sb_putc(b, 10); json_indent(b, depth) }
sb_putc(b, 125)
}
function json_dumps(v: JVal, pretty: bool, ea: bool) -> pointer {
let b = sb_new()
jwrite(b, v, pretty, 0, ea)
return sb_str(b)
}
# ============================================================================
# palette — the named-colour source of truth (ported from palette.py)
# ============================================================================
var pal_names: []pointer = new []pointer
var pal_hexes: []pointer = new []pointer # 6-digit uppercase, no prefix
var pal_groups: []pointer = new []pointer
var pal_ready: int = 0
function pal_add(nm: pointer, hx: pointer, grp: pointer) -> void {
push(pal_names, nm)
push(pal_hexes, hx)
push(pal_groups, grp)
}
function palette_init() -> void {
if pal_ready == 1 { return }
pal_ready = 1
pal_names = new []pointer
pal_hexes = new []pointer
pal_groups = new []pointer
pal_add("White", "FFFFFF", "Whites")
pal_add("Snow", "FFFAFA", "Whites")
pal_add("Ivory", "FFFFF0", "Whites")
pal_add("EggShellWhite", "F0EAD6", "Whites")
pal_add("FloralWhite", "FFFAF0", "Whites")
pal_add("SeaShell", "FFF5EE", "Whites")
pal_add("Linen", "FAF0E6", "Whites")
pal_add("AntiqueWhite", "FAEBD7", "Whites")
pal_add("OldLace", "FDF5E6", "Whites")
pal_add("Beige", "F5F5DC", "Whites")
pal_add("Cream", "FFFDD0", "Whites")
pal_add("Honeydew", "F0FFF0", "Whites")
pal_add("MintCream", "F5FFFA", "Whites")
pal_add("Azure", "F0FFFF", "Whites")
pal_add("AliceBlue", "F0F8FF", "Whites")
pal_add("GhostWhite", "F8F8FF", "Whites")
pal_add("WhiteSmoke", "F5F5F5", "Whites")
pal_add("Lavender", "E6E6FA", "Whites")
pal_add("Bone", "E3DAC9", "Whites")
pal_add("Parchment", "F1E9D2", "Whites")
pal_add("Gainsboro", "DCDCDC", "Grays")
pal_add("LightGray", "D3D3D3", "Grays")
pal_add("Silver", "C0C0C0", "Grays")
pal_add("Ash", "B2BEB5", "Grays")
pal_add("DarkGray", "A9A9A9", "Grays")
pal_add("Gray", "808080", "Grays")
pal_add("DimGray", "696969", "Grays")
pal_add("Nickel", "727472", "Grays")
pal_add("Slate", "708090", "Grays")
pal_add("SlateGray", "708090", "Grays")
pal_add("LightSlateGray", "778899", "Grays")
pal_add("Gunmetal", "2A3439", "Grays")
pal_add("Charcoal", "36454F", "Grays")
pal_add("Graphite", "1C1C1C", "Grays")
pal_add("Onyx", "353839", "Grays")
pal_add("Jet", "343434", "Grays")
pal_add("Black", "000000", "Grays")
pal_add("EerieBlack", "1B1B1B", "Grays")
pal_add("RaisinBlack", "242124", "Grays")
pal_add("Ebony", "555D50", "Grays")
pal_add("Red", "FF0000", "Reds")
pal_add("Crimson", "DC143C", "Reds")
pal_add("Scarlet", "FF2400", "Reds")
pal_add("Vermilion", "E34234", "Reds")
pal_add("FireBrick", "B22222", "Reds")
pal_add("Cinnabar", "E44D2E", "Reds")
pal_add("DarkRed", "8B0000", "Reds")
pal_add("Maroon", "800000", "Reds")
pal_add("Ruby", "E0115F", "Reds")
pal_add("Cardinal", "C41E3A", "Reds")
pal_add("IndianRed", "CD5C5C", "Reds")
pal_add("Rust", "B7410E", "Reds")
pal_add("Sangria", "92000A", "Reds")
pal_add("Redwood", "A45A52", "Reds")
pal_add("Cerise", "DE3163", "Reds")
pal_add("Amaranth", "E52B50", "Reds")
pal_add("Carmine", "960018", "Reds")
pal_add("Chestnut", "954535", "Reds")
pal_add("Brick", "CB4154", "Reds")
pal_add("TerraCotta", "E2725B", "Reds")
pal_add("Pink", "FFC0CB", "Pinks")
pal_add("LightPink", "FFB6C1", "Pinks")
pal_add("HotPink", "FF69B4", "Pinks")
pal_add("DeepPink", "FF1493", "Pinks")
pal_add("PaleVioletRed", "DB7093", "Pinks")
pal_add("Rose", "FF007F", "Pinks")
pal_add("Blush", "DE5D83", "Pinks")
pal_add("Salmon", "FA8072", "Pinks")
pal_add("LightSalmon", "FFA07A", "Pinks")
pal_add("DarkSalmon", "E9967A", "Pinks")
pal_add("Coral", "FF7F50", "Pinks")
pal_add("Watermelon", "FC6C85", "Pinks")
pal_add("Flamingo", "FC8EAC", "Pinks")
pal_add("Bubblegum", "FFC1CC", "Pinks")
pal_add("Fuchsia", "FF00FF", "Pinks")
pal_add("Magenta", "FF00FF", "Pinks")
pal_add("Mauve", "E0B0FF", "Pinks")
pal_add("Puce", "CC8899", "Pinks")
pal_add("Thistle", "D8BFD8", "Pinks")
pal_add("Orchid", "DA70D6", "Pinks")
pal_add("Orange", "FFA500", "Oranges")
pal_add("DarkOrange", "FF8C00", "Oranges")
pal_add("Tangerine", "F28500", "Oranges")
pal_add("Pumpkin", "FF7518", "Oranges")
pal_add("Apricot", "FBCEB1", "Oranges")
pal_add("Peach", "FFE5B4", "Oranges")
pal_add("Cantaloupe", "FFA62B", "Oranges")
pal_add("Amber", "FFBF00", "Oranges")
pal_add("Bronze", "CD7F32", "Oranges")
pal_add("Copper", "B87333", "Oranges")
pal_add("Marigold", "EAA221", "Oranges")
pal_add("Carrot", "ED9121", "Oranges")
pal_add("Persimmon", "EC5800", "Oranges")
pal_add("Papaya", "FF9E2C", "Oranges")
pal_add("Sunset", "FAD6A5", "Oranges")
pal_add("Yellow", "FFFF00", "Yellows")
pal_add("LightYellow", "FFFFE0", "Yellows")
pal_add("Gold", "FFD700", "Yellows")
pal_add("Goldenrod", "DAA520", "Yellows")
pal_add("Lemon", "FFF700", "Yellows")
pal_add("Canary", "FFEF00", "Yellows")
pal_add("Mustard", "FFDB58", "Yellows")
pal_add("Flax", "EEDC82", "Yellows")
pal_add("Wheat", "F5DEB3", "Yellows")
pal_add("Corn", "FBEC5D", "Yellows")
pal_add("Dandelion", "F0E130", "Yellows")
pal_add("Saffron", "F4C430", "Yellows")
pal_add("Khaki", "F0E68C", "Yellows")
pal_add("DarkKhaki", "BDB76B", "Yellows")
pal_add("Straw", "E4D96F", "Yellows")
pal_add("Brown", "8B4513", "Browns")
pal_add("SaddleBrown", "8B4513", "Browns")
pal_add("Sienna", "A0522D", "Browns")
pal_add("Chocolate", "D2691E", "Browns")
pal_add("Peru", "CD853F", "Browns")
pal_add("Tan", "D2B48C", "Browns")
pal_add("BurlyWood", "DEB887", "Browns")
pal_add("Sand", "C2B280", "Browns")
pal_add("Coffee", "6F4E37", "Browns")
pal_add("Espresso", "4B3621", "Browns")
pal_add("Mahogany", "C04000", "Browns")
pal_add("Walnut", "773F1A", "Browns")
pal_add("Umber", "635147", "Browns")
pal_add("Sepia", "704214", "Browns")
pal_add("Taupe", "483C32", "Browns")
pal_add("Fawn", "E5AA70", "Browns")
pal_add("Caramel", "C68E17", "Browns")
pal_add("Cocoa", "D2691E", "Browns")
pal_add("Hazel", "8E7618", "Browns")
pal_add("Wenge", "645452", "Browns")
pal_add("Green", "008000", "Greens")
pal_add("Lime", "00FF00", "Greens")
pal_add("LimeGreen", "32CD32", "Greens")
pal_add("LawnGreen", "7CFC00", "Greens")
pal_add("Chartreuse", "7FFF00", "Greens")
pal_add("GreenYellow", "ADFF2F", "Greens")
pal_add("SpringGreen", "00FF7F", "Greens")
pal_add("MintGreen", "98FF98", "Greens")
pal_add("SeaGreen", "2E8B57", "Greens")
pal_add("MediumSeaGreen", "3CB371", "Greens")
pal_add("ForestGreen", "228B22", "Greens")
pal_add("DarkGreen", "006400", "Greens")
pal_add("OliveDrab", "6B8E23", "Greens")
pal_add("Olive", "808000", "Greens")
pal_add("Moss", "8A9A5B", "Greens")
pal_add("Fern", "4F7942", "Greens")
pal_add("Emerald", "50C878", "Greens")
pal_add("Jade", "00A86B", "Greens")
pal_add("Malachite", "0BDA51", "Greens")
pal_add("Shamrock", "009E60", "Greens")
pal_add("Pistachio", "93C572", "Greens")
pal_add("Avocado", "568203", "Greens")
pal_add("Pine", "01796F", "Greens")
pal_add("Sage", "9CAF88", "Greens")
pal_add("Kelly", "4CBB17", "Greens")
pal_add("Hunter", "355E3B", "Greens")
pal_add("Basil", "579229", "Greens")
pal_add("Clover", "2E8B57", "Greens")
pal_add("Juniper", "6D9A79", "Greens")
pal_add("Neon", "39FF14", "Greens")
pal_add("Cyan", "00FFFF", "Cyans")
pal_add("Aqua", "00FFFF", "Cyans")
pal_add("LightCyan", "E0FFFF", "Cyans")
pal_add("PaleTurquoise", "AFEEEE", "Cyans")
pal_add("Aquamarine", "7FFFD4", "Cyans")
pal_add("Turquoise", "40E0D0", "Cyans")
pal_add("MediumTurquoise", "48D1CC", "Cyans")
pal_add("DarkTurquoise", "00CED1", "Cyans")
pal_add("Teal", "008080", "Cyans")
pal_add("DarkCyan", "008B8B", "Cyans")
pal_add("CadetBlue", "5F9EA0", "Cyans")
pal_add("Lagoon", "018E8E", "Cyans")
pal_add("Seafoam", "93E9BE", "Cyans")
pal_add("Cerulean", "007BA7", "Cyans")
pal_add("SkyBlueLight", "80DAEB", "Cyans")
pal_add("Robin", "00CCCC", "Cyans")
pal_add("Verdigris", "43B3AE", "Cyans")
pal_add("Celadon", "ACE1AF", "Cyans")
pal_add("Blue", "0000FF", "Blues")
pal_add("LightBlue", "ADD8E6", "Blues")
pal_add("PowderBlue", "B0E0E6", "Blues")
pal_add("SkyBlue", "87CEEB", "Blues")
pal_add("LightSkyBlue", "87CEFA", "Blues")
pal_add("DeepSkyBlue", "00BFFF", "Blues")
pal_add("DodgerBlue", "1E90FF", "Blues")
pal_add("CornflowerBlue", "6495ED", "Blues")
pal_add("SteelBlue", "4682B4", "Blues")
pal_add("RoyalBlue", "4169E1", "Blues")
pal_add("MediumBlue", "0000CD", "Blues")
pal_add("DarkBlue", "00008B", "Blues")
pal_add("Navy", "000080", "Blues")
pal_add("MidnightBlue", "191970", "Blues")
pal_add("Cobalt", "0047AB", "Blues")
pal_add("Sapphire", "0F52BA", "Blues")
pal_add("Denim", "1560BD", "Blues")
pal_add("Indigo", "4B0082", "Blues")
pal_add("Prussian", "003153", "Blues")
pal_add("Ultramarine", "3F00FF", "Blues")
pal_add("Periwinkle", "CCCCFF", "Blues")
pal_add("Iris", "5A4FCF", "Blues")
pal_add("Glaucous", "6082B6", "Blues")
pal_add("Zaffre", "0014A8", "Blues")
pal_add("Berry", "2E2D88", "Blues")
pal_add("Purple", "800080", "Purples")
pal_add("Violet", "EE82EE", "Purples")
pal_add("DarkViolet", "9400D3", "Purples")
pal_add("BlueViolet", "8A2BE2", "Purples")
pal_add("MediumPurple", "9370DB", "Purples")
pal_add("Amethyst", "9966CC", "Purples")
pal_add("Plum", "8E4585", "Purples")
pal_add("Eggplant", "614051", "Purples")
pal_add("Grape", "6F2DA8", "Purples")
pal_add("Wine", "722F37", "Purples")
pal_add("Mulberry", "C54B8C", "Purples")
pal_add("Lilac", "C8A2C8", "Purples")
pal_add("Wisteria", "C9A0DC", "Purples")
pal_add("Heliotrope", "DF73FF", "Purples")
pal_add("Byzantium", "702963", "Purples")
pal_add("Tyrian", "66023C", "Purples")
pal_add("RebeccaPurple", "663399", "Purples")
pal_add("Orchid2", "AF69EF", "Purples")
}
# emit selfhost/backend/stdlib/emit_color.ludic
function palette_emit_ludic(path: pointer) -> bool {
let b = sb_new()
sb_puts(b, "# ============================================================================\n")
sb_puts(b, "# emit_color.ludic — the named-color palette, resolved at compile time.\n")
sb_puts(b, "#\n")
sb_puts(b, "# `Color.Name` in a game lowers to a plain 0xRRGGBB int here: no runtime cost,\n")
sb_puts(b, "# no allocation, identical codegen to writing the hex by hand. Unknown names are\n")
sb_puts(b, "# a compile error (color_lookup returns -1, which emit_expr reports).\n")
sb_puts(b, "#\n")
sb_puts(b, "# GENERATED by `x docs-palette` from the single source-of-truth palette table\n")
sb_puts(b, "# in tools/x/docgen.ludic. Edit the palette there and regenerate; do not hand-edit.\n")
sb_puts(b, "# ============================================================================\n")
sb_puts(b, "\n")
sb_puts(b, "function color_lookup(name: pointer) -> int {\n")
var i = 0
while i < len(pal_names) {
sb_puts(b, ` if (name == "{pal_names[i]}") {{ return 0x{pal_hexes[i]} }}\n`)
i = i + 1
}
sb_puts(b, " return -1\n")
sb_puts(b, "}\n")
sb_puts(b, "\n")
return write_file(path, sb_str(b))
}
# emit docs/language/colors/palette.json (json.dump indent=2, no trailing nl)
function palette_emit_json(path: pointer) -> bool {
let b = sb_new()
sb_puts(b, "{\n")
sb_puts(b, ` "count": {string(len(pal_names))},\n`)
sb_puts(b, ` "groups": [` + "\n")
# groups in first-appearance order
let seen = new []pointer
var gi = 0
var firstg = true
while gi < len(pal_groups) {
let g = pal_groups[gi]
if not set_has(seen, g) {
push(seen, g)
if not firstg { sb_puts(b, ",\n") }
firstg = false
sb_puts(b, " {\n")
sb_puts(b, ` "name": "{g}",\n`)
sb_puts(b, ` "colors": [` + "\n")
var ci = 0
var firstc = true
while ci < len(pal_names) {
if pal_groups[ci] == g {
if not firstc { sb_puts(b, ",\n") }
firstc = false
sb_puts(b, " {\n")
sb_puts(b, ` "name": "{pal_names[ci]}",\n`)
sb_puts(b, ` "hex": "{pal_hexes[ci]}"\n`)
sb_puts(b, " }")
}
ci = ci + 1
}
sb_puts(b, "\n ]\n")
sb_puts(b, " }")
}
gi = gi + 1
}
sb_puts(b, "\n ]\n")
sb_puts(b, "}")
return write_file(path, sb_str(b))
}
function cmd_docs_palette() -> int {
palette_init()
# guard: duplicate names
let seen = new []pointer
var i = 0
while i < len(pal_names) {
if set_has(seen, pal_names[i]) { err(`DUPLICATE NAME: {pal_names[i]}\n`); return 1 }
push(seen, pal_names[i])
i = i + 1
}
if not palette_emit_ludic("selfhost/backend/stdlib/emit_color.ludic") { err("cannot write emit_color.ludic\n"); return 1 }
if not palette_emit_json("docs/language/colors/palette.json") { err("cannot write palette.json\n"); return 1 }
print(`OK {string(len(pal_names))} colors`)
return 0
}

288
tools/x/docgen_check.ludic Normal file
View file

@ -0,0 +1,288 @@
# docgen_check.ludic — `x docs-check [DIR]`: the coverage / integrity guard over
# a generated docs site, ported from tools/docgen/check.py. Fails (exit 1) if the
# pages contract is broken, an inventory symbol lacks a source file or page, a
# token is documented on two pages, a namespace/section is split across dirs, or
# a highlighter link target was not generated. Thin (un-expanded) symbols warn.
# a key -> set-of-values bucket (for token/dir conflict detection)
property Bucket { k1: pointer = "", k2: pointer = "", vals: []pointer }
function bucket_get(bs: []Bucket, k1: pointer, k2: pointer) -> Bucket {
var i = 0
while i < len(bs) {
if bs[i].k1 == k1 and bs[i].k2 == k2 { return bs[i] }
i = i + 1
}
let b = new Bucket
b.k1 = k1
b.k2 = k2
b.vals = new []pointer
push(bs, b)
return b
}
function bucket_add(bs: []Bucket, k1: pointer, k2: pointer, v: pointer) -> void {
let b = bucket_get(bs, k1, k2)
var i = 0
while i < len(b.vals) { if b.vals[i] == v { return }; i = i + 1 }
push(b.vals, v)
}
# ", ".join(sorted(vals))
function join_sorted(vals: []pointer) -> pointer {
strs_sort(vals)
let b = sb_new()
var i = 0
while i < len(vals) {
if i > 0 { sb_puts(b, ", ") }
sb_puts(b, vals[i])
i = i + 1
}
return sb_str(b)
}
# parse_front: like parse_doc but body is stripped on both ends (check.py)
function parse_front(path: pointer) -> Doc {
let d = new Doc
d.m = meta_new()
let text = read_file(path)
if text == null { d.body = ""; return d }
d.body = text
if not s_starts(text, "---") { return d }
let end = s_index(text, "\n---", 3)
if end < 0 { return d }
let fm = sslice(text, 3, end)
let fn2 = slen(fm)
var j = 0
while j < fn2 {
let ln = line_at(fm, j)
j = j + slen(ln) + 1
let t = s_trim(ln)
if slen(t) == 0 { continue }
let c = s_index(ln, ":", 0)
if c < 0 { continue }
meta_put(d.m, s_trim(sslice(ln, 0, c)), s_trim(sslice(ln, c + 1, slen(ln))))
}
d.body = s_trim(sslice(text, end + 4, slen(text)))
return d
}
# count Unicode codepoints (a byte is a lead unless (b & 0xC0) == 0x80)
function cp_len(s: pointer) -> int {
let n = slen(s)
var c = 0
var i = 0
while i < n { if (s[i] & 192) != 128 { c = c + 1 }; i = i + 1 }
return c
}
function replace_all(hay: pointer, needle: pointer, repl: pointer) -> pointer {
if slen(needle) == 0 { return hay }
let b = sb_new()
let nl = slen(needle)
let n = slen(hay)
var i = 0
while i < n {
if s_starts_at(hay, i, needle) { sb_puts(b, repl); i = i + nl }
else { sb_putc(b, hay[i]); i = i + 1 }
}
return sb_str(b)
}
# strip a filename's trailing ".md" -> the fallback id (fn[:-3])
function drop_md(fn: pointer) -> pointer {
let n = slen(fn)
if n >= 3 { return sslice(fn, 0, n - 3) }
return fn
}
function cmd_docs_check() -> int {
var site = "build/pages"
if arg_count() >= 3 { site = arg(2) }
let problems = new []pointer
let warnings = new []pointer
# 1) pages contract
if not file_exists(`{site}/index.html`) { push(problems, "missing required file: index.html") }
if not file_exists(`{site}/api.html`) { push(problems, "missing required file: api.html") }
if not file_exists(`{site}/.nojekyll`) { push(problems, "missing required file: .nojekyll") }
# 2) coverage + thin detection over docs/language/**
let id2src = meta_new()
let thin = new []pointer
let cats = list_sorted("docs/language")
var ci = 0
while ci < len(cats) {
let cat = cats[ci]
ci = ci + 1
let cdir = "docs/language/" + cat
if not is_dir(cdir) { continue }
let files = list_sorted(cdir)
var fi = 0
while fi < len(files) {
let fn = files[fi]
fi = fi + 1
if not s_ends(fn, ".md") { continue }
if fn == "_section.md" { continue }
let d = parse_front(cdir + "/" + fn)
let sid = meta_get(d.m, "id", drop_md(fn))
meta_put(id2src, sid, cdir + "/" + fn)
# thin: drop a Parameters: block (to end, case-insensitive) + the tip line
let tipline = meta_get(d.m, "tip", "")
let low = lower_ascii(d.body)
var btext = d.body
let pp = s_index(low, "parameters:", 0)
if pp >= 0 { btext = sslice(d.body, 0, pp) }
btext = s_trim(btext)
if slen(tipline) > 0 { btext = s_trim(replace_all(btext, tipline, "")) }
if cp_len(btext) < 40 { push(thin, sid) }
}
}
# coverage against the authoritative inventory
let invtext = read_file("tools/docgen/inventory.json")
if invtext != null {
let inv = json_parse(invtext)
if inv.t == JV_OBJ {
var k = 0
while k < len(inv.keys) {
let cat = inv.keys[k]
let ids = inv.kids[k]
k = k + 1
if ids.t != JV_ARR { continue }
var m = 0
while m < len(ids.kids) {
let sid = ids.kids[m].s
m = m + 1
if meta_get(id2src, sid, "") == "" { push(problems, `no source file for inventory symbol: {sid} ({cat})`) }
else if not file_exists(`{site}/{sid}.html`) { push(problems, `no generated page for symbol: {sid}.html`) }
}
}
}
}
# 2b/2c) duplicate tokens + one-dir-per-namespace/section
let tok2ids = new []Bucket # k1=kind, k2=tok -> ids
let ns2dirs = new []Bucket # k1="", k2=ns -> dirs
let id2dirs = new []Bucket # k1="", k2=section id -> dirs
let title2dirs = new []Bucket # k1="", k2=lower(title) -> dirs
ci = 0
while ci < len(cats) {
let cat = cats[ci]
ci = ci + 1
let cdir = "docs/language/" + cat
if not is_dir(cdir) { continue }
let secp = cdir + "/_section.md"
if file_exists(secp) {
let sd = parse_front(secp)
bucket_add(id2dirs, "", meta_get(sd.m, "id", cat), cat)
bucket_add(title2dirs, "", lower_ascii(s_trim(meta_get(sd.m, "title", ""))), cat)
}
let files = list_sorted(cdir)
var fi = 0
while fi < len(files) {
let fn = files[fi]
fi = fi + 1
if not s_ends(fn, ".md") { continue }
if fn == "_section.md" { continue }
let d = parse_front(cdir + "/" + fn)
let kind = meta_get(d.m, "kind", "")
let id = meta_get(d.m, "id", "")
let toks = split_ws(meta_get(d.m, "tokens", ""))
var ti = 0
while ti < len(toks) { bucket_add(tok2ids, kind, toks[ti], id); ti = ti + 1 }
let ns = meta_get(d.m, "ns", "")
if slen(ns) > 0 { bucket_add(ns2dirs, "", ns, cat) }
}
}
var bi = 0
while bi < len(tok2ids) {
let b = tok2ids[bi]
bi = bi + 1
if len(b.vals) > 1 { push(problems, `token '{b.k2}' ({b.k1}) documented on multiple pages: {join_sorted(b.vals)}`) }
}
bi = 0
while bi < len(ns2dirs) {
let b = ns2dirs[bi]
bi = bi + 1
if len(b.vals) > 1 { push(problems, `namespace '{b.k2}' documented from multiple dirs: {join_sorted(b.vals)}`) }
}
bi = 0
while bi < len(id2dirs) {
let b = id2dirs[bi]
bi = bi + 1
if len(b.vals) > 1 { push(problems, `section id '{b.k2}' declared by multiple dirs: {join_sorted(b.vals)}`) }
}
bi = 0
while bi < len(title2dirs) {
let b = title2dirs[bi]
bi = bi + 1
if slen(b.k2) > 0 and len(b.vals) > 1 { push(problems, `section title '{b.k2}' shared by multiple dirs: {join_sorted(b.vals)}`) }
}
# 3) thin warning
if len(thin) > 0 {
strs_sort(thin)
let b = sb_new()
var i = 0
while i < len(thin) and i < 12 {
if i > 0 { sb_puts(b, ", ") }
sb_puts(b, thin[i])
i = i + 1
}
var tail = ""
if len(thin) > 12 { tail = " …" }
push(warnings, `{string(len(thin))} symbols still have only seed text: {sb_str(b)}{tail}`)
}
# 4) highlighter targets exist
let sjtext = read_file(`{site}/symbols.json`)
if sjtext != null {
let sj = json_parse(sjtext)
let hl = j_get(sj, "highlight")
let targets = new []pointer
collect_targets_str(j_get(hl, "keywords"), targets)
collect_targets_str(j_get(hl, "types"), targets)
collect_targets_str(j_get(hl, "phases"), targets)
collect_targets_str(j_get(hl, "annotations"), targets)
collect_targets_id(j_get(hl, "builtins"), targets)
collect_targets_id(j_get(hl, "nsmethods"), targets)
collect_targets_str(j_get(hl, "namespaces"), targets)
strs_sort(targets)
var i = 0
while i < len(targets) {
let tgt = targets[i]
i = i + 1
if not file_exists(`{site}/{tgt}.html`) { push(problems, `highlighter links to {tgt}.html but it was not generated`) }
}
}
# report
var wi = 0
while wi < len(warnings) { print(` warning: {warnings[wi]}`); wi = wi + 1 }
if len(problems) > 0 {
print("docs check FAILED:")
var pi = 0
while pi < len(problems) { print(` - {problems[pi]}`); pi = pi + 1 }
return 1
}
print(`docs check OK: {site} ({string(len(id2src.keys))} symbols documented)`)
return 0
}
# add every string value of an object's members (set semantics via the caller)
function collect_targets_str(o: JVal, out: []pointer) -> void {
if o.t != JV_OBJ { return }
var i = 0
while i < len(o.kids) {
let v = o.kids[i]
i = i + 1
if v.t == JV_STR { set_add(out, v.s) }
}
}
# add each member value's ["id"] (builtins / nsmethods)
function collect_targets_id(o: JVal, out: []pointer) -> void {
if o.t != JV_OBJ { return }
var i = 0
while i < len(o.kids) {
let v = o.kids[i]
i = i + 1
let id = j_get(v, "id")
if id.t == JV_STR { set_add(out, id.s) }
}
}

1014
tools/x/docgen_gen.ludic Normal file

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,9 @@ program X {
import "test.ludic"
import "json.ludic"
import "checks.ludic"
import "docgen.ludic"
import "docgen_gen.ludic"
import "docgen_check.ludic"
import "release.ludic"
function usage() -> void {
@ -43,6 +46,9 @@ program X {
print(" x check-impl every implemented feature has a docs/language page")
print(" x check-vocabulary the vocabulary is in sync across grammar / lexer / header / parser")
print(" x lint-asset <file> validate one editor .json / .xml asset")
print(" x docs-gen [--out DIR] generate the documentation site (default build/pages)")
print(" x docs-check [DIR] coverage/integrity guard over a generated docs site")
print(" x docs-palette regenerate emit_color.ludic + palette.json from the palette table")
print("")
print("release:")
print(" x version print the toolchain version (ludicc --version)")
@ -80,6 +86,9 @@ program X {
if (cmd == "check-impl") { exit(cmd_check_impl()) }
if (cmd == "check-vocabulary") { exit(cmd_check_vocab()) }
if (cmd == "lint-asset") { exit(cmd_lint_asset()) }
if (cmd == "docs-palette") { exit(cmd_docs_palette()) }
if (cmd == "docs-gen") { exit(cmd_docs_gen()) }
if (cmd == "docs-check") { exit(cmd_docs_check()) }
if (cmd == "golden") { exit(cmd_golden()) }
if (cmd == "bootstrap") { exit(cmd_bootstrap()) }
if (cmd == "bootstrap-cfree") { exit(cmd_bootstrap_cfree()) }

View file

@ -186,5 +186,20 @@ function cmd_test() -> int {
if (rc == 42) { ok("ludic app.ludic -> compiles, runs, forwards exit code") }
else { bad2("ludic run: expected exit 42", `got {string(rc)}`) }
# docs site generator + guard, in Ludic (no Python). docs-gen emits the whole
# pages payload; docs-check is its coverage/integrity guard; docs-palette is
# the named-colour source of truth (its output is the tracked emit_color.ludic
# + palette.json, so a clean git diff proves it stayed byte-identical).
print("== docs site (Ludic generator, no Python) ==")
if shq("bin/x docs-gen --out /tmp/x_docs > /tmp/x_docs.out 2>&1") {
ok("x docs-gen -> the pages-branch payload")
} else { bad2("x docs-gen", capture_line("tail -1 /tmp/x_docs.out")) }
if shq("bin/x docs-check /tmp/x_docs > /tmp/x_docs_chk.out 2>&1") {
ok("x docs-check passes on the generated site")
} else { bad2("x docs-check", capture_line("tail -1 /tmp/x_docs_chk.out")) }
if shq("bin/x docs-palette > /dev/null 2>&1") and shq("git diff --quiet selfhost/backend/stdlib/emit_color.ludic docs/language/colors/palette.json") {
ok("x docs-palette regenerates emit_color.ludic + palette.json byte-identically")
} else { bad("x docs-palette drifted (git diff not empty)") }
return report()
}