docs(site): add generated documentation hub

This commit is contained in:
Peter Steinberger 2026-05-08 09:04:24 +01:00
parent 34aa6b9df4
commit 81ddeb43eb
No known key found for this signature in database
14 changed files with 1503 additions and 36 deletions

View File

@ -4,7 +4,9 @@ on:
push:
branches: [main]
paths:
- "docs/site/**"
- "docs/**"
- "scripts/build-docs-site.mjs"
- "scripts/docs-site-assets.mjs"
- ".github/workflows/pages.yml"
workflow_dispatch:
@ -14,27 +16,30 @@ permissions:
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Build static site
run: |
rm -rf _site
mkdir -p _site
cp -R docs/site/* _site/
- name: Build docs site
run: node scripts/build-docs-site.mjs
- name: Configure Pages
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v5
with:
path: _site
@ -47,5 +52,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5

1
.gitignore vendored
View File

@ -77,6 +77,7 @@ lib-cov/
*.launch
.settings/
.claude/settings.local.json
_site/
*.sublime-workspace
*.sublime-project

89
docs/automation.md Normal file
View File

@ -0,0 +1,89 @@
---
title: Automation
summary: 'Overview of Peekaboo UI automation targets, input primitives, app surfaces, recipes, and resilience tips.'
description: How to drive macOS UI with Peekaboo — click, type, scroll, drag, hotkeys, menus, dialogs, windows, Spaces.
read_when:
- 'deciding which UI automation command or targeting mode to use'
- 'documenting agent, MCP, or CLI behavior that mutates macOS UI'
---
# Automation
Peekaboo's automation surface is small but covers the whole macOS UI graph. Each command is documented separately under `commands/`; this page is the map.
## Targeting model
Every input command accepts one of three target shapes:
- **Element ID**`--id E12` (from `peekaboo see`); the most reliable.
- **Label / role / app**`--label "Send" --app Mail`; resolved via the AX tree.
- **Coordinates**`--at 480,120`; the fallback when the AX tree lies.
Prefer IDs when you can capture them, labels when you can't, and coordinates only as a last resort. The agent and MCP tooling default to the first two.
## Input primitives
| Command | Use it for |
| --- | --- |
| [click](commands/click.md) | mouse clicks, double/triple, right/middle, hold |
| [type](commands/type.md) | typing strings into focused fields |
| [press](commands/press.md) | individual key presses (return, escape, arrows, etc.) |
| [hotkey](commands/hotkey.md) | shortcut combos, including background apps |
| [scroll](commands/scroll.md) | wheel scrolling at a point or on a target |
| [drag](commands/drag.md) | press, move, release — files, sliders, selections |
| [swipe](commands/swipe.md) | trackpad-style multi-finger gestures |
| [move](commands/move.md) | warp the mouse without clicking |
| [set-value](commands/set-value.md) | write to text fields without typing |
| [perform-action](commands/perform-action.md) | trigger any AX action (`AXPress`, `AXShowMenu`, …) |
| [sleep](commands/sleep.md) | wait between steps with deterministic timing |
For UX parity with humans (jitter, easing, dwell), see [human-typing.md](human-typing.md) and [human-mouse-move.md](human-mouse-move.md).
## Surfaces
| Surface | Command | Notes |
| --- | --- | --- |
| App lifecycle | [app](commands/app.md) | launch, quit, focus, hide |
| Windows | [window](commands/window.md) | move, resize, focus, minimize, fullscreen |
| Spaces & Stage Manager | [space](commands/space.md) | enumerate and switch Spaces |
| Menus | [menu](commands/menu.md) | walk app menus by path |
| Menu bar / status items | [menubar.md](commands/menubar.md) | extra-fiddly popovers |
| Dialogs | [dialog](commands/dialog.md) | sheets, alerts, save panels |
| Dock | [dock](commands/dock.md) | inspect/click dock items |
| Clipboard | [clipboard](commands/clipboard.md) | read/write pasteboard contents |
| Open files / URLs | [open](commands/open.md) | with focus controls |
| Visual feedback | [visualizer](visualizer.md) | overlay so a human can follow what the agent is doing |
## Recipe: click a button by label
```bash
# 1. Inspect first to find a stable label.
peekaboo see --app Safari --annotate --output safari.png
# 2. Click it.
peekaboo click --label "Reload" --app Safari
```
## Recipe: a small flow
```bash
peekaboo app focus --name "Notes"
peekaboo hotkey cmd+n
peekaboo type "Standup notes\n\n- Shipped Peekaboo docs\n- Reviewed PR #42\n"
peekaboo hotkey cmd+s
```
Three primitives, four lines. The agent does the same thing under the hood — it just plans the sequence for you.
## Resilience tips
- Always run [`peekaboo see`](commands/see.md) when an element is unreachable. The AX tree refreshes after focus changes; capture again if a click fails.
- Use [focus](focus.md) and [application-resolving](application-resolving.md) for tricky cases (multiple windows, helper apps, processes that hide on activation).
- Wrap risky sequences with `peekaboo sleep 0.2` — humans don't fire ten clicks in a single frame, and neither should you.
- Prefer [`hotkey --focus-background`](commands/hotkey.md) when you need to drive an app without stealing focus from the user.
## Going further
- [Agent overview](commands/agent.md) — let Peekaboo plan input sequences from a goal.
- [MCP](MCP.md) — expose all of the above to Claude Desktop, Cursor, and Codex.
- [Architecture](ARCHITECTURE.md) — how the input pipeline routes through Bridge and Daemon.

50
docs/index.md Normal file
View File

@ -0,0 +1,50 @@
---
title: Peekaboo documentation
summary: 'Entry point for installing, configuring, and using Peekaboo across CLI, MCP, app, and library surfaces.'
description: macOS automation that sees the screen and does the clicks. Native CLI, MCP server, and agent runtime for OpenAI, Claude, Grok, Gemini, and Ollama.
read_when:
- 'starting with Peekaboo or looking for the right documentation page'
- 'linking the public documentation hub from README, site, or release notes'
---
# Peekaboo documentation
Peekaboo is a macOS automation toolkit for humans and agents. It captures pixels, reads the accessibility tree, drives input, and ships an agent runtime plus an MCP server so AI clients (Claude Desktop, Cursor, Codex, etc.) can drive the desktop with the same primitives you'd use from the shell.
> **TL;DR**`brew install steipete/tap/peekaboo`, grant Screen Recording + Accessibility, then `peekaboo agent "open Safari and search for Peekaboo"`.
## Where to start
- **[Install](install.md)** — Homebrew, npm/MCP, source builds.
- **[Quickstart](quickstart.md)** — first capture, first click, first agent run in five minutes.
- **[Permissions](permissions.md)** — what to grant, why, and how to verify.
- **[Configuration](configuration.md)** — environment variables, config files, credential storage.
## What Peekaboo does
- **[Capture & vision](commands/capture.md)** — pixel-accurate screen, window, and menu-bar capture; annotated AX maps.
- **[Automation](automation.md)** — click, type, scroll, drag, hotkeys, menus, dialogs, windows, Spaces.
- **[Agent](commands/agent.md)** — natural-language plan/act loop with provider switching, resumable sessions, and visualizer feedback.
- **[MCP](MCP.md)** — expose every Peekaboo tool over stdio for Claude Desktop, Cursor, Codex, and other MCP clients.
## Reference
- **[Command reference](cli-command-reference.md)** — every CLI command, grouped.
- **[Command index](commands/README.md)** — one page per command with flags and examples.
- **[Architecture](ARCHITECTURE.md)** — Core, CLI, Bridge, Daemon, Visualizer.
- **[Releasing](RELEASING.md)** — versioning, signing, distribution.
## Surfaces
| Surface | Use it for | Entry point |
| --- | --- | --- |
| **CLI** | scripts, ad-hoc captures, CI | `brew install steipete/tap/peekaboo` |
| **MCP server** | Claude Desktop, Cursor, Codex CLI | `npx @steipete/peekaboo mcp` |
| **Mac app** | menu-bar visualizer, permission prompts | [Releases](https://github.com/steipete/Peekaboo/releases/latest) |
| **Library** | embed in Swift apps and tools | `Core/PeekabooCore` (Swift Package) |
## Get help
- File issues: [github.com/steipete/Peekaboo/issues](https://github.com/steipete/Peekaboo/issues)
- Source: [github.com/steipete/Peekaboo](https://github.com/steipete/Peekaboo)
- Author: [@steipete](https://x.com/steipete)

61
docs/install.md Normal file
View File

@ -0,0 +1,61 @@
---
title: Install Peekaboo
summary: 'Install Peekaboo through Homebrew, npm/MCP, the Mac app, or a source checkout.'
description: Install the Peekaboo CLI, MCP server, or Mac app. Homebrew, npm, and source paths.
read_when:
- 'setting up Peekaboo for the first time'
- 'choosing between Homebrew, npm, Mac app, and source builds'
---
# Install
Peekaboo ships in three flavors. They all use the same Swift core and the same toolset — pick whichever surface fits your workflow.
## Homebrew (recommended)
The CLI is signed, notarized, and lives in [steipete/homebrew-tap](https://github.com/steipete/homebrew-tap).
```bash
brew install steipete/tap/peekaboo
peekaboo --version
```
Update with `brew upgrade steipete/tap/peekaboo`.
## npm (for MCP clients)
The npm package wraps the same CLI plus an MCP shim, so you can launch the server with `npx`:
```bash
npx -y @steipete/peekaboo mcp
```
This is the form you point Claude Desktop, Cursor, and Codex at. See [MCP.md](MCP.md) and [install-mcp-claude-desktop.md](install-mcp-claude-desktop.md).
## Mac app
The full menu-bar app (visualizer, permission flows, status item) is on the [Releases](https://github.com/steipete/Peekaboo/releases/latest) page. The bundled CLI lives at `/Applications/Peekaboo.app/Contents/MacOS/peekaboo`; symlink it if you want it on your `PATH` without Homebrew.
## Build from source
Requires macOS 26.1+, Xcode 26+, Swift 6.2.
```bash
git clone --recurse-submodules https://github.com/steipete/Peekaboo.git
cd Peekaboo
pnpm install
pnpm run build:cli # debug build
pnpm run build:swift:all # universal release
```
The output binary lives under `Apps/CLI/.build/...`. See [building.md](building.md) for signing, notarization, and the `pnpm run poltergeist:haunt` rapid-rebuild loop.
## Verify
```bash
peekaboo --version
peekaboo permissions status
peekaboo list apps
```
If any of those error out, jump to [permissions.md](permissions.md).

72
docs/providers.md Normal file
View File

@ -0,0 +1,72 @@
---
title: AI providers
summary: 'Configure model providers and credentials for the Peekaboo agent runtime.'
description: Configure OpenAI, Anthropic Claude, xAI Grok, Google Gemini, and Ollama for the Peekaboo agent.
read_when:
- 'configuring model credentials or provider selection'
- 'debugging agent model, tool-calling, or local Ollama setup'
---
# AI providers
Peekaboo's agent runtime is provider-agnostic — it talks to any chat-completions-style backend through Tachikoma. You configure provider credentials once and pick a model per-run.
## Supported providers
| Provider | Models we test | Credential |
| --- | --- | --- |
| **OpenAI** | gpt-5, gpt-5-mini, gpt-4.1 | `OPENAI_API_KEY` |
| **Anthropic** | claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5 | `ANTHROPIC_API_KEY` |
| **xAI** | grok-4 | `XAI_API_KEY` |
| **Google** | gemini-3-pro, gemini-3-flash | `GEMINI_API_KEY` |
| **Ollama** | any local model with tool-calling | runs at `http://localhost:11434` |
Other Tachikoma-supported providers also work — see the [Tachikoma docs](https://github.com/steipete/Tachikoma) for the full list.
## Credentials
Credentials live in `~/.peekaboo/credentials.json`, encrypted at rest with the macOS Keychain when available. Set them once via the CLI:
```bash
peekaboo config set-credential openai # interactive
peekaboo config set-credential anthropic
```
Environment variables override the stored values, which is handy in CI:
```bash
OPENAI_API_KEY=sk-... peekaboo agent "open a browser"
```
See [configuration.md](configuration.md) for the full precedence table.
## Picking a model
```bash
peekaboo agent --model claude-opus-4-7 "summarize this window"
peekaboo agent --model gpt-5-mini "click Continue and wait for the dialog"
peekaboo agent --model ollama:llama3.1:8b "describe this screenshot"
```
Defaults come from `agent.defaultModel` in `~/.peekaboo/config.json`. Set a per-project default with `PEEKABOO_AGENT_MODEL`.
## Tool calling
The agent expects tool-calling capable models. If your provider doesn't support it (some tiny local models), Peekaboo falls back to a structured-output prompt — slower and less reliable. Stick with mainstream tool-calling models for production runs.
## Local-only mode
Want everything on-device? Run an Ollama model with tool calling and point the CLI at it:
```bash
ollama run llama3.1:8b
peekaboo agent --model ollama:llama3.1:8b "open System Settings"
```
No network requests leave the machine. Captures, AX queries, and reasoning all stay local.
## Troubleshooting
- **"401 Unauthorized"** — credential isn't set, or env var overrides the saved one. Run `peekaboo config get-credential <provider>`.
- **"context length exceeded"** — long sessions accumulate screenshots. Start a fresh session with `peekaboo agent --new`.
- **"no tool-call support"** — pick a different model. The error log lists the providers and models with confirmed tool-calling.

95
docs/quickstart.md Normal file
View File

@ -0,0 +1,95 @@
---
title: Quickstart
summary: 'First-run walkthrough for permissions, capture, see, click, type, agent mode, and MCP setup.'
description: First capture, first click, first agent run with Peekaboo. Five minutes from install to working automation.
read_when:
- 'validating a fresh Peekaboo install'
- 'showing users the shortest path from install to working automation'
---
# Quickstart
This page assumes you've already followed [install.md](install.md). If `peekaboo --version` prints a version, you're ready.
## 1. Grant permissions
```bash
peekaboo permissions status
peekaboo permissions grant
```
`grant` opens System Settings to the right pane. You need **Screen Recording** (required) and **Accessibility** (recommended). Re-run `permissions status` until both are green. Background hotkeys also need **Event Synthesizing** — see [permissions.md](permissions.md).
## 2. Take a screenshot
```bash
# whole screen → ./screen.png
peekaboo capture --output screen.png
# only the focused window
peekaboo capture --window-focused --output focused.png
# a specific app's frontmost window
peekaboo capture --app Safari --output safari.png
```
The output is a regular PNG. Add `--format jpeg --quality 85` for smaller files. See [commands/capture.md](commands/capture.md) for every flag.
## 3. Inspect the UI
`see` returns a structured map of clickable elements with stable IDs:
```bash
peekaboo see --app Safari --json | jq '.elements[0:3]'
```
Add `--annotate` to write a labelled PNG you can eyeball:
```bash
peekaboo see --app Safari --annotate --output safari.png
```
Each element has `id`, `role`, `label`, `frame`, and `actions`. Pass an `id` to other commands to act on it.
## 4. Click and type
```bash
peekaboo click --label "Address and search bar" --app Safari
peekaboo type "github.com/steipete/Peekaboo" --press-return
```
Coordinates also work: `peekaboo click --at 480,120`. See [automation.md](automation.md) for the full input vocabulary.
## 5. Run an agent
The agent picks tools, plans, and executes — give it a goal in natural language:
```bash
peekaboo agent "Open Safari, go to github.com, and search for Peekaboo"
```
Watch the visualizer overlay as it works. Pause/resume with `peekaboo agent --resume <session-id>`. See [commands/agent.md](commands/agent.md) for provider switching and session management.
## 6. (Optional) Wire up MCP
Want Claude Desktop or Cursor to drive Peekaboo? Drop this into your MCP client config:
```json
{
"mcpServers": {
"peekaboo": {
"command": "npx",
"args": ["-y", "@steipete/peekaboo", "mcp"]
}
}
}
```
Full setup, including environment variables and provider keys, is in [install-mcp-claude-desktop.md](install-mcp-claude-desktop.md).
## What next?
- [Automation overview](automation.md) — every input primitive, when to use which.
- [Agent](commands/agent.md) — providers, sessions, tools.
- [MCP](MCP.md) — expose Peekaboo to any MCP client.
- [Configuration](configuration.md) — env vars, profiles, credentials.

View File

@ -1,2 +1 @@
www.peekaboo.boo
peekaboo.sh

View File

@ -9,7 +9,7 @@
name="description"
content="Peekaboo brings highfidelity screen capture, AI analysis, and complete GUI automation to macOS. Give your agents eyes."
/>
<link rel="canonical" href="https://www.peekaboo.boo/" />
<link rel="canonical" href="https://peekaboo.sh/" />
<meta property="og:title" content="Peekaboo" />
<meta
@ -17,7 +17,7 @@
content="macOS automation that sees the screen and does the clicks. Give your agents eyes."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://www.peekaboo.boo/" />
<meta property="og:url" content="https://peekaboo.sh/" />
<meta name="theme-color" content="#0b0c0f" />
<meta name="color-scheme" content="dark" />
@ -53,7 +53,7 @@
<a class="navLink" href="#install">Install</a>
<a class="navLink" href="#features">Features</a>
<a class="navLink" href="#how">How it works</a>
<a class="navLink" href="#docs">Docs</a>
<a class="navLink" href="./docs/">Docs</a>
<a class="navLink navLinkStrong" href="https://github.com/steipete/Peekaboo">GitHub</a>
</nav>
</div>
@ -303,22 +303,26 @@
</div>
<div class="links">
<a class="linkCard" href="./docs/">
<span class="linkTitle">Documentation hub</span>
<span class="linkHint">install · quickstart · CLI · MCP · agent</span>
</a>
<a class="linkCard" href="./docs/quickstart.html">
<span class="linkTitle">Quickstart</span>
<span class="linkHint">first capture, click, and agent run</span>
</a>
<a class="linkCard" href="./docs/MCP.html">
<span class="linkTitle">MCP setup</span>
<span class="linkHint">Claude Desktop · Cursor · Codex</span>
</a>
<a class="linkCard" href="./docs/ARCHITECTURE.html">
<span class="linkTitle">Architecture</span>
<span class="linkHint">how the pieces fit</span>
</a>
<a class="linkCard" href="https://github.com/steipete/Peekaboo">
<span class="linkTitle">Repository</span>
<span class="linkHint">source · releases · issues</span>
</a>
<a class="linkCard" href="https://github.com/steipete/Peekaboo/blob/main/docs/cli-command-reference.md">
<span class="linkTitle">Command reference</span>
<span class="linkHint">all CLI commands, grouped</span>
</a>
<a class="linkCard" href="https://github.com/steipete/Peekaboo/blob/main/docs/commands/mcp.md">
<span class="linkTitle">MCP setup</span>
<span class="linkHint">Claude Desktop · Cursor</span>
</a>
<a class="linkCard" href="https://github.com/steipete/Peekaboo/blob/main/docs/ARCHITECTURE.md">
<span class="linkTitle">Architecture</span>
<span class="linkHint">how the pieces fit</span>
</a>
</div>
</div>
</section>

View File

@ -1,5 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://www.peekaboo.boo/sitemap.xml
Sitemap: https://peekaboo.sh/sitemap.xml

View File

@ -1,7 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.peekaboo.boo/</loc>
</url>
<url><loc>https://peekaboo.sh/</loc></url>
<url><loc>https://peekaboo.sh/docs/</loc></url>
<url><loc>https://peekaboo.sh/docs/install.html</loc></url>
<url><loc>https://peekaboo.sh/docs/quickstart.html</loc></url>
<url><loc>https://peekaboo.sh/docs/permissions.html</loc></url>
<url><loc>https://peekaboo.sh/docs/configuration.html</loc></url>
<url><loc>https://peekaboo.sh/docs/automation.html</loc></url>
<url><loc>https://peekaboo.sh/docs/MCP.html</loc></url>
<url><loc>https://peekaboo.sh/docs/providers.html</loc></url>
<url><loc>https://peekaboo.sh/docs/cli-command-reference.html</loc></url>
<url><loc>https://peekaboo.sh/docs/ARCHITECTURE.html</loc></url>
</urlset>

View File

@ -38,6 +38,7 @@
"prepare-release": "node scripts/prepare-release.js",
"release:mac-app": "./scripts/release-macos-app.sh",
"docs:list": "node scripts/docs-list.mjs",
"docs:site": "node scripts/build-docs-site.mjs",
"lint:docs": "node scripts/docs-lint.mjs",
"polter": "FORCE_COLOR=1 CLICOLOR_FORCE=1 NODE_PATH=../poltergeist/node_modules script -q /dev/null node ../poltergeist/dist/polter.js",
"polter:dev": "cd /Users/steipete/Projects/Peekaboo && FORCE_COLOR=1 CLICOLOR_FORCE=1 NODE_PATH=../poltergeist/node_modules pnpm --dir ../poltergeist exec tsx ../poltergeist/src/polter.ts",

840
scripts/build-docs-site.mjs Normal file
View File

@ -0,0 +1,840 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { css, faviconSvg, js } from "./docs-site-assets.mjs";
const root = process.cwd();
const docsDir = path.join(root, "docs");
const siteSrcDir = path.join(docsDir, "site");
const outDir = path.join(root, "_site");
const repoBase = "https://github.com/steipete/Peekaboo";
const repoEditBase = `${repoBase}/edit/main/docs`;
const cname = readCname();
const siteBase = cname ? `https://${cname}` : "";
const productName = "Peekaboo";
const productTagline = "macOS automation that sees the screen and does the clicks";
const productDescription =
"Peekaboo brings high-fidelity screen capture, AI analysis, and complete GUI automation to macOS. Give your agents eyes.";
// Sidebar order. Files in `docs/` referenced by relative path. Anything not listed
// here is still built (so links work) but doesn't appear in the nav.
const sections = [
["Start", ["index.md", "install.md", "quickstart.md", "permissions.md", "configuration.md"]],
[
"Capture & vision",
[
"commands/capture.md",
"commands/see.md",
"commands/image.md",
"window-screenshot-smart-select.md",
"visualizer.md",
],
],
[
"Automation",
[
"automation.md",
"commands/click.md",
"commands/type.md",
"commands/hotkey.md",
"commands/press.md",
"commands/scroll.md",
"commands/drag.md",
"commands/menu.md",
"commands/dialog.md",
"commands/window.md",
"commands/space.md",
"commands/app.md",
"human-typing.md",
"human-mouse-move.md",
"focus.md",
"application-resolving.md",
],
],
[
"Agent & AI",
[
"commands/agent.md",
"agent-chat.md",
"agent-patterns.md",
"agent-skill.md",
"providers.md",
],
],
[
"MCP",
[
"MCP.md",
"commands/mcp.md",
"install-mcp-claude-desktop.md",
"mcp-best-practices.md",
],
],
[
"Architecture",
[
"ARCHITECTURE.md",
"engine.md",
"daemon.md",
"bridge-host.md",
"concurrency.md",
],
],
[
"Reference",
[
"cli-command-reference.md",
"commands/README.md",
"logging-guide.md",
"RELEASING.md",
"building.md",
],
],
];
// Files we don't want to ship as their own pages on the site (internal/dev notes).
const buildExcludes = [
/^archive\//,
/^refactor\//,
/^refactor\.md$/,
/^debug\//,
/^dev\//,
/^research\//,
/^reports\//,
/^references\//,
/^testing\//,
/^logging-profiles\//,
/^providers\//,
/^TODO\.md$/,
/^test-refactor\.md$/,
/^module-architecture-refactoring\.md$/,
/^module-refactoring-example\.md$/,
/^modern-api\.md$/,
/^modern-swift\.md$/,
/^silgen-crash-debug\.md$/,
/^swift-.*\.md$/,
/^swift6-.*\.md$/,
/^SwiftUI-.*\.md$/,
/^AppKit-.*\.md$/,
/^skylight-.*\.md$/,
/^playground-testing\.md$/,
/^claude-hooks\.md$/,
/^manual-testing\.md$/,
/^remote-testing\.md$/,
/^tool-formatter-architecture\.md$/,
/^tui\.md$/,
/^restore\.md$/,
/^poltergeist\.md$/,
/^homebrew-setup\.md$/,
/^oauth\.md$/,
/^audio\.md$/,
/^commander\.md$/,
/^spec\.md$/,
/^service-api-reference\.md$/,
/^error-handling-guide\.md$/,
/^mcp-testing\.md$/,
/^security\.md$/,
];
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
const allPages = allMarkdown(docsDir).map((file) => {
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
const raw = fs.readFileSync(file, "utf8");
const { frontmatter, body } = parseFrontmatter(raw);
const cleaned = stripStrayDirectives(body);
const title = frontmatter.title || firstHeading(cleaned) || titleize(path.basename(rel, ".md"));
return {
file,
rel,
title,
outRel: outPath(rel, frontmatter),
markdown: cleaned,
frontmatter,
};
});
const pages = allPages.filter((page) => !buildExcludes.some((re) => re.test(page.rel)));
const pageMap = new Map(pages.map((page) => [page.rel, page]));
const nav = sections
.map(([name, rels]) => ({
name,
pages: rels.map((rel) => pageMap.get(rel)).filter(Boolean),
}))
.filter((section) => section.pages.length);
const sectionByRel = new Map();
for (const section of nav) for (const page of section.pages) sectionByRel.set(page.rel, section.name);
const orderedPages = nav.flatMap((s) => s.pages);
// Build sub-pages under /docs/<rel>.html
for (const page of pages) {
const html = markdownToHtml(page.markdown, page.rel);
const toc = tocFromHtml(html);
const idx = orderedPages.findIndex((p) => p.rel === page.rel);
const prev = idx > 0 ? orderedPages[idx - 1] : null;
const next = idx >= 0 && idx < orderedPages.length - 1 ? orderedPages[idx + 1] : null;
const sectionName = sectionByRel.get(page.rel) || "Reference";
const pageOut = path.join(outDir, "docs", page.outRel);
fs.mkdirSync(path.dirname(pageOut), { recursive: true });
fs.writeFileSync(pageOut, layout({ page, html, toc, prev, next, sectionName }), "utf8");
}
// Copy hand-built landing site (root index.html, css, js, images, etc.)
copyTree(siteSrcDir, outDir);
// Site-wide assets used by docs sub-pages
fs.writeFileSync(path.join(outDir, "favicon.svg"), faviconSvg(), "utf8");
fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8");
if (cname) fs.writeFileSync(path.join(outDir, "CNAME"), cname, "utf8");
validateLinks(outDir);
console.log(`built docs site: ${path.relative(root, outDir)}`);
function readCname() {
for (const candidate of [
path.join(siteSrcDir, "CNAME"),
path.join(docsDir, "CNAME"),
path.join(root, "CNAME"),
]) {
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, "utf8").trim();
}
return "";
}
function copyTree(src, dest) {
if (!fs.existsSync(src)) return;
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
if (entry.isDirectory()) {
fs.mkdirSync(d, { recursive: true });
copyTree(s, d);
} else {
fs.copyFileSync(s, d);
}
}
}
function parseFrontmatter(raw) {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) return { frontmatter: {}, body: raw };
const fm = {};
for (const line of match[1].split("\n")) {
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
if (!m) continue;
let value = m[2];
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
fm[m[1]] = value;
}
return { frontmatter: fm, body: raw.slice(match[0].length) };
}
function stripStrayDirectives(body) {
return body
.replace(/\r\n/g, "\n")
.split("\n")
.filter((line) => !/^\s*\{:\s*[^}]*\}\s*$/.test(line))
.map((line) => line.replace(/\s*\{:\s*[^}]*\}\s*$/, ""))
.join("\n");
}
function allMarkdown(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.name === "site") return [];
if (entry.isDirectory()) return allMarkdown(full);
return entry.name.endsWith(".md") ? [full] : [];
})
.sort();
}
function outPath(rel) {
if (rel === "index.md") return "index.html";
if (rel === "README.md") return "index.html";
if (rel.endsWith("/README.md")) return rel.replace(/README\.md$/, "index.html");
return rel.replace(/\.md$/, ".html");
}
function firstHeading(markdown) {
return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
}
function titleize(input) {
return input.replaceAll("-", " ").replace(/\b\w/g, (m) => m.toUpperCase());
}
function markdownToHtml(markdown, currentRel) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const html = [];
let paragraph = [];
let list = null;
let fence = null;
let blockquote = [];
const flushParagraph = () => {
if (!paragraph.length) return;
html.push(`<p>${inline(paragraph.join(" "), currentRel)}</p>`);
paragraph = [];
};
const closeList = () => {
if (!list) return;
html.push(`</${list}>`);
list = null;
};
const flushBlockquote = () => {
if (!blockquote.length) return;
const inner = markdownToHtml(blockquote.join("\n"), currentRel);
html.push(`<blockquote>${inner}</blockquote>`);
blockquote = [];
};
const splitRow = (line) => {
let trimmed = line.trim();
if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
if (trimmed.endsWith("|") && !trimmed.endsWith("\\|")) trimmed = trimmed.slice(0, -1);
const cells = [];
let current = "";
for (let idx = 0; idx < trimmed.length; idx++) {
const char = trimmed[idx];
if (char === "\\" && trimmed[idx + 1] === "|") {
current += "\\|";
idx += 1;
continue;
}
if (char === "|") {
cells.push(current.trim().replace(/\\\|/g, "|"));
current = "";
continue;
}
current += char;
}
cells.push(current.trim().replace(/\\\|/g, "|"));
return cells;
};
const isDivider = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const fenceMatch = line.match(/^```([\w+-]+)?\s*$/);
if (fenceMatch) {
flushParagraph();
closeList();
flushBlockquote();
if (fence) {
const body = highlightCode(fence.lines.join("\n"), fence.lang);
html.push(`<pre><code class="language-${escapeAttr(fence.lang)}">${body}</code></pre>`);
fence = null;
} else {
fence = { lang: fenceMatch[1] || "text", lines: [] };
}
continue;
}
if (fence) {
fence.lines.push(line);
continue;
}
if (/^>\s?/.test(line)) {
flushParagraph();
closeList();
blockquote.push(line.replace(/^>\s?/, ""));
continue;
}
flushBlockquote();
if (!line.trim()) {
flushParagraph();
closeList();
continue;
}
if (/^\s*---+\s*$/.test(line)) {
flushParagraph();
closeList();
html.push("<hr>");
continue;
}
const heading = line.match(/^(#{1,4})\s+(.+)$/);
if (heading) {
flushParagraph();
closeList();
const level = heading[1].length;
const text = heading[2].trim();
const id = slug(text);
const inner = inline(text, currentRel);
if (level === 1) {
html.push(`<h1 id="${id}">${inner}</h1>`);
} else {
html.push(
`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-label="Anchor link">#</a>${inner}</h${level}>`,
);
}
continue;
}
if (line.trimStart().startsWith("|") && line.includes("|", line.indexOf("|") + 1) && isDivider(lines[i + 1] || "")) {
flushParagraph();
closeList();
const header = splitRow(line);
const aligns = splitRow(lines[i + 1]).map((cell) => {
const left = cell.startsWith(":");
const right = cell.endsWith(":");
return right && left ? "center" : right ? "right" : left ? "left" : "";
});
i += 1;
const rows = [];
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith("|")) {
i += 1;
rows.push(splitRow(lines[i]));
}
const th = header
.map((c, idx) => `<th${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</th>`)
.join("");
const tb = rows
.map(
(r) =>
`<tr>${r
.map(
(c, idx) =>
`<td${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</td>`,
)
.join("")}</tr>`,
)
.join("");
html.push(`<table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table>`);
continue;
}
const bullet = line.match(/^\s*-\s+(.+)$/);
const numbered = line.match(/^\s*\d+\.\s+(.+)$/);
if (bullet || numbered) {
flushParagraph();
const tag = bullet ? "ul" : "ol";
if (list && list !== tag) closeList();
if (!list) {
list = tag;
html.push(`<${tag}>`);
}
html.push(`<li>${inline((bullet || numbered)[1], currentRel)}</li>`);
continue;
}
paragraph.push(line.trim());
}
flushParagraph();
closeList();
flushBlockquote();
return html.join("\n");
}
function inline(text, currentRel) {
const stash = [];
let out = text.replace(/`([^`]+)`/g, (_, code) => {
stash.push(`<code>${escapeHtml(code)}</code>`);
return `${stash.length - 1}`;
});
out = escapeHtml(out)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1<em>$2</em>")
.replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, "$1<em>$2</em>")
.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_, label, href) => `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${label}</a>`,
)
.replace(/&lt;(https?:\/\/[^\s<>]+)&gt;/g, '<a href="$1">$1</a>');
out = out.replace(/\\\|/g, "|");
out = out.replace(/&lt;br&gt;/g, "<br>");
return out.replace(/(\d+)/g, (_, i) => stash[Number(i)]);
}
function rewriteHref(href, currentRel) {
if (/^(https?:|mailto:|tel:|#)/.test(href)) return href;
const [raw, hash = ""] = href.split("#");
if (!raw) return hash ? `#${hash}` : "";
if (raw.startsWith("/")) return href;
if (!raw.endsWith(".md")) return href;
const from = path.posix.dirname(currentRel);
const target = path.posix.normalize(path.posix.join(from, raw));
let rewritten = pageMap.get(target)?.outRel || outPath(target);
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
rewritten = hrefToOutRel(rewritten, currentOut);
return `${rewritten}${hash ? `#${hash}` : ""}`;
}
function tocFromHtml(html) {
const items = [];
const re = /<h([23]) id="([^"]+)">([\s\S]*?)<\/h[23]>/g;
let m;
while ((m = re.exec(html))) {
const text = m[3]
.replace(/<a class="anchor"[^>]*>.*?<\/a>/, "")
.replace(/<[^>]+>/g, "")
.trim();
items.push({ level: Number(m[1]), id: m[2], text });
}
if (items.length < 2) return "";
return `<nav class="toc" aria-label="On this page"><h2>On this page</h2>${items
.map((i) => `<a class="toc-l${i.level}" href="#${i.id}">${escapeHtml(i.text)}</a>`)
.join("")}</nav>`;
}
function standardHero(page, sectionName, editUrl, homeHref) {
return `<header class="hero">
<div class="hero-text">
<p class="eyebrow">${escapeHtml(sectionName)}</p>
<h1>${escapeHtml(page.title)}</h1>
</div>
<div class="hero-meta">
<a class="repo" href="${homeHref}">Home</a>
<a class="repo" href="${repoBase}" rel="noopener">GitHub</a>
<a class="edit" href="${escapeAttr(editUrl)}" rel="noopener">Edit page</a>
</div>
</header>`;
}
function layout({ page, html, toc, prev, next, sectionName }) {
// Pages live under /docs/<outRel>; root home is at "../" depth.
const docOutRel = `docs/${page.outRel}`;
const depth = docOutRel.split("/").length - 1;
const rootPrefix = depth ? "../".repeat(depth) : "";
const homeHref = rootPrefix || "./";
const editUrl = `${repoEditBase}/${page.rel}`;
const prevNext = prev || next ? pageNavHtml(prev, next, page.outRel) : "";
const heroBlock = standardHero(page, sectionName, editUrl, homeHref);
const titleSuffix = `${page.title}${productName}`;
const description = page.frontmatter.description || `${page.title}${productName} CLI documentation.`;
const canonicalUrl = pageCanonicalUrl(page);
const socialImage = siteBase ? `${siteBase}/social.png` : `${rootPrefix}social.png`;
const socialMeta = [
["link", "rel", "canonical", "href", canonicalUrl],
["meta", "property", "og:type", "content", "website"],
["meta", "property", "og:site_name", "content", productName],
["meta", "property", "og:title", "content", titleSuffix],
["meta", "property", "og:description", "content", description],
["meta", "property", "og:url", "content", canonicalUrl],
["meta", "property", "og:image", "content", socialImage],
["meta", "name", "twitter:card", "content", "summary_large_image"],
["meta", "name", "twitter:title", "content", titleSuffix],
["meta", "name", "twitter:description", "content", description],
["meta", "name", "twitter:image", "content", socialImage],
]
.map(tagHtml)
.join("\n ");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(titleSuffix)}</title>
<meta name="description" content="${escapeAttr(description)}">
<meta name="theme-color" content="#07080a">
<meta name="color-scheme" content="dark">
${socialMeta}
<link rel="icon" href="${rootPrefix}favicon.svg" type="image/svg+xml">
<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=Fraunces:opsz,wght@9..144,300..800&family=Recursive:wght@300..800&family=JetBrains+Mono:wght@400..700&display=swap" rel="stylesheet">
<style>${css()}</style>
</head>
<body>
<button class="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div class="shell">
<aside class="sidebar">
<div class="sidebar-head">
<a class="brand" href="${homeHref}" aria-label="${productName} home">
<span class="mark" aria-hidden="true"></span>
<span><strong>${escapeHtml(productName)}</strong><small>macOS automation docs</small></span>
</a>
</div>
<label class="search"><span>Search</span><input id="doc-search" type="search" placeholder="capture, click, agent, mcp"></label>
<nav>${navHtml(page)}</nav>
</aside>
<main>
${heroBlock}
<div class="doc-grid">
<article class="doc">${html}${prevNext}</article>
${toc}
</div>
</main>
</div>
<script>${js()}</script>
</body>
</html>`;
}
function pageCanonicalUrl(page) {
if (!siteBase) return `docs/${page.outRel}`;
const rel = page.outRel.endsWith("/index.html") ? page.outRel.slice(0, -"index.html".length) : page.outRel;
return `${siteBase}/docs/${rel}`;
}
function tagHtml([tag, k1, v1, k2, v2]) {
return tag === "link"
? `<link ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`
: `<meta ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`;
}
function pageNavHtml(prev, next, currentOutRel) {
const cell = (page, dir) => {
if (!page) return "";
return `<a class="page-nav-${dir}" href="${hrefToOutRel(page.outRel, currentOutRel)}"><small>${dir === "prev" ? "Previous" : "Next"}</small><span>${escapeHtml(page.title)}</span></a>`;
};
return `<nav class="page-nav" aria-label="Pager">${cell(prev, "prev")}${cell(next, "next")}</nav>`;
}
function navHtml(currentPage) {
return nav
.map(
(section) =>
`<section><h2>${escapeHtml(section.name)}</h2>${section.pages
.map((page) => {
const href = hrefToOutRel(page.outRel, currentPage.outRel);
const active = page.rel === currentPage.rel ? " active" : "";
return `<a class="nav-link${active}" href="${href}">${escapeHtml(navTitle(page))}</a>`;
})
.join("")}</section>`,
)
.join("");
}
function navTitle(page) {
if (page.rel === "index.md") return "Overview";
if (page.rel === "commands/README.md") return "Command index";
return page.title.replace(/^`peekaboo\s*/, "").replace(/`$/, "");
}
function hrefToOutRel(targetOutRel, currentOutRel) {
const currentDir = path.posix.dirname(currentOutRel);
if (targetOutRel.endsWith("/index.html")) {
const targetDir = targetOutRel.slice(0, -"index.html".length);
const rel = path.posix.relative(currentDir, targetDir || ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
if (targetOutRel === "index.html") {
const rel = path.posix.relative(currentDir, ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
return path.posix.relative(currentDir, targetOutRel) || path.posix.basename(targetOutRel);
}
function slug(text) {
return text.toLowerCase().replace(/`/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}
function escapeAttr(value) {
return escapeHtml(value);
}
function highlightCode(code, lang) {
const language = (lang || "text").toLowerCase();
if (
language === "bash" ||
language === "sh" ||
language === "shell" ||
language === "zsh" ||
language === "console"
) {
return highlightShell(code);
}
if (language === "json" || language === "json5") return highlightJson(code);
if (
language === "ts" ||
language === "typescript" ||
language === "js" ||
language === "javascript" ||
language === "tsx" ||
language === "jsx"
) {
return highlightJs(code);
}
if (language === "swift") return highlightSwift(code);
if (language === "yaml" || language === "yml") return highlightYaml(code);
return escapeHtml(code);
}
function stashToken(idx) {
return String.fromCharCode(0xe000 + idx);
}
function restoreStashTokens(value, stash) {
return value.replace(/[-]/g, (token) => {
const idx = token.charCodeAt(0) - 0xe000;
return stash[idx] ?? "";
});
}
function withStash(code, patterns) {
const stash = [];
let working = code;
for (const [re, cls] of patterns) {
working = working.replace(re, (match) => {
const idx = stash.length;
stash.push(`<span class="${cls}">${escapeHtml(match)}</span>`);
return stashToken(idx);
});
}
return restoreStashTokens(escapeHtml(working), stash);
}
function highlightShell(code) {
return code
.split("\n")
.map((line) => {
if (/^\s*#/.test(line)) return `<span class="hl-c">${escapeHtml(line)}</span>`;
const promptMatch = line.match(/^(\s*)([$#>])(\s+)(.*)$/);
if (promptMatch) {
const [, lead, sym, gap, rest] = promptMatch;
return `${escapeHtml(lead)}<span class="hl-p">${escapeHtml(sym)}</span>${escapeHtml(gap)}${highlightShellLine(rest)}`;
}
return highlightShellLine(line);
})
.join("\n");
}
function highlightShellLine(line) {
const stash = [];
const stashAdd = (match, cls) => {
const idx = stash.length;
stash.push(`<span class="${cls}">${escapeHtml(match)}</span>`);
return stashToken(idx);
};
let working = line;
working = working.replace(/(?:'[^']*'|"[^"]*")/g, (m) => stashAdd(m, "hl-s"));
working = working.replace(/\s#.*$/g, (m) => stashAdd(m, "hl-c"));
working = working.replace(/(^|\s)(--?[A-Za-z][A-Za-z0-9-]*)/g, (_, lead, flag) => `${escapeHtml(lead)}${stashAdd(flag, "hl-f")}`);
working = working.replace(
/\b(peekaboo|brew|npx|npm|pnpm|yarn|node|swift|git|gh|make|sudo|cd|export|cat|curl|jq|ls|mv|cp|rm|mkdir|docker|tail)\b/g,
(m) => stashAdd(m, "hl-cmd"),
);
working = working.replace(/\b(\d+(?:\.\d+)?)\b/g, (m) => stashAdd(m, "hl-n"));
return restoreStashTokens(escapeHtml(working), stash);
}
function highlightJson(code) {
return withStash(code, [
[/"(?:\\.|[^"\\])*"\s*:/g, "hl-k"],
[/"(?:\\.|[^"\\])*"/g, "hl-s"],
[/\b(true|false|null)\b/g, "hl-m"],
[/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/gi, "hl-n"],
]);
}
function highlightJs(code) {
return withStash(code, [
[/\/\/[^\n]*/g, "hl-c"],
[/\/\*[\s\S]*?\*\//g, "hl-c"],
[/`(?:\\.|[^`\\])*`/g, "hl-s"],
[/"(?:\\.|[^"\\])*"/g, "hl-s"],
[/'(?:\\.|[^'\\])*'/g, "hl-s"],
[
/\b(const|let|var|function|return|if|else|for|while|switch|case|break|continue|class|extends|new|import|from|export|default|async|await|try|catch|finally|throw|typeof|instanceof|interface|type|enum|as|of|in|null|undefined|true|false|this)\b/g,
"hl-k",
],
[/\b(\d+(?:\.\d+)?)\b/g, "hl-n"],
]);
}
function highlightSwift(code) {
return withStash(code, [
[/\/\/[^\n]*/g, "hl-c"],
[/\/\*[\s\S]*?\*\//g, "hl-c"],
[/"(?:\\.|[^"\\])*"/g, "hl-s"],
[
/\b(let|var|func|class|struct|enum|protocol|extension|actor|import|return|if|else|for|while|switch|case|break|continue|try|throw|throws|async|await|guard|defer|do|public|private|internal|fileprivate|open|static|final|init|deinit|nil|true|false|self|Self|some|any)\b/g,
"hl-k",
],
[/\b(\d+(?:\.\d+)?)\b/g, "hl-n"],
]);
}
function highlightYaml(code) {
return code
.split("\n")
.map((line) => {
if (/^\s*#/.test(line)) return `<span class="hl-c">${escapeHtml(line)}</span>`;
const m = line.match(/^(\s*-?\s*)([A-Za-z0-9_.-]+)(\s*:)(.*)$/);
if (m) {
const [, lead, key, colon, rest] = m;
return `${escapeHtml(lead)}<span class="hl-k">${escapeHtml(key)}</span>${escapeHtml(colon)}${highlightYamlValue(rest)}`;
}
return escapeHtml(line);
})
.join("\n");
}
function highlightYamlValue(rest) {
if (!rest.trim()) return escapeHtml(rest);
const trimmed = rest.trim();
if (/^["'].*["']$/.test(trimmed)) {
return escapeHtml(rest.replace(trimmed, "")) + `<span class="hl-s">${escapeHtml(trimmed)}</span>`;
}
if (/^(true|false|null|~)$/i.test(trimmed)) {
return escapeHtml(rest.replace(trimmed, "")) + `<span class="hl-m">${escapeHtml(trimmed)}</span>`;
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return escapeHtml(rest.replace(trimmed, "")) + `<span class="hl-n">${escapeHtml(trimmed)}</span>`;
}
return escapeHtml(rest);
}
function validateLinks(outputDir) {
const fatal = [];
const warnings = [];
const placeholderHrefs = /^(url|path|file|dir|name)$/i;
for (const file of allHtml(outputDir)) {
const html = fs.readFileSync(file, "utf8");
for (const match of html.matchAll(/href="([^"]+)"/g)) {
const href = match[1];
if (/^(#|https?:|mailto:|tel:|javascript:)/.test(href)) continue;
if (placeholderHrefs.test(href)) continue;
const [rawPath, anchor = ""] = href.split("#");
const targetPath = rawPath ? path.resolve(path.dirname(file), rawPath) : file;
const target =
fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
? path.join(targetPath, "index.html")
: targetPath;
if (!fs.existsSync(target)) {
warnings.push(`${path.relative(outputDir, file)}: ${href} -> missing ${path.relative(outputDir, target)}`);
continue;
}
if (anchor) {
const targetHtml = fs.readFileSync(target, "utf8");
if (!targetHtml.includes(`id="${anchor}"`) && !targetHtml.includes(`name="${anchor}"`)) {
warnings.push(`${path.relative(outputDir, file)}: ${href} -> missing anchor`);
}
}
}
}
if (warnings.length) {
console.warn(`docs site: ${warnings.length} broken link(s) (source-side typos, not fatal):`);
for (const w of warnings) console.warn(` ${w}`);
}
if (fatal.length) {
throw new Error(`broken docs links:\n${fatal.join("\n")}`);
}
}
function allHtml(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allHtml(full);
return entry.name.endsWith(".html") ? [full] : [];
})
.sort();
}

View File

@ -0,0 +1,245 @@
export function css() {
return `
:root{
--bg0:#07080a;
--bg1:#0a0b0f;
--bg2:#0e1020;
--panel:rgba(255,255,255,0.045);
--panel2:rgba(255,255,255,0.075);
--line:rgba(255,255,255,0.10);
--line-soft:rgba(255,255,255,0.05);
--ink:rgba(255,255,255,0.96);
--text:rgba(255,255,255,0.86);
--muted:rgba(255,255,255,0.62);
--subtle:rgba(255,255,255,0.42);
--ecto:#00f5a0;
--ecto2:#00c08f;
--moon:#ffd24a;
--hot:#ff3d78;
--accent:var(--ecto);
--accent-soft:rgba(0,245,160,0.14);
--accent-strong:#3affba;
--paper:rgba(255,255,255,0.03);
--code-bg:#06080d;
--code-fg:#e6edf3;
--code-border:rgba(255,255,255,0.08);
--shadow-card:0 18px 40px rgba(0,0,0,0.55);
--hl-keyword:#7aa2ff;
--hl-string:#a6e3a1;
--hl-number:#f0a868;
--hl-comment:#6b7388;
--hl-flag:#c4a4ff;
--hl-meta:#ff8aa0;
--hl-prompt:#7e8ba3;
--serif:"Fraunces",ui-serif,Georgia,serif;
--sans:"Recursive",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
}
:root{color-scheme:dark}
*{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:24px}
body{
margin:0;
background:radial-gradient(1200px 600px at 70% -10%,rgba(0,245,160,0.10),transparent 60%),
radial-gradient(900px 500px at 0% 110%,rgba(255,210,74,0.07),transparent 55%),
var(--bg0);
color:var(--text);
font-family:var(--sans);
line-height:1.65;
overflow-x:hidden;
-webkit-font-smoothing:antialiased;
font-feature-settings:"ss02","ss03";
}
::selection{background:var(--ecto);color:#04130c}
a{color:var(--ecto);text-decoration:none;transition:color .12s,opacity .12s}
a:hover{opacity:.85;text-decoration:underline;text-underline-offset:.2em}
.shell{display:grid;grid-template-columns:280px minmax(0,1fr);min-height:100vh}
.sidebar{position:sticky;top:0;height:100vh;overflow:auto;padding:24px 22px;background:rgba(7,8,10,0.85);backdrop-filter:saturate(140%) blur(8px);border-right:1px solid var(--line);scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.sidebar::-webkit-scrollbar{width:6px}
.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
.sidebar-head{display:flex;align-items:center;gap:10px;margin-bottom:24px}
.brand{display:flex;align-items:center;gap:11px;color:var(--ink);text-decoration:none;flex:1;min-width:0}
.brand:hover{text-decoration:none;opacity:1}
.brand .mark{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:10px;background:radial-gradient(circle at 35% 25%,#00f5a0,#00c08f 60%,#04130c 100%);box-shadow:0 0 0 1px rgba(0,245,160,0.4),0 8px 18px rgba(0,245,160,0.25)}
.brand .mark::after{content:"";display:block;width:10px;height:10px;border-radius:50%;background:#04130c;box-shadow:0 0 0 2px rgba(255,255,255,0.2)}
.brand strong{display:block;font-family:var(--serif);font-size:1.15rem;line-height:1;font-weight:600;letter-spacing:0.01em;color:var(--ink)}
.brand small{display:block;color:var(--muted);font-size:.74rem;margin-top:4px;font-weight:400}
.search{display:block;margin:0 0 22px}
.search span{display:block;color:var(--muted);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:7px}
.search input{width:100%;border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:9px 12px;font:inherit;font-size:.9rem;color:var(--text);outline:none;transition:border-color .15s,box-shadow .15s}
.search input::placeholder{color:var(--subtle)}
.search input:focus{border-color:var(--ecto);box-shadow:0 0 0 3px var(--accent-soft)}
nav section{margin:0 0 18px}
nav h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.10em;margin:0 0 6px;font-weight:600}
.nav-link{display:block;color:var(--text);text-decoration:none;border-radius:6px;padding:5px 10px;margin:1px 0;font-size:.9rem;line-height:1.4;transition:background .12s,color .12s}
.nav-link:hover{background:var(--panel);color:var(--ink);text-decoration:none;opacity:1}
.nav-link.active{background:var(--accent-soft);color:var(--ecto);font-weight:600}
main{min-width:0;padding:32px clamp(20px,4.5vw,56px) 80px;max-width:1180px;margin:0 auto;width:100%}
.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:8px 0 22px;margin-bottom:8px;flex-wrap:wrap}
.hero-text{min-width:0;flex:1 1 320px}
.eyebrow{margin:0 0 8px;color:var(--ecto);font-weight:600;text-transform:uppercase;letter-spacing:0.10em;font-size:.7rem}
.hero h1{font-family:var(--serif);font-size:2.4rem;line-height:1.05;letter-spacing:0;margin:0;font-weight:600;color:var(--ink)}
.hero-meta{display:flex;gap:8px;flex:0 0 auto;flex-wrap:wrap}
.repo,.edit,.btn-ghost{border:1px solid var(--line);color:var(--text);text-decoration:none;border-radius:8px;padding:7px 12px;font-weight:500;font-size:.83rem;background:var(--panel);transition:border-color .15s,color .15s,background .15s}
.repo:hover,.edit:hover,.btn-ghost:hover{border-color:var(--ecto);color:var(--ink);text-decoration:none;opacity:1}
.edit{color:var(--muted)}
.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:48px;margin-top:24px}
@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,72ch) 220px;justify-content:start}}
.doc{min-width:0;max-width:72ch;overflow-wrap:break-word}
.doc h1{font-family:var(--serif);font-size:2.6rem;line-height:1.08;letter-spacing:0;margin:0 0 .4em;font-weight:600;color:var(--ink)}
body:not(.home) .doc>h1:first-child{display:none}
.doc h2{font-family:var(--serif);font-size:1.55rem;line-height:1.2;margin:2em 0 .5em;font-weight:600;letter-spacing:0;color:var(--ink);position:relative}
.doc h3{font-family:var(--serif);font-size:1.2rem;margin:1.7em 0 .35em;position:relative;font-weight:600;color:var(--ink);letter-spacing:0}
.doc h4{font-size:1rem;margin:1.4em 0 .25em;color:var(--ink);position:relative;font-weight:600}
.doc h2:first-child,.doc h3:first-child,.doc h4:first-child{margin-top:.2em}
.doc :is(h2,h3,h4) .anchor{position:absolute;left:-1.05em;top:0;color:var(--subtle);opacity:0;text-decoration:none;font-weight:400;padding-right:.3em;transition:opacity .12s,color .12s}
.doc :is(h2,h3,h4):hover .anchor{opacity:.7}
.doc :is(h2,h3,h4) .anchor:hover{opacity:1;color:var(--ecto);text-decoration:none}
.doc p{margin:0 0 1.05em}
.doc ul,.doc ol{padding-left:1.3rem;margin:0 0 1.15em}
.doc li{margin:.25em 0}
.doc li>p{margin:0 0 .4em}
.doc strong{font-weight:600;color:var(--ink)}
.doc em{font-style:italic;color:var(--ink)}
.doc code{font-family:var(--mono);font-size:.84em;background:var(--panel);border:1px solid var(--line);border-radius:5px;padding:.08em .35em;color:var(--ink)}
.doc pre{position:relative;overflow:auto;background:var(--code-bg);color:var(--code-fg);border-radius:10px;padding:14px 18px;margin:1.3em 0;font-size:.85em;line-height:1.6;scrollbar-width:thin;scrollbar-color:#334155 transparent;border:1px solid var(--code-border)}
.doc pre::-webkit-scrollbar{height:8px;width:8px}
.doc pre::-webkit-scrollbar-thumb{background:#334155;border-radius:8px}
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre}
.doc pre .copy{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.06);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:3px 9px;font:500 .7rem/1 var(--sans);cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s}
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
.doc pre .copy:hover{background:rgba(255,255,255,.12)}
.doc pre .copy.copied{background:var(--ecto);border-color:var(--ecto);color:#04130c;opacity:1}
.doc pre .hl-c{color:var(--hl-comment);font-style:italic}
.doc pre .hl-s{color:var(--hl-string)}
.doc pre .hl-n{color:var(--hl-number)}
.doc pre .hl-k{color:var(--hl-keyword);font-weight:600}
.doc pre .hl-f{color:var(--hl-flag)}
.doc pre .hl-m{color:var(--hl-meta);font-weight:600}
.doc pre .hl-p{color:var(--hl-prompt);user-select:none}
.doc pre .hl-cmd{color:var(--ecto);font-weight:600}
.doc blockquote{margin:1.4em 0;padding:10px 16px;border-left:3px solid var(--ecto);background:var(--accent-soft);border-radius:0 8px 8px 0;color:var(--text)}
.doc blockquote p:last-child{margin-bottom:0}
.doc table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:.92em}
.doc th,.doc td{border-bottom:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top}
.doc th{font-weight:600;color:var(--ink);background:var(--panel);border-bottom:1px solid var(--line)}
.doc hr{border:0;border-top:1px solid var(--line);margin:2.2em 0}
.toc{position:sticky;top:24px;align-self:start;font-size:.84rem;padding-left:14px;border-left:1px solid var(--line);max-height:calc(100vh - 48px);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.toc::-webkit-scrollbar{width:5px}
.toc::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
.toc h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.10em;margin:0 0 10px;font-weight:600}
.toc a{display:block;color:var(--muted);text-decoration:none;padding:4px 0 4px 10px;line-height:1.35;border-left:2px solid transparent;margin-left:-12px;transition:color .12s,border-color .12s}
.toc a:hover{color:var(--ink);text-decoration:none;opacity:1}
.toc a.active{color:var(--ecto);border-left-color:var(--ecto);font-weight:500}
.toc-l3{padding-left:22px!important;font-size:.94em}
@media(max-width:1179px){.toc{display:none}}
.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:48px;border-top:1px solid var(--line);padding-top:20px}
.page-nav>a{display:block;border:1px solid var(--line);background:var(--panel);border-radius:10px;padding:13px 16px;text-decoration:none;color:var(--text);transition:border-color .15s,transform .15s,background .18s}
.page-nav>a:hover{border-color:var(--ecto);text-decoration:none;color:var(--ink);opacity:1}
.page-nav small{display:block;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:0.10em;margin-bottom:5px;font-weight:600}
.page-nav span{display:block;font-weight:600;line-height:1.3;color:var(--ink)}
.page-nav-prev{text-align:left}
.page-nav-next{text-align:right;grid-column:2}
.page-nav-prev:only-child{grid-column:1}
.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top, 0px));right:calc(14px + env(safe-area-inset-right, 0px));z-index:20;width:42px;height:42px;border-radius:10px;background:rgba(7,8,10,0.8);backdrop-filter:blur(10px);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:11px 10px;flex-direction:column;align-items:stretch;justify-content:space-between}
.nav-toggle span{display:block;width:100%;height:2px;flex:0 0 2px;background:currentColor;border-radius:2px;transition:transform .2s,opacity .2s}
.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)}
.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0}
.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
@media(max-width:900px){
.shell{display:block}
.sidebar{position:fixed;inset:0 30% 0 0;max-width:320px;height:100vh;z-index:15;transform:translateX(-100%);transition:transform .25s ease;box-shadow:0 18px 40px rgba(0,0,0,.45);background:rgba(7,8,10,0.96);pointer-events:none}
.sidebar.open{transform:translateX(0);pointer-events:auto}
.nav-toggle{display:flex}
main{padding:64px 18px 56px}
.hero{padding-top:6px}
.hero h1{font-size:1.9rem}
.doc h1{font-size:2.1rem}
.hero-meta{width:100%;justify-content:flex-start}
.doc{padding:0}
.doc-grid{margin-top:18px;gap:24px}
.doc :is(h2,h3,h4) .anchor{display:none}
}
@media(max-width:520px){
main{padding:60px 14px 48px}
.doc pre{margin-left:-14px;margin-right:-14px;border-radius:0;border-left:0;border-right:0}
}
`;
}
export function js() {
return `
const sidebar=document.querySelector('.sidebar');
const toggle=document.querySelector('.nav-toggle');
const mobileNav=window.matchMedia('(max-width: 900px)');
const sidebarFocusable='a[href],button,input,select,textarea,[tabindex]';
function setSidebarFocusable(enabled){
sidebar?.querySelectorAll(sidebarFocusable).forEach((el)=>{
if(enabled){
if(el.dataset.sidebarTabindex!==undefined){
if(el.dataset.sidebarTabindex)el.setAttribute('tabindex',el.dataset.sidebarTabindex);
else el.removeAttribute('tabindex');
delete el.dataset.sidebarTabindex;
}
}else if(el.dataset.sidebarTabindex===undefined){
el.dataset.sidebarTabindex=el.getAttribute('tabindex')??'';
el.setAttribute('tabindex','-1');
}
});
}
function setSidebarOpen(open){
if(!sidebar||!toggle)return;
sidebar.classList.toggle('open',open);
toggle.setAttribute('aria-expanded',open?'true':'false');
if(mobileNav.matches){
sidebar.inert=!open;
if(open)sidebar.removeAttribute('aria-hidden');
else sidebar.setAttribute('aria-hidden','true');
setSidebarFocusable(open);
}else{
sidebar.inert=false;
sidebar.removeAttribute('aria-hidden');
setSidebarFocusable(true);
}
}
setSidebarOpen(false);
toggle?.addEventListener('click',()=>setSidebarOpen(!sidebar?.classList.contains('open')));
document.addEventListener('click',(e)=>{if(!sidebar?.classList.contains('open'))return;if(sidebar.contains(e.target)||toggle?.contains(e.target))return;setSidebarOpen(false)});
document.addEventListener('keydown',(e)=>{if(e.key==='Escape')setSidebarOpen(false)});
const syncSidebarForViewport=()=>setSidebarOpen(sidebar?.classList.contains('open')??false);
if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarForViewport);
else mobileNav.addListener?.(syncSidebarForViewport);
const input=document.getElementById('doc-search');
input?.addEventListener('input',()=>{const q=input.value.trim().toLowerCase();document.querySelectorAll('nav section').forEach(sec=>{let any=false;sec.querySelectorAll('.nav-link').forEach(a=>{const m=!q||a.textContent.toLowerCase().includes(q);a.style.display=m?'block':'none';if(m)any=true});sec.style.display=any?'block':'none'})});
function attachCopy(target,getText){const btn=document.createElement('button');btn.type='button';btn.className='copy';btn.textContent='Copy';btn.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(getText());btn.textContent='Copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied')},1400)}catch{btn.textContent='Failed';setTimeout(()=>{btn.textContent='Copy'},1400)}});target.appendChild(btn)}
document.querySelectorAll('.doc pre').forEach(pre=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
const tocLinks=document.querySelectorAll('.toc a');
if(tocLinks.length){const map=new Map();tocLinks.forEach(a=>{const id=a.getAttribute('href').slice(1);const el=document.getElementById(id);if(el)map.set(el,a)});const setActive=l=>{tocLinks.forEach(x=>x.classList.remove('active'));l.classList.add('active')};const obs=new IntersectionObserver(entries=>{const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.boundingClientRect.top-b.boundingClientRect.top);if(visible.length){const link=map.get(visible[0].target);if(link)setActive(link)}},{rootMargin:'-15% 0px -65% 0px',threshold:0});map.forEach((_,el)=>obs.observe(el))}
`;
}
export function faviconSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Peekaboo">
<defs>
<radialGradient id="g" cx="0.45" cy="0.32" r="0.65">
<stop offset="0" stop-color="#00f5a0"/>
<stop offset="0.6" stop-color="#00c08f"/>
<stop offset="1" stop-color="#053826"/>
</radialGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#07080a"/>
<circle cx="32" cy="32" r="22" fill="url(#g)"/>
<circle cx="32" cy="32" r="10" fill="#04130c"/>
<circle cx="28" cy="28" r="3.6" fill="rgba(255,255,255,0.85)"/>
</svg>`;
}