aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-16 00:00:45 +0200
committeruser <user@clank>2026-08-16 00:00:45 +0200
commit910d47dc4ae10fb3c388f6883e5107d9659fa727 (patch)
treeaaea5134d0e0b5e66cfca4fefd301ddd72b08a18
parentsl0ppty: a README you can read, and docs/ for the rest (diff)
downloadslosh-910d47dc4ae10fb3c388f6883e5107d9659fa727.tar.gz
slosh-910d47dc4ae10fb3c388f6883e5107d9659fa727.tar.xz
slosh-910d47dc4ae10fb3c388f6883e5107d9659fa727.zip
sl0ppty: make docs
contrib/gen-docs renders docs/ into a static site: one file of stdlib Python and one stylesheet, no toolchain, no package manager, no npm. Same argument as src/kdl.c being a hand-rolled KDL subset rather than a library -- the input is ours, we know exactly which constructs it uses, and a docs build that needs a lockfile is a docs build nobody runs. What the Markdown subset understands is what docs/ actually contains: headings (with anchors), paragraphs, fenced code, pipe tables, lists one level deep, blockquotes, rules, `code`, bold, italic, links. Anything else is escaped and passed through as text. A construct nobody uses is not supported on purpose: it would be untested code pretending to be a feature. The layout is a sidebar of sections from docs/nav, the page, and its own headings down the right; prev/next chains the nav order; a link to another page is rewritten to its rendered page, so the same Markdown reads correctly on a forge and on the site. The colours are the session's own default theme, so the docs look like the thing they document, and the light variant follows the reader's system preference rather than a toggle nobody would find. Zero JavaScript -- the narrow-screen menu is a checkbox. Two things came out of looking at it in a browser rather than trusting the HTML. The hero diagram was hand-drawn and its lines were 78 and 79 columns long, which is visible. It is generated from a real session now -- three panes, a layout file, a text snapshot -- so it is exactly what the program draws. Terminal art also needs tighter leading and a font whose box drawing joins, so a fence with box characters in it and no language is emitted as art rather than code and styled for that. Detected rather than declared, because the alternative is a marker on every fence and one of them getting forgotten. (Vertical rules still break between lines in a browser: the glyph does not fill its em box, and no amount of CSS changes that. Widths line up, which is the part that matters.) The narrow-screen menu opened over a page that showed through underneath it: as a grid child taken out of flow it kept its content height, so `bottom: 0` did nothing. It has an explicit height now. tests/test_docs.py checks both jobs. The site: every page in the nav is rendered, carries the whole sidebar, is marked as current on its own page, chains prev/next, and no internal link in the output points at a file that is not there. The subset: each construct, and the one that matters most -- markup inside a code span is not markup, because half of these docs are shader expressions full of asterisks. A page missing from docs/nav fails the build, and so does a nav entry with no file: an unreachable page whose only symptom is its absence is the failure mode a hand-written nav has.
-rw-r--r--Makefile5
-rw-r--r--README.md22
-rw-r--r--contrib/docs.css193
-rwxr-xr-xcontrib/gen-docs317
-rw-r--r--docs/index.md19
-rw-r--r--docs/nav2
-rwxr-xr-xtests/test_docs.py220
7 files changed, 764 insertions, 14 deletions
diff --git a/Makefile b/Makefile
index 76df199..8b1f738 100644
--- a/Makefile
+++ b/Makefile
@@ -39,7 +39,7 @@ PY_TESTS := $(filter-out test_session.py,$(notdir $(wildcard tests/test_*.py)))
PY_STAMPS := $(PY_TESTS:%.py=build/.pass-%)
JOBS ?= $(shell nproc 2>/dev/null || echo 4)
-.PHONY: all clean vendor run test retest test-live test-all smoke help coverage
+.PHONY: all clean vendor run test retest test-live test-all smoke help coverage docs
.DEFAULT_GOAL := help
help: ## show this
@@ -154,6 +154,9 @@ test-all: test test-live ## everything
smoke: $(BIN) ## compose a screen headlessly and print it
./$(BIN) --headless --cols 40 --rows 12 -- /bin/sh -c 'printf "hello \033[1;32msl0ppty\033[0m\n"; echo "wide: 日本語"'
+docs: ## render docs/ into build/docs (no dependencies; open build/docs/index.html)
+ python3 contrib/gen-docs build/docs
+
run: $(BIN) ## build and run
./$(BIN)
diff --git a/README.md b/README.md
index 889a0db..1c3c291 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,19 @@ A terminal multiplexer — panes, tabs, sessions you can detach from — written
C on top of [libghostty-vt](https://github.com/ghostty-org/ghostty).
```
-╭──────────── nvim ─────────── ▬ □ ✕ ╮ ╭──────── npm run dev ──────── ▬ □ ✕ ╮
-│ │ │ │
-│ │ ╰────────────────────────────────────╯
-│ │ ╭──────────── shell ───────── ▬ □ ✕ ╮
-│ │ │ │
-╰─────────────────────────────────────╯ ╰────────────────────────────────────╯
+╭────────── nvim ─────────── ▬ □ ✕ ╮ ╭─────── npm run dev ─────── ▬ □ ✕ ╮
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ ╰──────────────────────────────────╯
+│ │
+│ │ ╭────────── shell ────────── ▬ □ ✕ ╮
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+╰──────────────────────────────────╯ ╰──────────────────────────────────╯
```
## Install
@@ -41,6 +48,9 @@ split, `C-a ?` for the keys, `C-a p` for every command by name.
[layouts](docs/layouts.md) · [scripting](docs/scripting.md) ·
[how it works](docs/design.md)
+`make docs` renders the same pages as a static site into `build/docs` — no
+dependencies, one file of Python and one stylesheet.
+
## What is different about it
- **The mouse works properly.** Click a border to split toward it, drag a gap to
diff --git a/contrib/docs.css b/contrib/docs.css
new file mode 100644
index 0000000..8b2268c
--- /dev/null
+++ b/contrib/docs.css
@@ -0,0 +1,193 @@
+/* sl0ppty docs. One file, no build step, no framework.
+ *
+ * The colours are the session's own default theme, so the docs look like the
+ * thing they document. Dark by default; the light variant follows the reader's
+ * system preference rather than a toggle nobody would find. */
+
+:root {
+ --bg: #141418;
+ --panel: #17171c;
+ --line: #26262d;
+ --ink: #d8d8dd;
+ --dim: #8a8a95;
+ --accent: #ff5fd7;
+ --code-bg: #1c1c22;
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", "DejaVu Sans Mono", monospace;
+ --sans: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ --bg: #fdfdfc; --panel: #f6f6f4; --line: #e3e3df;
+ --ink: #26262a; --dim: #6b6b72; --code-bg: #f1f1ee;
+ }
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font: 16px/1.65 var(--sans);
+ -webkit-font-smoothing: antialiased;
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ---- frame ---- */
+
+header {
+ position: sticky; top: 0; z-index: 3;
+ display: flex; align-items: center; gap: 12px;
+ height: 48px; padding: 0 20px;
+ background: color-mix(in srgb, var(--bg) 86%, transparent);
+ backdrop-filter: blur(8px);
+ border-bottom: 1px solid var(--line);
+}
+header > a { font-family: var(--mono); font-weight: 600; color: var(--ink); }
+header .crumb { color: var(--dim); font-size: 13px; }
+header .crumb::before { content: "/ "; }
+
+.burger { display: none; cursor: pointer; color: var(--dim); font-size: 20px; }
+
+.wrap {
+ display: grid;
+ grid-template-columns: 232px minmax(0, 1fr) 200px;
+ gap: 40px;
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 32px 20px 96px;
+}
+
+/* ---- sidebar ---- */
+
+.side { position: sticky; top: 80px; align-self: start; font-size: 14.5px; }
+.side .brand { display: none; }
+.side h6 {
+ margin: 20px 0 6px;
+ font: 600 11px/1 var(--sans);
+ letter-spacing: .09em;
+ text-transform: uppercase;
+ color: var(--dim);
+}
+.side h6:first-of-type { margin-top: 0; }
+.side ul { list-style: none; margin: 0; padding: 0; }
+.side li { margin: 1px 0; }
+.side a {
+ display: block; padding: 4px 10px; border-radius: 6px;
+ color: var(--ink); opacity: .78;
+}
+.side a:hover { background: var(--panel); opacity: 1; text-decoration: none; }
+.side a.here {
+ background: var(--panel); opacity: 1; font-weight: 600;
+ box-shadow: inset 2px 0 0 var(--accent);
+}
+
+/* ---- on this page ---- */
+
+.toc { position: sticky; top: 80px; align-self: start; font-size: 13px; }
+.toc h6 {
+ margin: 0 0 8px; font: 600 11px/1 var(--sans);
+ letter-spacing: .09em; text-transform: uppercase; color: var(--dim);
+}
+.toc ul { list-style: none; margin: 0; padding: 0; }
+.toc a { color: var(--dim); display: block; padding: 3px 0; }
+.toc a:hover { color: var(--ink); text-decoration: none; }
+.toc .l3 { padding-left: 12px; }
+
+/* ---- prose ---- */
+
+main { min-width: 0; }
+main > :first-child { margin-top: 0; }
+
+h1, h2, h3, h4 { line-height: 1.25; scroll-margin-top: 72px; }
+h1 { font-size: 30px; margin: 0 0 18px; letter-spacing: -.01em; }
+h2 {
+ font-size: 21px; margin: 40px 0 12px;
+ padding-top: 20px; border-top: 1px solid var(--line);
+}
+h3 { font-size: 17px; margin: 26px 0 8px; }
+h4 { font-size: 15px; margin: 22px 0 6px; color: var(--dim); }
+
+.anchor {
+ margin-left: 8px; color: var(--line);
+ font-weight: 400; opacity: 0; transition: opacity .1s;
+}
+h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor { opacity: 1; }
+.anchor:hover { color: var(--accent); text-decoration: none; }
+
+p, ul, ol, table, blockquote, pre { margin: 0 0 14px; }
+ul, ol { padding-left: 22px; }
+li { margin: 4px 0; }
+li > ul, li > ol { margin: 4px 0; }
+
+code {
+ font: .875em/1.4 var(--mono);
+ background: var(--code-bg);
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ padding: .1em .35em;
+}
+
+pre {
+ background: var(--code-bg);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 14px 16px;
+ overflow-x: auto;
+}
+pre code { background: none; border: 0; padding: 0; font-size: 13px; line-height: 1.55; }
+
+/* Terminal art: box-drawing joins only when the leading is tight and the font
+ * draws the pieces edge to edge. DejaVu first because it does, and because it
+ * is the monospace almost every Linux box already has. */
+pre.art code {
+ font-family: "DejaVu Sans Mono", Menlo, Consolas, var(--mono);
+ line-height: 1.05;
+ letter-spacing: 0;
+ font-size: 12.5px;
+}
+
+blockquote {
+ margin-left: 0; padding: 2px 0 2px 16px;
+ border-left: 2px solid var(--accent);
+ color: var(--dim);
+}
+
+hr { border: 0; border-top: 1px solid var(--line); margin: 28px 0; }
+
+table { border-collapse: collapse; width: 100%; font-size: 14.5px; display: block; overflow-x: auto; }
+th, td { text-align: left; padding: 7px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
+th { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--dim); font-weight: 600; }
+tbody tr:hover { background: var(--panel); }
+td code { white-space: nowrap; }
+
+/* ---- prev/next ---- */
+
+.prevnext {
+ display: flex; justify-content: space-between; gap: 16px;
+ margin-top: 48px; padding-top: 20px; border-top: 1px solid var(--line);
+ font-size: 14.5px;
+}
+.prevnext .next { margin-left: auto; }
+
+/* ---- narrow ---- */
+
+@media (max-width: 1040px) { .toc { display: none; } .wrap { grid-template-columns: 232px minmax(0, 1fr); } }
+
+@media (max-width: 760px) {
+ .burger { display: block; }
+ .wrap { grid-template-columns: minmax(0, 1fr); padding-top: 24px; }
+ .side {
+ display: none; position: fixed; top: 48px; left: 0; right: 0; z-index: 2;
+ /* An explicit height rather than `bottom: 0`. Taken out of flow it is still
+ * a grid child, and Chrome keeps its content height, which leaves the page
+ * showing through under the open menu. */
+ height: calc(100vh - 48px);
+ padding: 20px; overflow-y: auto; background: var(--bg);
+ border-right: 1px solid var(--line);
+ }
+ #menu:checked ~ .wrap .side { display: block; }
+}
diff --git a/contrib/gen-docs b/contrib/gen-docs
new file mode 100755
index 0000000..c1542b4
--- /dev/null
+++ b/contrib/gen-docs
@@ -0,0 +1,317 @@
+#!/usr/bin/env python3
+"""Render docs/ into a static site.
+
+ contrib/gen-docs [outdir] default: build/docs
+ SL0PPTY_DOCS=dir contrib/gen-docs out ...from another docs tree
+
+No dependencies and no toolchain: this is one file of stdlib Python and one
+stylesheet, for the same reason src/kdl.c is a hand-rolled KDL subset rather
+than a library — the input is ours, we know exactly which constructs it uses,
+and a docs build that needs a package manager is a docs build nobody runs.
+
+What it understands is what docs/ actually contains: headings, paragraphs,
+fenced code, pipe tables, lists (one level of nesting), blockquotes, rules,
+`code`, **bold**, *italic*, and links. Anything else is passed through as text,
+escaped. A construct nobody uses is not supported on purpose: it would be
+untested code pretending to be a feature.
+
+The nav — order, grouping — comes from docs/nav. Page titles come from each
+page's own `# heading`, so they cannot drift from the page.
+"""
+import html
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(HERE)
+# $SL0PPTY_DOCS points it at another tree, which is how the tests drive it over
+# fixtures -- including the trees that are supposed to be refused.
+DOCS = os.environ.get("SL0PPTY_DOCS") or os.path.join(ROOT, "docs")
+
+
+# ---- the markdown subset --------------------------------------------------
+
+INLINE_CODE = re.compile(r"`([^`]+)`")
+LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
+BOLD = re.compile(r"\*\*([^*]+)\*\*")
+ITALIC = re.compile(r"(?<![*\w])\*([^*\n]+)\*(?!\*)")
+
+
+def inline(text):
+ """Inline markup, in one pass over the escaped text.
+
+ Code spans are pulled out first and put back last, because their contents
+ are not markup: `**` inside a code span is two asterisks, and a rule that
+ formats them is a rule that mangles every shader expression on the page.
+ """
+ spans = []
+
+ def stash(m):
+ spans.append(html.escape(m.group(1)))
+ return "\0%d\0" % (len(spans) - 1)
+
+ text = INLINE_CODE.sub(stash, text)
+ text = html.escape(text)
+ text = LINK.sub(lambda m: '<a href="%s">%s</a>' % (link_href(m.group(2)),
+ m.group(1)), text)
+ text = BOLD.sub(r"<strong>\1</strong>", text)
+ text = ITALIC.sub(r"<em>\1</em>", text)
+ for i, code in enumerate(spans):
+ text = text.replace("\0%d\0" % i, "<code>%s</code>" % code)
+ return text
+
+
+def link_href(href):
+ """A link to another page becomes a link to its rendered page."""
+ if href.startswith(("http://", "https://", "#", "mailto:")):
+ return html.escape(href)
+ page, _, frag = href.partition("#")
+ if page.endswith(".md"):
+ page = page[:-3] + ".html"
+ return html.escape(page + ("#" + frag if frag else ""))
+
+
+def slug(text):
+ """A heading's anchor: what it says, lowercased, punctuation dropped."""
+ text = re.sub(r"`|\*\*|\*", "", text)
+ text = re.sub(r"[^\w\s-]", "", text).strip().lower()
+ return re.sub(r"[\s_]+", "-", text)
+
+
+def render(md):
+ """Markdown to (html, title, [(level, text, anchor)] for the page's own
+ table of contents)."""
+ out, toc, title = [], [], None
+ lines = md.split("\n")
+ i = 0
+
+ def close(stack):
+ while stack:
+ out.append("</%s>" % stack.pop())
+
+ stack = [] # open list elements, innermost last
+ while i < len(lines):
+ line = lines[i]
+
+ if line.startswith("```"):
+ lang = line[3:].strip()
+ i += 1
+ body = []
+ while i < len(lines) and not lines[i].startswith("```"):
+ body.append(lines[i])
+ i += 1
+ i += 1
+ close(stack)
+ text = "\n".join(body)
+ # A block of terminal art rather than a block of code: it is drawn
+ # with box characters, so it needs leading tight enough for the
+ # verticals to join and a font whose box drawing does. Detected
+ # rather than declared, because the alternative is a marker in every
+ # fence and one of them getting forgotten.
+ art = not lang and any(c in text for c in "─│╭╮╰╯├┤┬┴┼")
+ cls = ' class="art"' if art else (
+ ' class="lang-%s"' % html.escape(lang) if lang else "")
+ out.append("<pre%s><code>%s</code></pre>" % (cls, html.escape(text)))
+ continue
+
+ if re.match(r"^#{1,4} ", line):
+ close(stack)
+ level = len(line) - len(line.lstrip("#"))
+ text = line[level:].strip()
+ anchor = slug(text)
+ if level == 1 and title is None:
+ title = re.sub(r"`", "", text)
+ else:
+ toc.append((level, re.sub(r"`", "", text), anchor))
+ out.append('<h%d id="%s">%s<a class="anchor" href="#%s">#</a></h%d>'
+ % (level, anchor, inline(text), anchor, level))
+ i += 1
+ continue
+
+ if line.startswith("|") and i + 1 < len(lines) and \
+ re.match(r"^\|[\s:|-]+\|$", lines[i + 1]):
+ close(stack)
+ head = [c.strip() for c in line.strip("|").split("|")]
+ i += 2
+ rows = []
+ while i < len(lines) and lines[i].startswith("|"):
+ rows.append([c.strip() for c in lines[i].strip("|").split("|")])
+ i += 1
+ out.append("<table><thead><tr>%s</tr></thead><tbody>%s</tbody></table>"
+ % ("".join("<th>%s</th>" % inline(c) for c in head),
+ "".join("<tr>%s</tr>" % "".join("<td>%s</td>" % inline(c)
+ for c in r)
+ for r in rows)))
+ continue
+
+ bullet = re.match(r"^(\s*)([-*]|\d+\.)\s+(.*)$", line)
+ if bullet:
+ indent, marker, text = bullet.groups()
+ tag = "ul" if marker in ("-", "*") else "ol"
+ depth = 1 + (len(indent) >= 2)
+ while len(stack) > depth:
+ out.append("</%s>" % stack.pop())
+ if len(stack) < depth:
+ out.append("<%s>" % tag)
+ stack.append(tag)
+ # Continuation lines: an item wrapped across the source.
+ body = [text]
+ while i + 1 < len(lines) and re.match(r"^\s+\S", lines[i + 1]) and \
+ not re.match(r"^(\s*)([-*]|\d+\.)\s+", lines[i + 1]):
+ i += 1
+ body.append(lines[i].strip())
+ out.append("<li>%s</li>" % inline(" ".join(body)))
+ i += 1
+ continue
+
+ if line.startswith("> "):
+ close(stack)
+ body = []
+ while i < len(lines) and lines[i].startswith(">"):
+ body.append(lines[i].lstrip("> ").rstrip())
+ i += 1
+ out.append("<blockquote>%s</blockquote>" % inline(" ".join(body)))
+ continue
+
+ if re.match(r"^---+$", line):
+ close(stack)
+ out.append("<hr>")
+ i += 1
+ continue
+
+ if not line.strip():
+ close(stack)
+ i += 1
+ continue
+
+ close(stack)
+ body = [line]
+ while i + 1 < len(lines) and lines[i + 1].strip() and \
+ not re.match(r"^(#{1,4} |```|\||>|---+$|\s*([-*]|\d+\.)\s)",
+ lines[i + 1]):
+ i += 1
+ body.append(lines[i])
+ out.append("<p>%s</p>" % inline(" ".join(body)))
+ i += 1
+
+ close(stack)
+ return "\n".join(out), title or "sl0ppty", toc
+
+
+# ---- the site -------------------------------------------------------------
+
+def read_nav(path):
+ """docs/nav: sections in column zero, pages indented under them."""
+ sections = []
+ for raw in open(path):
+ line = raw.rstrip()
+ if not line.strip() or line.lstrip().startswith("#"):
+ continue
+ if line[0].isspace():
+ if not sections:
+ continue
+ sections[-1][1].append(line.strip())
+ else:
+ sections.append((line.strip(), []))
+ return sections
+
+
+def sidebar(sections, pages, current):
+ out = ['<nav class="side"><a class="brand" href="index.html">sl0ppty</a>']
+ for name, files in sections:
+ out.append("<h6>%s</h6><ul>" % html.escape(name))
+ for f in files:
+ href = f[:-3] + ".html"
+ cls = ' class="here"' if f == current else ""
+ out.append('<li><a%s href="%s">%s</a></li>'
+ % (cls, href, html.escape(pages[f]["title"])))
+ out.append("</ul>")
+ out.append("</nav>")
+ return "\n".join(out)
+
+
+def page_toc(toc):
+ """On this page: the h2s, and the h3s under them."""
+ items = [(lvl, text, anchor) for lvl, text, anchor in toc if lvl in (2, 3)]
+ if len(items) < 2:
+ return ""
+ out = ['<nav class="toc"><h6>On this page</h6><ul>']
+ for lvl, text, anchor in items:
+ out.append('<li class="l%d"><a href="#%s">%s</a></li>'
+ % (lvl, anchor, html.escape(text)))
+ out.append("</ul></nav>")
+ return "\n".join(out)
+
+
+SHELL = """<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{title}</title>
+<link rel="stylesheet" href="docs.css">
+</head><body>
+<input type="checkbox" id="menu" hidden>
+<header><label for="menu" class="burger" aria-label="Menu">≡</label>
+<a href="index.html">sl0ppty</a><span class="crumb">{crumb}</span></header>
+<div class="wrap">
+{sidebar}
+<main>
+{body}
+{prevnext}
+</main>
+{toc}
+</div>
+</body></html>
+"""
+
+
+def build(outdir):
+ sections = read_nav(os.path.join(DOCS, "nav"))
+ order = [f for _, files in sections for f in files]
+
+ pages = {}
+ for f in order:
+ path = os.path.join(DOCS, f)
+ if not os.path.exists(path):
+ sys.exit("gen-docs: %s is in docs/nav and not on disk" % f)
+ body, title, toc = render(open(path).read())
+ pages[f] = {"body": body, "title": title, "toc": toc}
+
+ stray = sorted(set(os.path.basename(p) for p in os.listdir(DOCS)
+ if p.endswith(".md")) - set(order))
+ if stray:
+ sys.exit("gen-docs: not in docs/nav: %s" % ", ".join(stray))
+
+ os.makedirs(outdir, exist_ok=True)
+ for i, f in enumerate(order):
+ p = pages[f]
+ links = []
+ if i:
+ links.append('<a class="prev" href="%s">← %s</a>'
+ % (order[i - 1][:-3] + ".html", html.escape(pages[order[i - 1]]["title"])))
+ if i + 1 < len(order):
+ links.append('<a class="next" href="%s">%s →</a>'
+ % (order[i + 1][:-3] + ".html", html.escape(pages[order[i + 1]]["title"])))
+ section = next(name for name, files in sections if f in files)
+ out = SHELL.format(
+ title=html.escape(p["title"] + (" — sl0ppty" if f != "index.md" else "")),
+ crumb=html.escape(section),
+ sidebar=sidebar(sections, pages, f),
+ body=p["body"],
+ prevnext='<div class="prevnext">%s</div>' % "".join(links) if links else "",
+ toc=page_toc(p["toc"]))
+ with open(os.path.join(outdir, f[:-3] + ".html"), "w") as fh:
+ fh.write(out)
+
+ css = open(os.path.join(HERE, "docs.css")).read()
+ with open(os.path.join(outdir, "docs.css"), "w") as fh:
+ fh.write(css)
+
+ return [f[:-3] + ".html" for f in order]
+
+
+if __name__ == "__main__":
+ out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "build", "docs")
+ written = build(out)
+ print("gen-docs: %d pages -> %s" % (len(written), out))
diff --git a/docs/index.md b/docs/index.md
index 69595a5..5567195 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,12 +4,19 @@ A terminal multiplexer — panes, tabs, sessions you can detach from — written
C on top of [libghostty-vt](https://github.com/ghostty-org/ghostty).
```
-╭──────────── nvim ─────────── ▬ □ ✕ ╮ ╭──────── npm run dev ──────── ▬ □ ✕ ╮
-│ │ │ │
-│ │ ╰────────────────────────────────────╯
-│ │ ╭──────────── shell ───────── ▬ □ ✕ ╮
-│ │ │ │
-╰─────────────────────────────────────╯ ╰────────────────────────────────────╯
+╭────────── nvim ─────────── ▬ □ ✕ ╮ ╭─────── npm run dev ─────── ▬ □ ✕ ╮
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ ╰──────────────────────────────────╯
+│ │
+│ │ ╭────────── shell ────────── ▬ □ ✕ ╮
+│ │ │ │
+│ │ │ │
+│ │ │ │
+│ │ │ │
+╰──────────────────────────────────╯ ╰──────────────────────────────────╯
```
## Install
diff --git a/docs/nav b/docs/nav
index ad025d8..ac979f6 100644
--- a/docs/nav
+++ b/docs/nav
@@ -4,7 +4,7 @@
# come from each file's own `# heading`, so they cannot drift from the page.
# Read by contrib/gen-docs, which builds the static site.
-sl0ppty
+Start
index.md
Using it
diff --git a/tests/test_docs.py b/tests/test_docs.py
new file mode 100755
index 0000000..12d208b
--- /dev/null
+++ b/tests/test_docs.py
@@ -0,0 +1,220 @@
+#!/usr/bin/env python3
+"""`make docs`: docs/ rendered as a static site by contrib/gen-docs.
+
+Two kinds of check, because the generator has two jobs.
+
+The site: every page in docs/nav becomes a page, every page carries the whole
+sidebar, prev/next chains the order, and every internal link lands on a file
+that exists. A docs site whose links rot is worse than a directory of Markdown,
+which at least a forge will render.
+
+The Markdown subset: what it understands, it has to understand *exactly* --
+notably that markup inside a code span is not markup, because half of these docs
+are shader expressions full of asterisks.
+"""
+import html
+import os
+import re
+import subprocess
+import sys
+import tempfile
+
+from harness import check, report
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(HERE)
+GEN = os.path.join(ROOT, "contrib", "gen-docs")
+DOCS = os.path.join(ROOT, "docs")
+
+
+def run(outdir, docs=None, expect_ok=True):
+ env = dict(os.environ)
+ if docs:
+ env["SL0PPTY_DOCS"] = docs
+ r = subprocess.run([sys.executable, GEN, outdir], capture_output=True,
+ text=True, env=env)
+ if expect_ok and r.returncode != 0:
+ check("gen-docs succeeds", False, r.stderr.strip() or r.stdout.strip())
+ return r
+
+
+def nav_pages():
+ pages, sections = [], []
+ for raw in open(os.path.join(DOCS, "nav")):
+ line = raw.rstrip()
+ if not line.strip() or line.lstrip().startswith("#"):
+ continue
+ (pages if line[0].isspace() else sections).append(line.strip())
+ return sections, pages
+
+
+def test_it_renders_every_page_in_the_nav():
+ out = tempfile.mkdtemp()
+ run(out)
+ sections, pages = nav_pages()
+ check("docs/nav has sections and pages", sections and len(pages) >= 8,
+ f"{sections} / {pages}")
+
+ for md in pages:
+ f = os.path.join(out, md[:-3] + ".html")
+ check(f"{md} was rendered", os.path.exists(f), f)
+ check("the stylesheet came with it",
+ os.path.exists(os.path.join(out, "docs.css")))
+
+ body = open(os.path.join(out, "keys.html")).read()
+ for md in pages:
+ check(f"keys.html links to {md} in its sidebar",
+ 'href="%s"' % (md[:-3] + ".html") in body, md)
+ check("the current page is marked in the sidebar",
+ 'class="here" href="keys.html"' in body, "no here marker")
+ check("its section is the crumb", 'class="crumb">Using it' in body, body[:400])
+
+
+def test_every_internal_link_lands_somewhere():
+ out = tempfile.mkdtemp()
+ run(out)
+ broken = []
+ for f in sorted(os.listdir(out)):
+ if not f.endswith(".html"):
+ continue
+ for href in re.findall(r'href="([^"]+)"', open(os.path.join(out, f)).read()):
+ if href.startswith(("http://", "https://", "#", "mailto:")):
+ continue
+ target = href.split("#")[0]
+ if target and not os.path.exists(os.path.join(out, target)):
+ broken.append(f"{f} -> {href}")
+ check("no internal link is broken", not broken, "; ".join(broken[:6]))
+
+
+def test_prev_and_next_chain_the_nav_order():
+ out = tempfile.mkdtemp()
+ run(out)
+ _, pages = nav_pages()
+ first = open(os.path.join(out, pages[0][:-3] + ".html")).read()
+ middle = open(os.path.join(out, pages[1][:-3] + ".html")).read()
+ last = open(os.path.join(out, pages[-1][:-3] + ".html")).read()
+ check("the first page has a next and no prev",
+ 'class="next"' in first and 'class="prev"' not in first, "first")
+ check("a middle page has both",
+ 'class="next"' in middle and 'class="prev"' in middle, "middle")
+ check("the last page has a prev and no next",
+ 'class="prev"' in last and 'class="next"' not in last, "last")
+
+
+def test_a_page_missing_from_the_nav_is_refused():
+ """Otherwise a page written today is a page nobody can reach, and the only
+ sign of it is its absence."""
+ docs = tempfile.mkdtemp()
+ with open(os.path.join(docs, "nav"), "w") as f:
+ f.write("Start\n index.md\n")
+ with open(os.path.join(docs, "index.md"), "w") as f:
+ f.write("# Start\n\nhello\n")
+ with open(os.path.join(docs, "orphan.md"), "w") as f:
+ f.write("# Orphan\n\nnobody links here\n")
+ r = run(tempfile.mkdtemp(), docs=docs, expect_ok=False)
+ check("an orphan page fails the build", r.returncode != 0, r.stdout)
+ check("and it says which one", "orphan.md" in (r.stderr + r.stdout),
+ r.stderr + r.stdout)
+
+
+def test_a_nav_entry_with_no_file_is_refused():
+ docs = tempfile.mkdtemp()
+ with open(os.path.join(docs, "nav"), "w") as f:
+ f.write("Start\n index.md\n ghost.md\n")
+ with open(os.path.join(docs, "index.md"), "w") as f:
+ f.write("# Start\n\nhello\n")
+ r = run(tempfile.mkdtemp(), docs=docs, expect_ok=False)
+ check("a nav entry with no page fails the build", r.returncode != 0, r.stdout)
+ check("and it says which one", "ghost.md" in (r.stderr + r.stdout),
+ r.stderr + r.stdout)
+
+
+def render_fixture(md):
+ """One page through the real generator; returns its HTML."""
+ docs = tempfile.mkdtemp()
+ with open(os.path.join(docs, "nav"), "w") as f:
+ f.write("Start\n index.md\n")
+ with open(os.path.join(docs, "index.md"), "w") as f:
+ f.write(md)
+ out = tempfile.mkdtemp()
+ run(out, docs=docs)
+ return open(os.path.join(out, "index.html")).read()
+
+
+def test_the_markdown_subset():
+ got = render_fixture("""# Title
+
+A paragraph with `code`, **bold**, *italic* and a [link](keys.md#defaults).
+
+## A heading
+
+- one
+- two
+ - nested
+
+| a | b |
+|---|---|
+| `x` | y |
+
+```kdl
+shaders { dim amount="(y % 2) * 40" }
+```
+
+```
+╭─────╮
+╰─────╯
+```
+
+> a quote
+
+---
+""")
+ cases = {
+ "the h1 is the title": "<title>Title</title>" in got,
+ "headings get an id and an anchor":
+ '<h2 id="a-heading">' in got and 'href="#a-heading"' in got,
+ "inline code": "<code>code</code>" in got,
+ "bold": "<strong>bold</strong>" in got,
+ "italic": "<em>italic</em>" in got,
+ "a link to another page points at its html":
+ 'href="keys.html#defaults"' in got,
+ "lists nest": "<ul>" in got and "<li>nested</li>" in got,
+ "tables": "<table>" in got and "<th>a</th>" in got,
+ "a fenced block keeps its language": 'class="lang-kdl"' in got,
+ "and its contents verbatim":
+ html.escape('dim amount="(y % 2) * 40"') in got,
+ "box drawing is marked as art, not code": 'class="art"' in got,
+ "blockquotes": "<blockquote>" in got,
+ "rules": "<hr>" in got,
+ }
+ for name, ok in cases.items():
+ check(name, ok, got[:600])
+
+
+def test_markup_inside_a_code_span_is_not_markup():
+ """Half of these docs are shader expressions. A rule that formats the
+ asterisks inside them is a rule that mangles the documentation."""
+ got = render_fixture('# T\n\n`amount="(x < 10) * 200 ** 2"` and **bold**\n')
+ check("asterisks in a code span survive",
+ html.escape('(x < 10) * 200 ** 2') in got, got[:400])
+ check("bold outside it still works", "<strong>bold</strong>" in got, got[:400])
+ check("and the span is not a strong tag",
+ "<strong>" in got and got.count("<strong>") == 1, got[:400])
+
+
+def test_html_in_the_source_is_escaped():
+ got = render_fixture("# T\n\nnot <script>alert(1)</script> markup\n")
+ check("a tag in the source is text", "&lt;script&gt;" in got, got[:400])
+ check("and not a tag", "<script>" not in got, got[:400])
+
+
+if __name__ == "__main__":
+ test_it_renders_every_page_in_the_nav()
+ test_every_internal_link_lands_somewhere()
+ test_prev_and_next_chain_the_nav_order()
+ test_a_page_missing_from_the_nav_is_refused()
+ test_a_nav_entry_with_no_file_is_refused()
+ test_the_markdown_subset()
+ test_markup_inside_a_code_span_is_not_markup()
+ test_html_in_the_source_is_escaped()
+ sys.exit(report())