diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 940e457d1..0a698caee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -196,7 +196,7 @@ jobs: run: pnpm --filter dormouse build - name: Package extension - run: pnpm --dir vscode-ext exec vsce package --no-dependencies + run: pnpm --dir vscode-ext exec vsce package --no-dependencies --baseImagesUrl https://dormouse.sh/guide - name: Generate artifact manifest shell: bash diff --git a/.gitignore b/.gitignore index 9d4dbc660..47c6df4f4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,10 @@ lib/.vite/ # VSCode extension vscode-ext/dist/ -vscode-ext/dor-cli/ +# The webview bundle's Vite output. Emptied on every build, so guide media +# lives in vscode-ext/images/ instead (docs/specs/website-docs.md). vscode-ext/media/ +vscode-ext/dor-cli/ vscode-ext/node_modules/ vscode-ext/*.vsix @@ -40,6 +42,8 @@ standalone/node_modules/ website/dist/ website/node_modules/ website/src/data/changelog.json +website/src/data/docs.*.json +website/public/guide/ # OS .DS_Store diff --git a/AGENTS.md b/AGENTS.md index f2185262c..fca8057eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ One implementation map per spec: an exhaustive `Files` / `Code Map` section or s - **`docs/specs/auto-update.md`** — Standalone auto-update: check → approved download → install-on-quit, the Baseboard notice, Windows sidecar teardown, per-platform quit behavior. - **`docs/specs/mobile-terminal-ui.md`** — The mobile composition (`MobileTerminalUi` / `MobileWall`): stable viewport + keyboard reserve, touch modes, the radial gesture menu; shipped in the Pocket playground and the Pocket app. - **`docs/specs/tutorial.md`** — Website playground tutorial: device-specific routes, the `tut` runner and progress state, desktop and Pocket profiles, the lib hooks for tutorial observability. +- **`docs/specs/website-docs.md`** — Public documentation on the marketing site: the generated references, the Markdown rendering contract they share, the left rail across the docs section, `vscode-ext/README.md` as the canonical guide published off-site, and the lint that pins their links. - **`docs/specs/webgl-text.md`** — SDF text rendering for the 3D/WebXR effort: the diffplug/xterm.js fork pipeline and its version lockstep, the SDF glyph architecture, the canopy Storybook lab. - **`docs/specs/remote-security-model.md`** — Remote-control trust model: one Noise channel per ceremony, passkeys proving presence inside it, per-Host Client statics, the Host (not the Server) authorizing the pair. Read first for anything remote. - **`docs/specs/remote-api.md`** — What an authorized Client speaks: the shipped terminal-only **protocol-v1** and the staged remainder. @@ -83,10 +84,11 @@ Specs are written ahead of the code: a new component's spec starts as a full des `scripts/spec-lint.mjs` (`pnpm lint:specs`, the first step of the root `pnpm test`) enforces the mechanically checkable conventions above — its header comment lists the checks — and ratchets size: every spec and this file carry a word budget in `scripts/spec-word-budgets.json`, its size rounded up to the nearest 50. Rationale files carry none; evidence may grow without limit. Over budget: cut to fit, or re-baseline with `node scripts/spec-lint.mjs --ratchet ` in the same PR. `SELF_HOST.md` rides the same checks. Advisory prose reviews follow `docs/prose-audit.md` (`pnpm audit:prose`). -Four sibling lints run in `pnpm test`, each enforcing one invariant a spec states in prose; each names the line it enforces and fails if that line is gone: +Five sibling lints run in `pnpm test`, each enforcing one invariant a spec states in prose; each names the line it enforces and fails if that line is gone: | Lint | Enforces | |---|---| +| `scripts/public-docs-lint.mjs` (`pnpm lint:public-docs`) | The public-doc contracts in `docs/specs/website-docs.md`, every inventory derived from the file that owns it. | | `scripts/xterm-lint.mjs` (`pnpm lint:xterm`) | The `@xterm/*` version lockstep in `docs/specs/webgl-text.md`. | | `scripts/loopback-lint.mjs` (`pnpm lint:loopback`) | `SECURITY.md` -> "Loopback Listeners": a loopback bind is not an access control — a new listener references a guard module or is allowlisted with a reason. | | `scripts/deploy-lint.mjs` (`pnpm lint:deploy`) | `SECURITY.md` -> "Credentials at rest" and "Network posture (self-hosted)": the installer controls binding all three of `deploy/local/install-{macos,windows,linux}`. | diff --git a/README.md b/README.md index 7b2543eb3..cc885e0f8 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,29 @@ [![maintained with tend](https://img.shields.io/badge/maintained_with-tend-bba580?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwxNikgc2NhbGUoMC4wMTI1LC0wLjAxMjUpIiBmaWxsPSIjZmZmIiBzdHJva2U9Im5vbmUiPjxwYXRoIGQ9Ik02ODAgMTEyOCBjNjIgLTk2IDY5IC0xNzggMjAgLTI0MSAtMTcgLTIyIC0yMCAtNDAgLTIwIC0xMzQgbDEgLTEwOCAyMSAyOCBjMTEgMTYgMzAgNDcgNDIgNzAgMTIgMjIgMzIgNDkgNDYgNTkgMzcgMjcgMTE0IDM4IDE4NCAyNyA5MyAtMTUgOTQgLTE4IDQ0IC03OSAtNzIgLTg4IC0xMDkgLTExMyAtMTc2IC0xMTcgLTMxIC0yIC02NCAxIC03MiA2IC0yMyAxNSAyMSA1NiAxMDcgOTggNDAgMjAgNzEgMzggNjkgNDAgLTYgNyAtODggLTE3IC0xMjYgLTM3IC00OSAtMjUgLTEwMCAtNzggLTEyMSAtMTI1IC0xNSAtMzMgLTE5IC02NiAtMTkgLTE4OCAwIC0xNTcgOCAtMTk1IDUwIC0yMzIgMTcgLTE2IDM2IC0yMCA4NSAtMTkgNjIgMSA2MyAxIDczIC0zMiA5IC0zMiA5IC0zMyAtMjIgLTQwIC01MCAtMTIgLTEzMiAtNyAtMTY0IDEwIC00MCAyMSAtNzkgNjkgLTkyIDExNCAtNSAyMCAtMTAgMTAyIC0xMCAxODIgMCA4MCAtNSAxNjIgLTExIDE4NCAtMjIgNzkgLTEzNSAxNjYgLTIzNCAxODEgLTM3IDYgLTM1IDMgMzAgLTI4IDc4IC0zOSAxNDQgLTkxIDEzMiAtMTA0IC01IC00IC0zNyAtOCAtNzEgLTggLTc3IDAgLTExNyAyNCAtMTgyIDEwOSAtNTIgNjggLTUxIDcwIDQyIDg1IDcxIDExIDE0MyAwIDE4MyAtMjkgMTYgLTExIDQwIC00MyA1NCAtNzMgMTMgLTI5IDMyIC01OSA0MSAtNjYgMTQgLTEyIDE2IC03IDE2IDU4IDAgNTkgNCA3NyAyMyAxMDIgMTkgMjYgMjMgNDYgMjUgMTMwIDMgNjcgMCA5OSAtNyA5OSAtNyAwIC0xMSAtMjMgLTEyIC01NyAwIC0zMiAtNiAtNzYgLTEyIC05NyBsLTEyIC00MCAtMjcgMzIgYy0zNCA0MSAtNDMgOTYgLTI0IDE1MSAxNCA0MSA3NSAxNDEgODYgMTQxIDMgMCAyMSAtMjQgNDAgLTUyeiIvPjwvZz48L3N2Zz4K)](https://github.com/max-sixty/tend) +A multitasking terminal for VS Code and the desktop — a real tiling layout, tmux keybindings, full mouse support, browser panes your agents can drive, and alerts that tell you when something needs you. + ## Try it - **[Playground](https://dormouse.sh/playground)** - try in your browser, no install - **[VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=diffplug.dormouse)** / **[Open VSX](https://open-vsx.org/extension/diffplug/dormouse)** - works in VS Code and its forks - **[Standalone app](https://dormouse.sh/#download)** - Mac, Windows, Linux +## Documentation + +- [CLI reference](https://dormouse.sh/docs/dor) — every `dor` command +- [Agent skill](https://dormouse.sh/docs/agent-skill) — the operating guide Dormouse bundles for coding agents +- [Self-host](https://dormouse.sh/docs/self-host) — run the coordinating server on your own tailnet + ## Features -- **Automatic completion detection.** Detect when an agent needs your attention. When a pane goes quiet for two seconds, it's marked done. Works with builds, AI agents, scripts, anything. +- **Alerts when something needs you.** Terminal notification protocols (`BEL`, `OSC 9/9;4/99/777`) and unattended command exits alert with no setup; opt in per command name to also be alerted when a watched command goes quiet. - **tmux-compatible keybindings.** Same prefix, same splits, same pane navigation. Muscle memory transfers. - **Full mouse support.** Click to split, drag to resize, scroll to navigate. Or stay on the keyboard. - **Copy-paste that works.** Click and drag selects text the way you'd expect, even in mouse-aware TUIs that normally swallow it as escape codes. -- **Sleep/wake panes.** Minimize a terminal to a compact status indicator. It keeps running and you can still see whether its task finished. +- **Minimize to doors.** Minimize a terminal to a compact status indicator. It keeps running and keeps reporting whether its task needs attention. +- **Browser panes.** Put a browser in the tiling layout next to the terminal serving it, drivable by you or your agents. - **Dual distribution.** Standalone desktop app (Mac/Windows/Linux) or VS Code extension. -- **Pocket (coming soon).** Tether your sessions to your phone over WebRTC — walk away, keep working. ## Development @@ -28,25 +36,30 @@ The terminal is currently hosted by `node-pty`, but we plan on switching to a Ru ```sh pnpm install -pnpm dev:website # vite hotreload at http://localhost:5173/playground +pnpm dev:website # vite hotreload at http://localhost:5173 pnpm dev:standalone # tauri hotreload +pnpm dev:server # selfhost relay server for remote control pnpm dogfood:vscode # builds the VSCode extension and installs it into your local VSCode pnpm dogfood:standalone # installs your local build overtop of your existing system installation pnpm dogfood:standalone --no-install # builds and runs the standalone app from the build dir, without installing pnpm storybook # http://localhost:6006 -pnpm test # runs all tests +pnpm test # runs all tests (spec lint first) +pnpm lint:specs # docs/specs conventions only ``` ### Folder structure | Path | Description | |------|-------------| -| `lib/` | Shared terminal library | -| `website/` | dormouse.sh (including playground) | -| `standalone/` | Tauri desktop app | -| `vscode-ext/` | VSCode extension | +| `lib/` | Shared React terminal library, plus the remote/Pocket modules | +| `website/` | dormouse.sh — marketing, playground, and the generated docs pages | +| `standalone/` | Tauri desktop app and its Node PTY sidecar | +| `vscode-ext/` | VS Code extension (the README there is the canonical product guide) | +| `server/` | Selfhost coordinating server for remote control | +| `dor/` | The `dor` CLI staged onto every Dormouse terminal's `PATH` | +| `docs/specs/` | Internal specs — the reference for how everything actually behaves | ### Agent strategy diff --git a/docs/specs/theme.md b/docs/specs/theme.md index e12e9c528..bdcc10e9c 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -185,9 +185,19 @@ playground navbar — carries none**. init **and repeats after commit** (hydration again). Pocket passes `restorePocketTheme` as its `restore` argument so the browser-chrome sync rides the same lifecycle. -- The two `/playground/pocket` marketing mounts keep the free-floating `compact` - picker (rationale). **Both variants show the active theme's `ThemeSwatch`** — - beside its label on the dialog trigger, beside the word "Theme" in `compact`. +- The two `/playground/pocket` marketing mounts and the docs pages keep the + free-floating `compact` picker (rationale); the docs pages give it two + placements, floating at `lg` and inline in the mobile bar. **Both variants + show the active theme's `ThemeSwatch`** — beside its label on the dialog + trigger, beside the word "Theme" in `compact`. +- **`compact` takes its menu offset from style, never a Tailwind class** + (`menuSide`): the website renders against the lib's *prebuilt* stylesheet, so + a utility the lib never emitted is absent there and the menu drops to its + static position. +- **`onPick` reports the choice, not the change.** `restoreActiveTheme` persists + the id it resolved, so `dormouse:active-theme` exists whether or not anyone + chose, and `subscribeToActiveTheme` is silent on a re-pick. Only the picker + can answer "has this person chosen yet". - **The host's fallback theme is module state, not a prop.** `setDefaultThemeId()` holds it and `restoreActiveTheme()` takes no argument, so every path re-resolving the active theme gets the same answer (rationale). diff --git a/docs/specs/website-docs.md b/docs/specs/website-docs.md new file mode 100644 index 000000000..7c8e6ab0d --- /dev/null +++ b/docs/specs/website-docs.md @@ -0,0 +1,582 @@ +# Website Documentation + +> See `docs/specs/glossary.md` for canonical Surface / Session / Pane +> vocabulary used by the public product guide and browser workflow. + +Dormouse publishes three specialized references on the marketing site, each +generated from a source that lives next to the code it describes. + +```text +/docs/dor dor CLI reference +/docs/agent-skill exact bundled agent skill +/docs/self-host the SELF_HOST.md runbook, minus its withheld halves +``` + +`/docs` is an entrypoint rather than a page: it redirects to the page +`DOCS_DEFAULT_PATH` names (see [Reference page chrome](#reference-page-chrome)), +and the marketing nav's desktop **Docs** link is the only public link to it. The +general product guide is `vscode-ext/README.md`, published through the +Marketplace, Open VSX, and GitHub rather than through this site; the machinery +that once rendered it at `/docs` is retained and still runs (see +[Canonical product guide](#canonical-product-guide)). + +| Surface | Purpose | Canonical content | +| --- | --- | --- | +| Homepage | Product marketing, visual proof, conversion, and the way in to all three references | `website/src/pages/Home.tsx` | +| Marketplace and Open VSX | Extension discovery, evaluation, and basic onboarding | `vscode-ext/README.md` plus public metadata in `vscode-ext/package.json` | +| `/docs/dor` | Complete CLI reference | Help snapshots in `dor/test/snapshots/help/`, verified against the built CLI | +| `/docs/agent-skill` | Agent-facing operating guide | Exact `dor/skill.md` | +| `/docs/self-host` | Running the coordinating server yourself | The runbook half of `SELF_HOST.md` | +| GitHub root | Repository overview and contributor entry point | Root `README.md` | + +The guide reaches readers through the Marketplace, Open VSX, and GitHub rows of +that table. It has no row of its own on this site. + +Internal specs remain maintainer references. Public docs are written from +shipped behavior above each spec's fold and do not expose host plumbing, +internal state shapes, or staged `## Future` material. + +## Canonical product guide + +`vscode-ext/README.md` is the single authored source for the general product +guide. It works without prose forks in the VS Code Marketplace, Open VSX, and +GitHub. + +The guide is not served by this site. It was rendered at `/docs`; that page and +every link to it were removed, and the guide is now read where it is published. +The generator still parses it on every build, for two reasons that outlive the +page: the pass validates the guide's media against the Marketplace rules and +copies `vscode-ext/images/` to `public/guide/images/`, which the +packaged listing resolves its images against, and the parsed guide is what a +replacement page would render. Its data file is generated and unconsumed. The +lint's guide checks still run, because they constrain the guide as a +*Marketplace listing*, not as a website page. + +The guide is host-neutral at the top level; VS Code and standalone instructions +live under explicit subsections rather than relying on the website to rewrite +host-specific prose. Its sections are: + +```text +## Get Dormouse +## Layout and panes +## Alerts and TODOs +## Browsers for you and your agents +## Mouse, selection, and copy/paste +## Keyboard shortcuts +## Themes and host integration +## Getting started (### VS Code, ### Standalone) +## Automation and agents +## Help and project links +``` + +Content invariants, enforced by the public-doc lint where mechanically +checkable and by review otherwise: + +- The alert explanation matches [alert.md](alert.md). Terminal notification + protocols and unattended command exit are independent, zero-configuration + tracks; WATCHING is opt-in per command name and needs `OSC 633` / `OSC 133` + shell integration. The guide must not promise that every quiet Pane is + automatically marked done after a fixed interval. +- Pocket is described only as shipped or explicitly in development, and never + presents WebRTC staged in [remote-api.md](remote-api.md) as available. +- Browser Surfaces are explained to match [dor-browser.md](dor-browser.md) + without exposing persisted params, controller registries, proxy plumbing, or + future renderers. +- VS Code command names in getting started exist in `vscode-ext/package.json`. +- Detailed CLI behavior links to `/docs/dor`; the complete agent operating guide + links to `/docs/agent-skill`. Those two are the only site pages the guide may + send a reader to for documentation. +- The guide contains no `TODO:` placeholders and no copied internal future + design. + +### Marketplace and Open VSX constraints + +The extension-root README is the packaged listing body, so the canonical guide +stays within Marketplace-compatible Markdown: + +- It does not depend on React, JavaScript, custom CSS, or website-only layout. +- README image URLs use HTTPS. +- User-provided SVG images are not allowed; content uses raster media or an + approved badge provider. +- Media is **repo-relative local files under `vscode-ext/images/`**, referenced + the way GitHub expects (`images/hero.jpg`). The Markdown stays the source of + truth and ordinary GitHub authoring works: drop a file in and link it. It is + `images/` and never `media/`: `vscode-ext/media/` is the webview bundle's + Vite output directory, emptied on every extension build, so anything + committed there is deleted by the next `pnpm build:vscode`. + Remote media is rejected outright. `github.com/user-attachments` URLs in + particular 302 to a signature-expiring S3 object (so `HEAD` 403s where `GET` + succeeds), cannot be cached downstream, leak every visitor's IP to a third + party, and disappear with the comment they were uploaded to — taking the + listing's images with them. + +Each renderer resolves those relative paths differently, and all four are +verified: + +| Renderer | How `images/x.gif` resolves | +| --- | --- | +| GitHub | Natively, relative to `vscode-ext/` | +| Packaged extension pane | From `images/` inside the VSIX, retained by `!images/**` in `.vscodeignore` | +| Marketplace / Open VSX | `vsce --baseImagesUrl https://dormouse.sh/guide` rewrites both Markdown images **and** raw `` attributes at package time | +| `dormouse.sh` | The generator copies `vscode-ext/images/` to `public/guide/images/`, which is what `--baseImagesUrl` above resolves against | + +Links back to this site take the same shape of treatment. The guide spells them +absolutely (`https://dormouse.sh/docs/dor`) because the Marketplace, Open VSX, +and GitHub all render it away from this origin, where a root-relative path +resolves against the wrong host or not at all. On the site those same URLs must +be root-relative: an absolute one leaves the origin on every click, so a link +followed from a dev server or a preview build lands on production instead of +the page next to it. The generator therefore strips its own origin and keeps +path, query, and fragment, so `/docs/dor#agent-browser` survives as a deep +link. Only exact-origin matches are rewritten; every other host is untouched. + +Reserved: because the generator guarantees it, same-site hrefs reach +`MarkdownDocument` root-relative, and the renderer's external-link test is a +bare scheme check. A new documentation source rendered through that component +— the revived guide page under **Scope: guide-page-return** included — must run +through `localizeSiteLinks` too, or its site links will open in a new tab +pointed at production. + +**Must pass** `--baseImagesUrl` on every `vsce` or `ovsx` invocation that +builds a VSIX from source, rather than letting either infer a base (rationale); +`checkImageBaseUrl` pins them to `SITE_IMAGE_BASE`, exempting a `--packagePath` +republish. + +**Never** write to `public/guide/` from anything but the generator, which +deletes it wholesale each build (rationale). Hand-authored assets stay at +`public/` root, where git tracks them. +- The same content renders usefully in Open VSX and GitHub Markdown. + +The listing's discovery contract also includes `displayName`, `description`, +icon, category, keywords, homepage, repository, and issue URL in +`vscode-ext/package.json`. A major guide rewrite reviews those fields at the +same time. + +Source constraints: the official VS Code +[publishing guide](https://code.visualstudio.com/api/working-with-extensions/publishing-extension#marketplace-integration) +and +[Marketplace presentation guide](https://code.visualstudio.com/api/references/extension-manifest#marketplace-presentation-tips). + +## Markdown parsing + +The Markdown parser is in-repo and takes no third-party dependency. It +therefore supports a deliberate *subset* of CommonMark and raises +`UnsupportedMarkdownError` outside it, rather than degrading silently the way a +general parser would. The public-doc lint turns that error into a build +failure, which is what makes a hand-rolled parser safe as the guide grows. + +Raw HTML is disabled except for a narrow `` allowlist carrying only `src`, +`alt`, `width`, `height`, and `title`, with an `https:` source. Every other tag, +and every other attribute on ``, is rejected outright. The exception exists +because the guide's inline 22px alert-state icons need sizing and portable +Markdown has no syntax for it; it is not a general licence for HTML. + +Heading ids come from one slugger that mirrors `github-slugger`, including +replacing each space individually rather than collapsing runs — so a heading +whose punctuation sits between two spaces yields a double hyphen exactly as on +GitHub. That is what keeps `/docs` anchors identical to the same heading's +anchor on GitHub. + +## Markdown rendering contract + +The website build reads each Markdown source and retains its headings, +paragraphs, lists, links, tables, code, and images in source order. The website +delta is structural: + +1. Omit the README's top-level `# Dormouse`; a page shell supplies its own + title. +2. Generate an on-page table of contents from the remaining headings. +3. Assign stable, unique heading ids with one checked slugger. +4. Rewrite links pointing back at this site to root-relative paths, dropping + only the origin. +5. Render the subset using the marketing website's typography, spacing, links, + code blocks, tables, and responsive raster-media treatment. +6. Add the shared site header and footer. +7. Mark same-site and external navigation appropriately. + +Operations 1–4 live in the generator; 5–7 live in the page components. +Operations 1–3 apply to the guide, which has no page today, and to the +self-host runbook; operation 4 runs over the guide, the runbook, and +`dor/skill.md`, the last before `/docs/dor` lifts its introduction out of those +same blocks, so every published page inherits one rewrite. + +**Never** use a regular expression to turn a canonical source's prose into +site prose. Channel-specific differences are explicit entries in one fixed +delta table per document — `DOCS_DELTA` for the guide, `SELF_HOST_DELTA` for +the runbook. Each entry names exactly one source target and fails the build +when its target matches zero blocks or more than one. Fuzzy text and +line-number patches are forbidden. + +Two operations exist. `remove` drops the matched block. `remove-section` +requires a heading and drops it with every block up to the next heading of the +same or shallower depth, so a removed `##` takes its `###` subsections with it. + +**Must** leave no `#anchor` link pointing at a heading the delta removed. +`resolveRemovedAnchors` rewrites such a link to the canonical file on GitHub — +the material still exists, it is just not published here — and +`assertAnchorsResolve` then fails the build on any that remain. Both run on +every page built from a delta, so the guarantee does not depend on remembering +to ask for it. + +The renderer preserves selectable code, authored image alt text, safe +external-link attributes, mobile table access, and mobile-width media and prose +without horizontal overflow — an inline code span offers a break at each of its +separators. **Never** let such a hint change what the span's `textContent` +yields, so a path still pastes into a shell. No HTML string is ever injected — +`dangerouslySetInnerHTML` is deliberately absent. + +## Per-page head tags + +`website/src/lib/site-meta.ts` builds every page's title, description, +canonical, and social cards. The root route calls it with the homepage's copy; +a page overrides by exporting `meta` and calling it with its own. + +**Never** hardcode one of those in `root.tsx`'s ``: a `` tag is +emitted before ``, so a page with its own `meta` ships two `` +elements and crawlers read the first (rationale). **Must** give every +prerendered page a canonical on its own path, carrying the trailing slash the +host redirects to. **Never** claim one from a route served through the SPA +fallback — the client `<Meta />` appends rather than replaces, so a second +canonical joins the fallback's and both are discarded; `siteMeta`'s +`indexable: false` sends `robots: noindex, follow` instead. +`checkPageHeadTags` and `checkSiteOrigin` pin the first two; +`ChangelogAfter.tsx` is the only route under the third. + +## Reference page chrome + +Every page in `DOCS_PAGES` shares `DocsLayout`: the site header, the left +navigation rail, an `h1` and intro, and prev/next. The changelog and the supply +chain are in that list too — a reader meets them the same way, as long-form +material reached from the rail rather than from the marketing nav. + +**The rail is the only table of contents.** It lists every page and expands the +current one's sections beneath it, so moving between pages and within one is +the same control. A second "on this page" would restate half of it. + +**The page list never shrinks; the expanded sections scroll.** The rail is a +bounded flex column whose section list is the only part that gives up space, so +everything shows when it fits and the page list stays reachable when it does +not. `/docs/dor` nests its subcommands under one `Commands` heading rather than +listing fourteen entries beside four elsewhere. A reader on a screen reader +navigates the outline rather than the rail, so **must** keep the two agreeing: +the commands render a level below that heading, and their own labels a level +below them again (`website/src/pages/DorDocs.test.tsx`). + +**`/docs` is an entrypoint, not an index.** It redirects to the page +`DOCS_DEFAULT_PATH` names — a 302, because the target is a judgement call we +expect to revisit and a 301 outlives it in readers' caches. There is no page at +`/docs` itself, and `checkDocsEntrypoint` keeps the redirect and the constant +saying the same thing. `Docs` joins the marketing nav on desktop only; on a +phone the docs are reached from the homepage's own links. + +**These pages follow the reader's theme; the rest of the site does not.** They +are long-form reading, so `DocsLayout` restores a theme and gives the `compact` +`ThemePicker` two placements: floating bottom right at `lg`, opening upward, and +inline at the end of the mobile docs bar below that, opening downward +([theme.md](./theme.md) → Where the user picks a theme). The `docs-themed` body +class redefines the site's own `--color-*` tokens from the applied +`--vscode-*`, and only `DocsLayout` adds it, so the homepage keeps its black. +The changelog and the supply chain joined that rule when they joined the rail, +which is why their links moved off caramel. +`applyTheme` writes to `body.style`, which `html` cannot read, so `html` gives +up the canvas and lets body's background propagate. + +**Prose links take the picked theme's `accent`, contrast-corrected — never +brand caramel, never `--vscode-textLink-foreground`** (rationale). Caramel +stays where the reader cannot retheme it — the wordmark, the header, the +homepage — and is the fallback before a theme applies. + +**A reader is prompted to pick a theme until they answer.** Picking one and +closing the prompt both count — a reader who declined has seen the offer. +Keyed on the website's own `dormouse:docs-theme-prompt-dismissed`, because +`dormouse:active-theme` cannot answer it: restoring writes that key too. + +**Must keep** prerendered and first-client prompt markup independent of +`localStorage`, then reconcile after hydration. Until then the prompt stays +hidden, so a returning reader never sees dismissed UI flash. Pinned by +`website/src/components/DocsThemeControl.test.tsx`. + +## `/docs/dor` reference + +The CLI page consumes the Markdown snapshots generated by +`dor/test/cli-help.test.mjs`. The root help snapshot owns command order and +inventory. That existing test remains responsible for proving every command's +snapshot equals real help output. + +Stable anchors: `#targeting`, `#surface-handles`, `#dor`, one per canonical +command snapshot filename, and `#agent-browser` for both `dor agent-browser` and +the `dor ab` alias. + +The targeting and Surface-handle introduction is extracted from the matching +sections of `dor/skill.md`; it is not re-authored in the website. + +Each command section renders its title and invocation, usage as copyable +monospace lines, normally wrapped descriptive prose, separate examples and +text/JSON output blocks, responsive flag and argument definition tables, and a +collapsed disclosure containing the original help byte for byte. + +The narrow help parser recognizes only the current column-zero markers `USAGE`, +`COMMANDS`, `FLAGS`, `ARGUMENTS`, `Examples:`, `Text output:`, and +`JSON output:`. A marker section owns only its indented body: the first +column-zero non-blank line that is not itself a marker ends it and begins +prose. Unclassified content remains ordered prose. Every parsed node retains its +raw source slice, and a losslessness test reconstructs the complete raw help +from those slices for every shipped snapshot. + +Definition rows split on the block's aligned description column rather than the +first whitespace run, because a term may contain its own gap (`-h --help`). A +description that wrapped onto the next line is one whose indent sits nearer the +description column than the term column; `dor split --help` produces exactly +that for its long direction flag. + +Generation fails on a malformed snapshot envelope, duplicate command id, missing +or extra snapshot, or root inventory mismatch. Semantic parsing may fall back to +prose but never silently discards source text. + +## `/docs/agent-skill` guide + +The agent page renders `dor/skill.md` exactly. Page chrome adds a table of +contents, stable heading ids, styled code blocks, copy buttons for `dor skill` +and `dor skill --install`, and reference links, but adds nothing to the skill +body. The raw Markdown is deliberately **not** emitted into the generated data: +nothing renders it, and a copy of the generator's own input proves nothing about +the generator. A test instead re-parses the file independently and compares the +resulting heading inventory and ids. + +The web rendering adds contextual reference links beside matching skill +headings. Command rules match on a backticked token inside the heading, since +skill headings carry descriptive suffixes; the two prose rules match the +heading's opening words. + +| Skill section | CLI target | +| --- | --- | +| Targeting | `/docs/dor#targeting` | +| Surface handles | `/docs/dor#surface-handles` | +| `dor list` | `/docs/dor#list` | +| `dor split` | `/docs/dor#split` | +| `dor ensure` | `/docs/dor#ensure` | +| `dor send` | `/docs/dor#send` | +| `dor read` | `/docs/dor#read` | +| `dor kill` | `/docs/dor#kill` | +| `dor ab` / `dor agent-browser` | `/docs/dor#agent-browser` | +| `dor iframe` | `/docs/dor#iframe` | + +These links are presentation adjacent to the skill body. Website URLs are never +injected into `dor/skill.md`: an older installed CLI must remain self-contained +and version-matched rather than directing its instructions to the latest +website reference. + +Generation fails when a mapped skill heading is missing or ambiguous, or when +its target anchor does not exist in the generated CLI reference. + +## `/docs/self-host` runbook + +`SELF_HOST.md` stays canonical in the repository and is published from there. +It has two consumers that outrank the website: an assistant reads it in a +checkout (`read @SELF_HOST.md and walk me through it`), and +`scripts/deploy-lint.mjs` audits its Installer contract against +`deploy/local/`. A second copy under `website/` would be a second file to keep +true about how a server is installed. + +The file is two documents in one, and `SELF_HOST_DELTA` publishes only the +first: it withholds the `#` title, the opening blockquote, and the three +sections addressed to the assistant or to a maintainer, each rule carrying its +own `reason`. What survives is the runbook — prerequisites, what the installer +does, the definition of done, the six checkpoints, official references, +troubleshooting boundaries, and keeping the relay up while the laptop sleeps. + +**Must** keep every withheld section present in `SELF_HOST.md`. `applyDelta` +owns this: a rule matching nothing fails the build naming the rule, so a +renamed section is a decision rather than a silent republication of what the +delta meant to hold back. + +The page carries one authored paragraph the source does not: a pointer to +running the runbook with an assistant. It belongs to the page because it is +advice about reading this document here. + +## Generated documentation boundary + +One build-time generator reads the canonical inputs and writes a gitignored +website data module: + +```text +website/scripts/generate-docs.js +website/scripts/docs-parser.js +website/scripts/help-parser.js +website/src/data/docs.guide.json generated, no page consumes it today +website/src/data/docs.selfhost.json +website/src/data/docs.cli.json +website/src/data/docs.skill.json +``` + +One file per document rather than one combined module: a shared import made +every docs route pull the others' content into one chunk. + +Inputs: + +```text +vscode-ext/README.md +SELF_HOST.md +dor/test/snapshots/help/*.md +dor/skill.md +``` + +The generated data contains the canonical product-guide blocks and heading +inventory with the explicit fixed delta applied, ordered semantic CLI nodes +plus exact raw help, and the skill blocks plus validated heading-to-reference +links. The raw skill Markdown is deliberately not emitted. + +Website `predev`, `pretest`, and `prebuild` run the generator, mirroring +`generate-changelog.js`. Browser code consumes generated data rather than +importing Dor command implementation modules, which use Node APIs. Generated +output stays out of version control and is reproducible from a clean checkout. + +## Homepage browser proof + +The **Browsers for you (and your agents)** section in +`website/src/pages/Home.tsx` shows a terminal-to-browser transcript followed by +a browser Surface preview, and links to `/docs/dor#agent-browser` and +`/docs/agent-skill`. + +The transcript is **authored literals in `Home.tsx`, not generated or tested.** +Proving it end to end would need a live Host and a real `agent-browser` in CI, +and a captured dev-server port is not stable enough to commit — a busy 5173 +silently becomes 5174. The accepted cost is that the transcript can drift from +real output with no test to catch it. + +Two mitigations bound that drift. Command *syntax* matches +`dor/test/snapshots/help/`, which is tested against the real CLI, so only the +output lines are unverified. And output uses notation the CLI itself documents — +`created surface:N "<command>"` from `dor ensure`'s text output, and the +resolution arrow from `dor ab`'s own examples — rather than invented +formatting. A source comment marks the block as authored and untested. + +Desktop and mobile presentations keep the terminal and browser relationship +legible, selectable, and accessible without requiring animation. + +## Root README + +Root `README.md` is shorter than the canonical product guide and does not +duplicate it. It carries a product image and one-sentence cross-platform +description, playground/Marketplace/Open VSX/standalone links, links to the two +published references, a concise current feature summary, contributor setup and +repository structure +with links to `AGENTS.md` and the internal specs, and license and supply-chain +links. + +GitHub-specific development material lives here and is audited against current +package scripts and architecture. Staged implementation plans are not presented +as shipped behavior. + +## Public-doc validation + +`scripts/public-docs-lint.mjs`, invoked by root `pnpm test` after the spec lint, +verifies: + +- the canonical guide carries every section listed in the `text` fence above, + read out of this spec rather than restated in the lint; +- neither public README nor `SELF_HOST.md` contains `TODO:` placeholders; +- every canonical Markdown source stays inside the parser's supported subset; +- public links use canonical HTTPS URLs, and a local link resolves — read off + the parsed tree, so a link-shaped string in a code span is not a link. + `SELF_HOST.md` gets only the HTTPS half; spec-lint already resolves its + relative links and validates their fragments; +- guide images are repo-relative files that exist under `vscode-ext/images/`, + with no remote URLs and no SVG, and every file there is referenced; +- VS Code commands named by the guide exist in `vscode-ext/package.json`, and + the listing metadata fields are present; +- guide heading ids are stable and unique; +- every agent-skill reference target exists in `/docs/dor`; +- generated command inventory matches the snapshot set exactly; +- both READMEs link to every reference page in `docs-pages.ts`, except that + the root README alone owns `/docs/self-host` — checked as exact URLs, so the + `/docs` entrypoint cannot stand in for a page under a prefix test. The guide + carries no self-host obligation: it is a Marketplace listing for the editor + extension, and running a relay server is not part of installing one; +- the homepage links every `/docs` page root-relatively, and every `/docs` href + on it resolves to one — both directions, because a rewritten section can + strand a page's only link or leave one aimed at the entrypoint; +- every `vsce` or `ovsx` invocation that packages the extension from source + passes the site image base; +- no per-page head tag is hardcoded in the root route, every route that + exports `meta` builds it with `siteMeta`, and the two spellings of the site + origin agree; +- `/docs` redirects to a page the rail actually lists, with the status the + entrypoint's own constant expects; +- public copy does not present staged WebRTC as shipped, for as long as WebRTC + is still under `## Future` in [remote-api.md](remote-api.md). + +Each check is isolated, so one malformed source reports its own failure instead +of aborting the run and hiding every other problem behind a stack trace. + +Nuanced product prose is not checked with phrase blacklists. When a public +feature section changes, review compares it with its owning implementation +spec. + +## Code map + +| File | Role | +| --- | --- | +| `vscode-ext/README.md` | The canonical product guide; published off-site, parsed here | +| `SELF_HOST.md` | The self-host runbook and Installer contract; the runbook half is published | +| `vscode-ext/package.json` | Listing metadata and VS Code command inventory | +| `README.md` | Repository and contributor entry point | +| `vscode-ext/images/` | Guide media; the generator copies it to `public/guide/images/`, which the Marketplace listing loads from | +| `dor/skill.md` | The bundled agent skill, rendered exactly at `/docs/agent-skill` | +| `dor/test/snapshots/help/` | Tested CLI help, the source for `/docs/dor` | +| `website/src/lib/site-meta.ts` | Every page's title, description, canonical, and social cards | +| `website/src/lib/docs-pages.ts` | The rail's pages and their order; routes, prerender, rail, and lint all read it | +| `website/src/lib/docs-rail.test.tsx` | Every entry anchors on an id its page renders | +| `website/src/pages/Changelog.tsx`, `website/src/pages/SupplyChain.tsx` | Rail pages deriving their own sections | +| `website/public/_redirects` | The `/docs` entrypoint and the changelog SPA fallback | +| `website/src/routes.ts`, `website/src/components/SiteHeader.tsx` | The published routes and the marketing nav, which carries `Docs` on desktop | +| `website/scripts/docs-parser.js` (+ `.test.js`) | Markdown subset parser, slugger, `<img>` allowlist | +| `website/scripts/help-parser.js` (+ `.test.js`) | Narrow CLI-help parser with losslessness | +| `website/scripts/generate-docs.js` | Codegen: the delta tables, `buildDocument`, `localizeSiteLinks`, `resolveRemovedAnchors`, `linkSkillHeadings` | +| `website/src/components/MarkdownDocument.tsx` | Renders parsed Markdown blocks | +| `website/src/components/DocsLayout.tsx` | Docs chrome: header, the rail and its mobile drawer, prev/next, theme restore | +| `website/src/components/DocsThemeControl.tsx` | The picker's two placements and its first-visit prompt | +| `website/src/lib/docs-accent.ts` (+ `.test.ts`) | The themed link color, contrast-corrected per theme | +| `website/src/lib/docs-theme.ts` | Default docs theme, and whether the reader has chosen | +| `website/src/components/DorCommandReference.tsx` | One CLI command section | +| `website/src/pages/DorDocs.tsx` | `/docs/dor` | +| `website/src/pages/AgentSkillDocs.tsx` | `/docs/agent-skill` | +| `website/src/pages/SelfHostDocs.tsx` | `/docs/self-host` | +| `scripts/public-docs-lint.mjs` | Public-doc validation | + +## Future + +**Scope: website-docs-release** + +Remaining work, in staged order: + +1. **VSIX packaging verification.** Package the extension and inspect its + README and media inventory as part of release, so a listing cannot ship with + a broken image or an unretained local asset. `vscode-ext/.vscodeignore` + already retains `README.md`, `icon.png`, and `images/`. +2. **Live listing verification.** After publication, inspect the rendered + Marketplace and Open VSX pages, and preview the root README under GitHub + Markdown. If packaged or live README inspection becomes a release step, + [deploy.md](deploy.md) owns that release ordering and verification. +3. **Promote public-doc contracts.** Move the contracts that constrain CLI help + text and VS Code command titles into [dor-cli.md](dor-cli.md) and + [vscode.md](vscode.md), so a change there sees the public-doc consequence + without reading this spec. Public wording alone does not change a behavior + spec when it accurately describes already-shipped behavior. + +**Scope: guide-page-return** + +A hosted rendering of the general product guide was built, shipped at `/docs`, +and then withdrawn — the guide reads well enough where it is already published, +and the page did not earn its place in the site's navigation. The pipeline is +whole, not a stub: `docs.guide.json` is written on every build with no +consumer. + +Reviving it needs a page component and an entry in `docs-pages.ts` — not new +pipeline work. Whoever does it should first answer the question that removed +the page: what this rendering gives a reader that the Marketplace and GitHub +renderings do not. + +The lint checks reference URLs exactly rather than by prefix, so a link to a +`/docs/...` page that does not exist is caught rather than satisfied by the +entrypoint redirect. diff --git a/docs/specs/website-docs.rationale.md b/docs/specs/website-docs.rationale.md new file mode 100644 index 000000000..ff08e887d --- /dev/null +++ b/docs/specs/website-docs.rationale.md @@ -0,0 +1,78 @@ +# website-docs rationale + +Evidence behind the rules in [website-docs.md](website-docs.md), keyed by that +file's headings. Informative, not normative. + +## Canonical product guide + +`vsce` and `ovsx` both infer an image base from the repository root, and this +extension lives in a subdirectory, so inference resolves the guide's +repo-relative `images/x` against the wrong path. A packaging invocation missing +`--baseImagesUrl` therefore ships a Marketplace listing whose images 404, and +nothing in CI or a local build notices — only a human opening the live listing +does. An invocation with `--packagePath` republishes an already-built VSIX, +whose URLs were rewritten when it was packaged. + +Generated guide media gets `public/guide/` to itself because `syncGuideMedia` +deletes that directory wholesale on every build. It previously wrote to +`public/images/`, which is the natural home for hand-authored site assets, and +the whole path was gitignored — so an `og-card.png` dropped there would have +worked locally, never been staged, and vanished on the next build. + +## Per-page head tags + +The two-`<title>` failure was observed rather than reasoned about: with a +hardcoded title in `root.tsx`'s `<head>`, every page that exported `meta` +emitted two `<title>` elements, and a minimal two-title page confirmed in +Chrome that `document.title` and the tab both take the *first*. Each reference +page therefore advertised itself as "Dormouse — A dormouse knows when to wake +up" to anything that did not run the page's JavaScript. + +The canonical collapse was worse because it was uniform: all ten prerendered +pages carried `canonical="https://dormouse.sh/"`, verified in the live +production HTML for `/supply-chain/`. A self-referential canonical on every URL +tells search engines the subpages duplicate the homepage. + +The SPA-fallback rule came from the fix making things briefly worse. +`/changelog/after/*` is served by rewriting to `__spa-fallback.html`, whose +static head carries the homepage's tags. Giving the route its own `meta` fixed +the `<title>` — a text child React reconciles — but left **two** conflicting +canonicals in the hydrated DOM, measured on the deployed preview. Search +engines discard a conflicting pair, so that is worse than the single wrong tag +it replaced. + +## Reference page chrome + +Brand caramel measures 5.56:1 on the site's black but 3.43–3.78:1 on every +bundled light theme, which is why prose links could not keep it once the docs +pages started following the reader's theme. + +`--vscode-textLink-foreground` looked like the replacement and was not: none of +the 11 bundled themes defines `textLink.foreground`, so the variable always +resolves to the colour registry's default for the theme's *kind*. Every dark +theme shared one blue. + +The theme's own `accent` varies per theme but cannot be used raw — measured +against each theme's `--vscode-editor-background`, 7 of the 11 fall below 4.5:1 +and four carry alpha. Hence the correction, which walks the accent toward +whichever of white or black contrasts more and stops at the first step that +clears the threshold. Direction is measured rather than taken from a luminance +midpoint, which is not where the contrast crossover sits: `#808080` needs to +darken, and a luminance test sends it toward white. + +Anchor offsets are tight by measurement, not by estimate. The mobile bar is +45px and the site header 64px, 80px from `md` up with no `lg` step, so the two +steps that clear both have 3px in hand and `lg` clears the header alone by +16px. + +## Markdown rendering contract + +Long paths in prose scrolled `/docs/self-host` sideways on a phone: ten +space-free tokens sit in its paragraphs, the longest 47 characters — about +404px of monospace against the 343px a 375px phone leaves after padding. One +unbreakable word to the line breaker, so it widened the article rather than +only itself. The same tokens inside tables were already contained by the +table's own scroller, which is why only that page showed it. + +Breaking after every separator rather than after each run was the first attempt +and read badly: `--watch` parted at its dashes, `https://` at its slashes. diff --git a/lib/src/components/ThemePicker.test.tsx b/lib/src/components/ThemePicker.test.tsx index f31b198a5..ef96a08f0 100644 --- a/lib/src/components/ThemePicker.test.tsx +++ b/lib/src/components/ThemePicker.test.tsx @@ -42,4 +42,38 @@ describe('ThemePicker', () => { // in for, so collapsed and expanded read as one control. expect(trigger?.querySelector('span[class*="rounded-full"]')).not.toBeNull(); }); + + /** Open the compact picker and return its menu panel. */ + function openCompact(props: Partial<Parameters<typeof ThemePicker>[0]> = {}) { + act(() => root.render(<ThemePicker variant="compact" {...props} />)); + const trigger = container.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!; + act(() => trigger.click()); + return container.querySelector<HTMLDivElement>('[role="menu"]')!; + } + + it('offsets the compact menu with style, not a utility class', () => { + // The website renders this against the lib's prebuilt stylesheet, where a + // never-emitted utility simply does not exist and the menu would fall back + // to its static position (docs/specs/theme.md). + expect(openCompact({ menuSide: 'above' }).style.bottom).toBe('100%'); + act(() => root.render(<></>)); + expect(openCompact().style.top).toBe('100%'); + }); + + it('reports a pick even when it does not change the active theme', () => { + // `subscribeToActiveTheme` reports a changed id and would stay silent here, + // but re-picking the active theme is still an answer to "have you chosen?". + const onPick = vi.fn(); + const menu = openCompact({ onPick }); + const active = menu.querySelector<HTMLButtonElement>('button[aria-checked="true"]') + ?? menu.querySelector<HTMLButtonElement>('button')!; + + act(() => active.click()); + expect(onPick).toHaveBeenCalledTimes(1); + + act(() => (container.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!).click()); + const again = container.querySelector<HTMLDivElement>('[role="menu"]')!; + act(() => again.querySelector<HTMLButtonElement>('button')!.click()); + expect(onPick).toHaveBeenCalledTimes(2); + }); }); diff --git a/lib/src/components/ThemePicker.tsx b/lib/src/components/ThemePicker.tsx index cbeb0bef9..106c6abfa 100644 --- a/lib/src/components/ThemePicker.tsx +++ b/lib/src/components/ThemePicker.tsx @@ -24,6 +24,10 @@ import { chromeButton, modalIconButton, OVERLAY_MAX_HEIGHT } from './design'; */ export type ThemePickerVariant = 'compact' | 'settings-dialog'; +/** Which way `compact` opens its menu. Ignored by `settings-dialog`, which + * anchors off its measured trigger rect instead. */ +export type ThemePickerMenuSide = 'below' | 'above'; + export interface ThemePickerProps { variant: ThemePickerVariant; /** Controlled dropdown state. Omit both for the uncontrolled default; the @@ -32,6 +36,19 @@ export interface ThemePickerProps { * otherwise swallow the key before the picker ever sees it). */ open?: boolean; onOpenChange?: (open: boolean) => void; + /** Default `below`. A `compact` trigger pinned to the bottom of the viewport + * needs `above`, or its menu opens off-screen. */ + menuSide?: ThemePickerMenuSide; + /** + * The user chose a theme from this picker. + * + * Fires on every selection, including re-selecting the active one — unlike + * `subscribeToActiveTheme`, which reports a changed id. A caller asking + * "has this person picked a theme yet" needs the choice, not the change: + * `restoreActiveTheme` persists an id of its own, so storage cannot answer + * it (docs/specs/theme.md). + */ + onPick?: (theme: DormouseTheme) => void; } const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; @@ -44,6 +61,8 @@ export function ThemePicker({ variant, open: controlledOpen, onOpenChange, + menuSide = 'below', + onPick, }: ThemePickerProps) { // Apply the persisted theme during render initialization, before commit, so // the first paint already has --vscode-* on body. Hosts must not *rely* on @@ -101,6 +120,7 @@ export function ThemePicker({ setActiveId(id); applyTheme(theme); setOpen(false); + onPick?.(theme); }; const deleteTheme = (theme: DormouseTheme) => { @@ -111,7 +131,15 @@ export function ThemePicker({ refreshThemes(); }; - const panelStyle: CSSProperties = inDialog ? { ...styles.panel, ...menuStyle } : styles.panel; + // Offsets, not Tailwind classes: the website consumes this component through + // the lib's prebuilt stylesheet, so a utility the lib has never emitted + // before (`bottom-full`) is simply absent there and the menu falls back to + // its static position, off the bottom of the viewport. + const compactSideStyle: CSSProperties = + menuSide === 'above' ? { bottom: '100%', marginBottom: 4 } : { top: '100%', marginTop: 4 }; + const panelStyle: CSSProperties = inDialog + ? { ...styles.panel, ...menuStyle } + : { ...styles.panel, ...compactSideStyle }; return ( <div ref={rootRef} className="relative flex items-center"> @@ -140,7 +168,7 @@ export function ThemePicker({ role="menu" aria-label="Select theme" className={`z-50 flex flex-col overflow-hidden rounded border font-mono shadow-2xl ${OVERLAY_MAX_HEIGHT.popover} ${ - inDialog ? '' : 'absolute right-0 top-full mt-1 w-[280px]' + inDialog ? '' : 'absolute right-0 w-[280px]' }`} style={panelStyle} > diff --git a/package.json b/package.json index b15fbaba9..29d2a679a 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ }, "scripts": { "build": "pnpm run build:vscode && pnpm --filter dormouse-lib build:pocket && pnpm --filter dormouse-website build", - "test": "node scripts/spec-lint.mjs && node scripts/spec-lint-selftest.mjs && node scripts/xterm-lint.mjs && node scripts/loopback-lint.mjs && node scripts/loopback-lint-selftest.mjs && node scripts/deploy-lint.mjs && node scripts/deploy-lint-selftest.mjs && node scripts/installer-verify-test.mjs && node scripts/e2e-lint.mjs && node scripts/e2e-lint-selftest.mjs && node scripts/clamp-issue-body-selftest.mjs && pnpm -r run test", + "test": "node scripts/spec-lint.mjs && node scripts/spec-lint-selftest.mjs && node scripts/public-docs-lint.mjs && node scripts/xterm-lint.mjs && node scripts/loopback-lint.mjs && node scripts/loopback-lint-selftest.mjs && node scripts/deploy-lint.mjs && node scripts/deploy-lint-selftest.mjs && node scripts/installer-verify-test.mjs && node scripts/e2e-lint.mjs && node scripts/e2e-lint-selftest.mjs && node scripts/clamp-issue-body-selftest.mjs && pnpm -r run test", "lint:specs": "node scripts/spec-lint.mjs && node scripts/spec-lint-selftest.mjs", + "lint:public-docs": "node scripts/public-docs-lint.mjs", "audit:prose": "node scripts/prose-audit.mjs", "lint:loopback": "node scripts/loopback-lint.mjs && node scripts/loopback-lint-selftest.mjs", "lint:xterm": "node scripts/xterm-lint.mjs", diff --git a/scripts/public-docs-lint.mjs b/scripts/public-docs-lint.mjs new file mode 100644 index 000000000..81b05b641 --- /dev/null +++ b/scripts/public-docs-lint.mjs @@ -0,0 +1,430 @@ +#!/usr/bin/env node +/** + * Public documentation lint. + * + * Checks the contracts in docs/specs/website-docs.md that are mechanically + * checkable. Nuanced product prose is deliberately NOT checked with phrase + * blacklists — when a public feature section changes, review it against its + * owning spec instead. + * + * Every inventory here is derived from the file that owns it rather than + * restated: the guide's sections from the spec, the reference pages from the + * route table's own list, the image base from the generator. A lint that + * carries its own copy of a list is a second owner, and the copy is the one + * that rots. + * + * Run by the root `pnpm test`. + */ + +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { repoRoot, readRepoFile, trackedFiles } from './lint-kit.mjs'; +import { + hasScheme, + isProtocolRelative, + parseMarkdown, + visit, + UnsupportedMarkdownError, +} from '../website/scripts/docs-parser.js'; +import { generateDocs, SITE_ORIGIN, SITE_IMAGE_BASE } from '../website/scripts/generate-docs.js'; +// Imported, not scraped: Node strips the types, so the lint reads the same +// objects the browser bundle does. A regex over the source silently returned +// fewer pages when a field was reordered or a flag renamed, and every check +// here only fails on zero — so a partial miss passed. +import { DOCS_PAGES, DOCS_DEFAULT_PATH } from '../website/src/lib/docs-pages.ts'; + +const failures = []; +const fail = (msg) => failures.push(msg); + +const GUIDE = 'vscode-ext/README.md'; +const ROOT_README = 'README.md'; +const SKILL = 'dor/skill.md'; +const SELF_HOST = 'SELF_HOST.md'; +const HOMEPAGE = 'website/src/pages/Home.tsx'; +const SPEC = 'docs/specs/website-docs.md'; +const REDIRECTS = 'website/public/_redirects'; +const ROOT_ROUTE = 'website/src/root.tsx'; +const SITE_META = 'website/src/lib/site-meta.ts'; + +/** Each source read once and parsed once, then shared by every check. */ +const src = { + [GUIDE]: readRepoFile(GUIDE), + [ROOT_README]: readRepoFile(ROOT_README), + [SKILL]: readRepoFile(SKILL), + [SELF_HOST]: readRepoFile(SELF_HOST), + [HOMEPAGE]: readRepoFile(HOMEPAGE), +}; + +/** Parsed form, or null when the source is outside the supported subset. */ +const parsed = {}; +for (const rel of [GUIDE, ROOT_README, SKILL, SELF_HOST]) { + try { + parsed[rel] = parseMarkdown(src[rel]); + } catch (error) { + parsed[rel] = null; + if (error instanceof UnsupportedMarkdownError) { + fail(`${rel}: uses Markdown outside the supported subset — ${error.message}`); + } else { + throw error; + } + } +} + +/** Every href a document links to, off the parsed tree. */ +function linksIn(rel) { + const hrefs = []; + visit(parsed[rel].blocks, (node) => { + if (node.type === 'link' && node.href) hrefs.push(node.href); + }); + return hrefs; +} + +function checkNoPlaceholders() { + for (const rel of [GUIDE, ROOT_README, SELF_HOST]) { + if (/\bTODO:/.test(src[rel])) fail(`${rel}: contains a TODO: placeholder`); + } +} + +/** + * The guide must carry the sections the spec says it carries. + * + * Read out of the spec's own `text` fence, which is where the guide's shape is + * declared; a copy of the list here would be a second owner, and the spec is + * the one a person edits. An empty or missing fence fails: the rule and the + * prose it enforces must move together. + */ +function checkGuideSections() { + if (!parsed[GUIDE]) return; + const fence = /```text\n((?:## .*\n)+)```/.exec(readRepoFile(SPEC)); + if (!fence) { + fail(`${SPEC}: no \`\`\`text fence listing the guide's sections; checkGuideSections enforces nothing without it`); + return; + } + // "## Getting started (### VS Code, ### Standalone)" annotates subsections. + const required = fence[1] + .trim() + .split('\n') + .map((line) => line.replace(/^##\s+/, '').replace(/\s{2,}\(.*\)$/, '').toLowerCase()); + const present = parsed[GUIDE].headings.map((h) => h.text.toLowerCase()); + for (const section of required) { + if (!present.includes(section)) fail(`${GUIDE}: missing required section "${section}" (${SPEC})`); + } +} + +/** Marketplace media rules, read off the parsed tree rather than the source. + * A regex over raw Markdown also matches <img> examples inside fenced code + * blocks and misses images the parser normalizes. + * + * Where a guide image must live, that it exists, and that nothing under + * vscode-ext/images/ is unused are the generator's rules (resolveGuideMedia), + * reported by checkGenerated; only what the Marketplace itself refuses is + * here. */ +function checkImages() { + if (!parsed[GUIDE]) return; + const urls = []; + visit(parsed[GUIDE].blocks, (node) => { + if (node.type === 'image' && node.src) urls.push(node.src); + }); + if (urls.length === 0) fail(`${GUIDE}: has no images; the listing needs at least a hero`); + for (const url of urls) { + if (/\.svg(\?|#|$)/i.test(url)) fail(`${GUIDE}: SVG images are not allowed on the Marketplace — ${url}`); + } +} + +/** + * Every `vsce` or `ovsx` invocation that packages the extension from source + * must pass the site's image base. + * + * docs/specs/website-docs.md -> Canonical product guide — the + * guide's images are repo-relative, both packagers infer a base from the + * repository root, and this extension lives in a subdirectory, so an + * invocation without the flag ships a listing whose images 404. Only a human + * looking at the live Marketplace page would find out, which is why it is + * pinned here. An invocation with `--packagePath` republishes an + * already-packaged VSIX, whose URLs were rewritten when it was built. + */ +function checkImageBaseUrl() { + const claim = '**Must pass** `--baseImagesUrl` on every'; + if (!readRepoFile(SPEC).includes(claim)) { + fail(`${SPEC}: no longer says ${claim} — the rule and its prose must move together`); + } + const callers = ['vscode-ext/package.json', '.github/workflows/release.yml']; + let found = 0; + for (const rel of callers) { + for (const line of readRepoFile(rel).split('\n')) { + if (!/\b(?:vsce (?:package|publish)|ovsx publish)\b/.test(line) || line.includes('--packagePath')) continue; + found += 1; + if (!line.includes(`--baseImagesUrl ${SITE_IMAGE_BASE}`)) { + fail(`${rel}: packages the extension without --baseImagesUrl ${SITE_IMAGE_BASE} — ${line.trim()}`); + } + } + } + if (found === 0) fail(`no source-packaging \`vsce\` or \`ovsx\` invocation found in ${callers.join(', ')}`); +} + +/** + * Public links must be absolute https, and a local link must resolve. + * + * Read off the parsed tree, like checkImages: a regex over raw Markdown also + * matches link-shaped text inside code spans and fenced samples. + * + * SELF_HOST.md gets only the https half. scripts/spec-lint.mjs already resolves + * its relative links and validates their `#fragment` against real headings, + * which is strictly more than this check could say. + */ +function checkLinks() { + for (const rel of [GUIDE, ROOT_README, SELF_HOST]) { + if (!parsed[rel]) continue; + for (const href of linksIn(rel)) { + if (href.startsWith('#')) continue; + if (/^https:\/\//i.test(href)) continue; + if (/^https?:\/\//i.test(href)) { + fail(`${rel}: public link must use https — ${href}`); + continue; + } + if (hasScheme(href) || isProtocolRelative(href)) continue; + if (rel === SELF_HOST) continue; + const target = join(repoRoot, dirname(rel), href.split('#')[0]); + if (!existsSync(target)) fail(`${rel}: local link does not resolve — ${href}`); + } + } +} + +/** Commands the guide tells people to run must exist in the extension manifest. */ +function checkVsCodeCommands() { + const manifest = JSON.parse(readRepoFile('vscode-ext/package.json')); + const titles = new Set((manifest.contributes?.commands ?? []).map((c) => c.title)); + const named = [...src[GUIDE].matchAll(/\*\*(Dormouse: [^*]+)\*\*/g)].map(([, title]) => title); + // The guide names its commands in bold. Rewritten as a table or in backticks, + // this check would quietly enforce nothing, and pass while doing it. + if (named.length === 0) fail(`${GUIDE}: names no **Dormouse: …** command; this check has stopped matching`); + for (const title of named) { + if (!titles.has(title)) fail(`${GUIDE}: names VS Code command "${title}", which is not in vscode-ext/package.json`); + } + for (const field of ['bugs', 'homepage', 'repository', 'icon']) { + if (!manifest[field]) fail(`vscode-ext/package.json: missing listing field "${field}"`); + } +} + +/** + * Every page reaches the readers its `linkedFrom` names, and the homepage + * reaches them all. + * + * The READMEs render off-site and spell the URLs absolutely; the homepage is + * on-site and spells them root-relatively. + */ +function checkRoutesToReferences() { + // Each page names the documents that must link it, so the one exemption — + // the guide owes nothing to the self-host runbook — lives on the entry it + // applies to rather than as a set here. + const README_OF = { guide: GUIDE, 'root-readme': ROOT_README }; + const linked = DOCS_PAGES.filter((page) => page.linkedFrom?.length); + if (linked.length === 0) { + fail('docs-pages.ts: no page names a README; checkRoutesToReferences enforces nothing'); + return; + } + + for (const page of linked) { + const url = SITE_ORIGIN + page.path; + for (const source of page.linkedFrom ?? []) { + const rel = README_OF[source]; + // Node strips the `"guide" | "root-readme"` union at runtime, so a typo + // here would otherwise skip the page's check and still print "passed". + if (!rel) { + fail(`docs-pages.ts: ${page.path} names an unknown linkedFrom "${source}"`); + continue; + } + if (!parsed[rel]) continue; + if (!linksIn(rel).some((href) => href === url || href.startsWith(`${url}#`))) { + fail(`${rel}: does not link to ${url}`); + } + } + } + + // The homepage owes a link to every `/docs` page, not only the ones a README + // also carries: it is the page most able to strand one, and a page added + // without a `linkedFrom` would otherwise ship reachable from the rail alone. + // Checked as exact paths, because `/docs` is an entrypoint rather than a page + // and a prefix test would be satisfied by a link to it. + const paths = DOCS_PAGES.map((page) => page.path).filter((path) => path.startsWith('/docs/')); + const hrefs = [...src[HOMEPAGE].matchAll(/href="(\/docs[^"]*)"/g)].map(([, href]) => href); + const satisfies = (href, path) => href === path || href.startsWith(`${path}#`); + for (const path of paths) { + if (!hrefs.some((href) => satisfies(href, path))) fail(`${HOMEPAGE}: does not link to ${path}`); + } + for (const href of hrefs) { + if (!paths.some((path) => satisfies(href, path))) { + fail(`${HOMEPAGE}: links to ${href}, which is not a published reference`); + } + } +} + +/** + * Per-page head tags come from `siteMeta`, never from the root route's `<head>`. + * + * React Router renders only the deepest route's `meta`, and anything hardcoded + * in `root.tsx`'s `<head>` is emitted *before* `<Meta />`. Putting a title or + * canonical there gave every page with its own `meta` two `<title>` elements — + * crawlers read the first, so each reference page advertised itself as the + * homepage — and pinned `canonical`/`og:url` to `https://dormouse.sh/` on every + * URL, which asks search engines to treat every page as a duplicate of the + * homepage (docs/specs/website-docs.md -> Per-page head tags). + */ +function checkPageHeadTags() { + const root = readRepoFile(ROOT_ROUTE); + const head = root.slice(root.indexOf('<head>'), root.indexOf('</head>')); + const banned = [ + [/<title>/, '<title>'], + [/name="description"/, 'name="description"'], + [/rel="canonical"/, 'rel="canonical"'], + [/property="og:/, 'property="og:*"'], + [/name="twitter:/, 'name="twitter:*"'], + ]; + if (!head) { + fail(`${ROOT_ROUTE}: no <head> found; checkPageHeadTags enforces nothing without it`); + return; + } + for (const [pattern, label] of banned) { + if (pattern.test(head)) { + fail(`${ROOT_ROUTE}: ${label} is hardcoded in <head>; it belongs in siteMeta (${SITE_META})`); + } + } + + // Every route module that supplies its own head tags must go through the + // helper, or it emits a title with no canonical beside it. + let routesWithMeta = 0; + for (const rel of trackedFiles()) { + if (!rel.startsWith('website/src/') || !/\.tsx$/.test(rel)) continue; + const source = readRepoFile(rel); + if (!/export function meta\b/.test(source)) continue; + routesWithMeta += 1; + if (!/\bsiteMeta\(/.test(source)) { + fail(`${rel}: exports meta() without calling siteMeta; its page would ship no canonical`); + } + } + if (routesWithMeta === 0) fail(`no route module exports meta(); checkPageHeadTags has stopped matching`); + + // A route that re-exports another page's component inherits its rendering but + // not its `meta` — the export is per-module. That left /changelog/after/* with + // no head tags at all, serving the SPA fallback's homepage title and canonical + // on the URL the standalone updater opens. + for (const rel of trackedFiles()) { + if (!rel.startsWith('website/src/pages/') || !/\.tsx$/.test(rel)) continue; + const source = readRepoFile(rel); + if (!/export \{[^}]*\bdefault\b[^}]*\} from/.test(source)) continue; + // An export, not the word: `\bmeta\b` also matched the comment above the + // re-export explaining why the export must be there, so deleting the export + // and keeping the comment passed. Both legitimate spellings count. + if (!/export function meta\b|export \{[^}]*\bmeta\b[^}]*\} from/.test(source)) { + fail(`${rel}: re-exports a page component without meta(); the route would ship the fallback's head`); + } + } +} + +/** One origin, spelled the same in the build script and the browser bundle. */ +function checkSiteOrigin() { + const declared = /SITE_ORIGIN = "([^"]+)"/.exec(readRepoFile(SITE_META)); + if (!declared) { + fail(`${SITE_META}: no SITE_ORIGIN declaration found`); + return; + } + if (declared[1] !== SITE_ORIGIN) { + fail(`${SITE_META}: SITE_ORIGIN is ${declared[1]}, but the generator uses ${SITE_ORIGIN}`); + } +} + +/** + * `/docs` sends a reader to the page `DOCS_DEFAULT_PATH` names, with the status + * docs/specs/website-docs.md requires. + * + * `_redirects` is a deploy artifact no test exercises and no build reads, so + * without this the two spellings of one destination drift the first time + * someone edits only the constant. + */ +function checkDocsEntrypoint() { + const target = DOCS_DEFAULT_PATH; + if (!DOCS_PAGES.some((page) => page.path === target)) { + fail(`docs-pages.ts: DOCS_DEFAULT_PATH is ${target}, which is not a page in the rail`); + } + const rule = readRepoFile(REDIRECTS) + .split('\n') + .find((line) => /^\/docs\s/.test(line)); + if (!rule) { + fail(`${REDIRECTS}: no /docs rule; the entrypoint would 404`); + return; + } + const [, to, status] = rule.trim().split(/\s+/); + if (to !== target) fail(`${REDIRECTS}: /docs goes to ${to}, but DOCS_DEFAULT_PATH is ${target}`); + // A 301 outlives the next time we change our mind about the entrypoint. + if (status !== '302') fail(`${REDIRECTS}: /docs must redirect with 302, not ${status ?? '(none)'}`); +} + +/** Generated data must be internally consistent. */ +async function checkGenerated() { + let data; + try { + data = await generateDocs(); + } catch (error) { + fail(`docs generation failed: ${error.message}`); + return; + } + + // Everything else the generator guarantees by throwing: unique ids per page, + // the delta's targets still being there, snapshot/inventory agreement in both + // directions, and reference anchors resolving. Re-asserting them here would + // state each invariant twice with a different message, so only the generator + // owns them and the catch above reports any failure. + for (const file of data.guide.media.unused) { + fail(`vscode-ext/images/${file} is not referenced by the guide`); + } +} + +/** + * Public copy must not present staged remote transports as shipped. + * + * Scoped by the spec that stages them: the ban applies only while WebRTC is + * still below docs/specs/remote-api.md's `## Future` fold, so promoting it + * retires this rule in the same commit that ships it. + */ +function checkNoStagedClaims() { + const api = readRepoFile('docs/specs/remote-api.md'); + const future = api.slice(api.indexOf('\n## Future')); + if (!/WebRTC/i.test(future)) return; + for (const rel of [GUIDE, ROOT_README, HOMEPAGE]) { + if (/WebRTC/i.test(src[rel])) { + fail(`${rel}: mentions WebRTC, which is staged under docs/specs/remote-api.md -> ## Future`); + } + } +} + +const checks = [ + checkNoPlaceholders, + checkGuideSections, + checkImages, + checkImageBaseUrl, + checkLinks, + checkVsCodeCommands, + checkPageHeadTags, + checkSiteOrigin, + checkDocsEntrypoint, + checkRoutesToReferences, + checkGenerated, + checkNoStagedClaims, +]; + +// Each check is isolated: one throwing check must not abort the run, or a +// single malformed source hides every other problem behind a stack trace. +for (const check of checks) { + try { + await check(); + } catch (error) { + fail(`${check.name} threw: ${error.message}`); + } +} + +if (failures.length > 0) { + console.error(`public-docs-lint: ${failures.length} problem(s)\n`); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +} +console.log(`public-docs-lint: ${checks.length} checks passed`); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index bfdd6a714..01bb83d48 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 2500, + "AGENTS.md": 2600, "SELF_HOST.md": 6000, "docs/specs/alert.md": 6350, "docs/specs/auto-update.md": 1000, @@ -19,10 +19,11 @@ "docs/specs/standalone.md": 4000, "docs/specs/terminal-escapes.md": 3000, "docs/specs/terminal-state.md": 2200, - "docs/specs/theme.md": 1800, + "docs/specs/theme.md": 1900, "docs/specs/tiling-engine.md": 4400, "docs/specs/transport.md": 3950, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7050, - "docs/specs/webgl-text.md": 1200 + "docs/specs/webgl-text.md": 1200, + "docs/specs/website-docs.md": 4400 } diff --git a/vscode-ext/.vscodeignore b/vscode-ext/.vscodeignore index 3c5f36dd3..ad09ca42d 100644 --- a/vscode-ext/.vscodeignore +++ b/vscode-ext/.vscodeignore @@ -9,6 +9,7 @@ icon.svg *.ts !dist/** !media/** +!images/** !icon.png !README.md !CHANGELOG.md diff --git a/vscode-ext/README.md b/vscode-ext/README.md index d0547bca5..64bf85aa6 100644 --- a/vscode-ext/README.md +++ b/vscode-ext/README.md @@ -1,85 +1,148 @@ # Dormouse -Terminal multiplexer for VS Code (or [standalone app](https://dormouse.sh/#download)) — tmux keybindings, mouse support, human-friendly copy-paste, and alerts for completed tasks. +A multitasking terminal for VS Code and the desktop — a real tiling layout, tmux keybindings, full mouse support, browser panes your agents can drive, and alerts that tell you when something needs you. -[dormouse.sh/playground](https://dormouse.sh/playground) — try before you install +![Dormouse running a tiling terminal layout](images/hero.jpg) -TODO: Hero GIF. +[dormouse.sh/playground](https://dormouse.sh/playground) — try the real thing in your browser, nothing to install. -## Alert System +## Get Dormouse -Dormouse tracks activity the same way you do — visual motion. When a pane stops changing for two seconds, it marks the task complete and alerts you. Works with any CLI tool that prints to a terminal, no plugins or configuration. +- **VS Code** — install from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=diffplug.dormouse) or [Open VSX](https://open-vsx.org/extension/diffplug/dormouse). Also works in Cursor, Windsurf, and other VS Code forks. +- **Standalone** — self-updating installers for macOS, Windows, and Linux at [dormouse.sh](https://dormouse.sh/#download). +- **Playground** — [dormouse.sh/playground](https://dormouse.sh/playground) runs the real interface in your browser. -- <img width="22" height="22" alt="todo-disabled" src="https://github.com/user-attachments/assets/29178d1e-062f-4e4d-8de8-250e01b73125" /> alerts disabled -- <img width="22" height="22" alt="todo-enabled" src="https://github.com/user-attachments/assets/1f6dfeb3-7d8e-4724-b777-af6b350cbc80" /> alerts enabled -- <img width="22" height="22" alt="todo-armed" src="https://github.com/user-attachments/assets/a02e9b7c-4a48-459d-910a-ef0b0ae2a27f" /> task is running, will send an alert when task completes -- <img width="22" height="22" alt="todo-ringing" src="https://github.com/user-attachments/assets/55082f42-ddcc-402c-b550-814d84b86630" /> task is finished and needs your attention +Both hosts run the same terminal, the same layout engine, and the same alerts. -When you click a task that was ringing, it adds a TODO next to the terminal's title. This TODO will remain until you hit `Enter` in that terminal, or until you explicitly dismiss the TODO by clicking it or typing `t` in command mode. +## Layout and panes -This lightweight TODO system remembers which tasks need follow-up so you don't have to. +Run builds, agents, servers, and scripts side by side. Split, resize, swap, and zoom panes with the mouse or the keyboard. -## Mouse-Friendly Copy and Paste +Minimize the panes you aren't watching down to a compact status indicator on the baseboard — a **door**. A minimized pane keeps running and keeps reporting, so a door still shows you when its task needs attention. Reattach it and you are back where you left off. -When you copy-paste from a terminal, you are usually stuck with a bunch of newlines that you wouldn't get if you were copying from any other kind of program. Dormouse can optionally remove these with `Copy Rewrapped`. +You can spawn and rearrange everything using any of: -<img width="744" height="378" alt="copy-paste" src="https://github.com/user-attachments/assets/8e905ba1-a8b7-43c5-86af-e7dfd84f04db" /> +- default tmux shortcuts +- Dormouse's own shortcuts +- the mouse -For TUIs which register for xterm mouse interception (such as `htop` and `neovim`), most terminals make it impossible for you to copy using the mouse. Dormouse makes it easy to temporarily override the mouse interception. +## Alerts and TODOs -TODO: GIF showing htop and the override mechanism +Dormouse can owe you attention in three independent ways. Two of them need no setup at all. -## Tiling Layout with Minimize +**A program asks for you.** Dormouse understands the standard terminal notification and progress protocols — `BEL`, `OSC 9`, `OSC 9;4`, `OSC 99`, and `OSC 777`. Any tool that already signals completion or progress rings its pane, with no configuration. -Run builds, agents, servers, and scripts side by side. Minimize the ones you're not watching to a compact status indicator — every pane keeps running and every alert still fires whether minimized or not. +**A command finishes while you are away.** If a foreground command was running while you were watching, you left, and it exited after you had been gone a while, that pane is ringing when you come back. Also no configuration. -You can spawn, layout, and relayout everything in the terminal using any of: +**A watched command goes quiet.** This one is opt-in, and it is the one for coding agents. Click the bell in a pane running `claude` — or press `a` in command mode — and Dormouse watches *that command name*. Every pane running `claude` is then watched, the ones open now and the ones you open later. When a watched command's output goes busy and then falls quiet while you are not looking, it rings. -- default tmux shortcuts -- intuitive modern shortcuts -- the mouse +Dormouse never guesses which commands deserve an alert. Watching is a rule you create on a command name, and turning it off anywhere removes it everywhere. + +- <img width="22" height="22" alt="Bell icon, alerts off" src="images/alert-disabled.gif" /> no watch rule for this pane's command +- <img width="22" height="22" alt="Bell icon, command watched" src="images/alert-enabled.gif" /> this command is watched +- <img width="22" height="22" alt="Bell icon, watched command running" src="images/alert-armed.gif" /> a watched command is running; it will alert when it goes quiet +- <img width="22" height="22" alt="Bell icon, ringing for attention" src="images/alert-ringing.gif" /> finished, and it needs your attention + +Whichever way a pane rings, the ring becomes a **TODO** — a marker beside the pane's title that outlives the alert, so a ring you dismissed does not disappear without a trace. Clear it by clicking it or pressing `t` in command mode. + +Watching a command's output requires shell integration (`OSC 633` / `OSC 133`) so Dormouse can tell where one command ends and the next begins. Shells that do not report command boundaries — `cmd.exe`, `fish`, or any shell where the integration did not take — never engage watching. The protocol and command-exit alerts above work regardless. + +## Browsers for you and your agents + +A browser is just another pane. Put your dev server next to the terminal running it, in the same tiling layout. + +``` +dor ab open surface:2 +``` + +That aims a browser pane at the port a terminal surface is serving. Your agents run the same command, so when an agent wants to look at what it just built, it opens a pane you are already watching. + +Browser panes render three ways: a live Chromium stream inside the pane, popped out to a real OS window when you need the genuine article, or a lightweight proxied iframe. Dormouse is a client for the `agent-browser` you already have installed — it does not ship a browser of its own. + +See [`/docs/dor#agent-browser`](https://dormouse.sh/docs/dor#agent-browser) for the full command reference. -TODO: layout GIF +## Mouse, selection, and copy/paste -## Keyboard Shortcuts +Click and drag in most terminals does not select text — it fires a mouse escape sequence at whatever is running. Dormouse notices when a TUI such as `htop` or `neovim` has grabbed the mouse and gives you a one-click override, so you can select the thing. -If you use the mouse, then Dormouse is always in **passthrough** mode, where all keypresses passthrough to the selected terminal. If you press `LShift` followed by `RShift` in quick succession (or `LCmd → RCmd`, or `LCtrl → RCtrl`), then you will enter **command** mode where keypresses can spawn terminals, navigate panes, and rearrange the layout. +Then copy it the way you meant it: -### Command Mode Shortcuts +- **Copy Raw** keeps the hard wraps exactly as the terminal drew them. +- **Copy Rewrapped** joins those wrapped lines back into the line the program actually printed. +Hold `Alt` while dragging to toggle between block and linewise selection, and press `e` mid-drag to extend the selection out to the whole URL or file path. + +## Keyboard shortcuts + +Dormouse starts in **passthrough** mode, where every keypress goes to the selected terminal. Tap **left Shift then right Shift** within half a second to enter **command** mode, where keys drive the layout instead. Left Cmd then right Cmd works too (left Win / left Super on Windows and Linux). + | Key | Action | |-----|--------| -| `Enter` | Return to **passthrough** mode | -| `\|` tmux `%` | Split left/right | -| `-` tmux `"` | Split top/bottom | -| Arrow keys | Navigate between panes | -| `Cmd+Arrow` | Swap pane positions | +| `Enter` | Return to passthrough mode | +| `\|` or tmux `%` | Split left/right | +| `-` or tmux `"` | Split top/bottom | +| Arrow keys | Move selection between panes | +| `Cmd`/`Ctrl` + arrows | Swap terminals between two panes | | `z` | Zoom / unzoom the selected pane | -| `m` tmux `d` | Minimize pane to baseboard | -| `k` tmux `x` | Kill pane | +| `m` or tmux `d` | Minimize pane to a door, or reattach one | +| `k` or tmux `x` | Kill pane (asks you to confirm) | | `,` | Rename pane | +| `a` | Toggle the alert rule for the running command | +| `t` | Toggle the TODO marker | +| `>` | Open the pane header menu, including bound ports | + +Copy and paste keep their usual bindings in both modes: `Cmd+C` / `Ctrl+C` copies raw, `Cmd+Shift+C` / `Ctrl+Shift+C` copies rewrapped, and `Cmd+V` / `Ctrl+V` pastes. On macOS, `Ctrl+C` still passes through to the running program. + +The complete table lives in [the keyboard shortcut reference](https://github.com/diffplug/dormouse/blob/main/docs/specs/shortcuts.md). + +## Themes and host integration + +Inside VS Code, Dormouse uses your VS Code theme — colors, styling, everything. Switch themes and Dormouse switches with you. No separate configuration and no mismatched colors. + +The standalone app ships the same theme system with its own picker, so a layout you like looks the same in both places. + +## Getting started + +### VS Code + +1. Install the extension. +2. Open the command palette (`Cmd+Shift+P` / `Ctrl+Shift+P`). +3. Run one of: + - **Dormouse: Focus** — open Dormouse in the Panel area, next to the built-in terminal. + - **Dormouse: Open in Editor** — open Dormouse as an editor tab. You can open several. + - **Dormouse: New Terminal** — add a terminal to the active Dormouse. + - **Dormouse: Select Shell** — pick which shell new terminals launch. + +Dormouse works in the Panel area, the Editor area, or both at once. + +### Standalone -## Any Theme, Anywhere +1. Download an installer for your platform from [dormouse.sh](https://dormouse.sh/#download). +2. Launch it. The app updates itself, so there is nothing further to wire up. -Dormouse uses your VSCode theme — colors, styling, everything. Switch themes and Dormouse switches with you. No separate configuration, no mismatched colors. +## Automation and agents -TODO: GIF showing theme switching — user changes VSCode theme and Dormouse updates instantly to match +Every terminal Dormouse launches has `dor` on its `PATH` — a small CLI that talks to the Dormouse hosting it. It lets a script, or an agent, drive the layout: -You can also use Dormouse in the Panel area (bottom, next to the built-in terminal), in the Editor area (center region where you edit files), or both. +``` +dor list # what surfaces exist +dor ensure -- pnpm dev # make sure this is running, exactly once +dor split -- pnpm test # open a new terminal pane +dor ab open surface:2 # open a browser on that terminal's port +``` -TODO: GIF showing Dormouse in various areas +`dor ensure` is idempotent: run it twice and the second call reuses the pane already running that command in that directory instead of starting a second copy. -## Getting Started +Dormouse also bundles an agent skill describing all of this in the form agents expect. Run `dor skill` to print it, or `dor skill --install` to install it for the agent in your current project. -1. Install the extension -2. Open the command palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) - - **Dormouse: Focus** to open the "Panel" version of Dormouse (next to the terminal) - - **Dormouse: Open in Editor** to open a Dormouse tab in the content area (you can open multiple) +- [Complete CLI reference](https://dormouse.sh/docs/dor) +- [The bundled agent skill](https://dormouse.sh/docs/agent-skill) -## Links +## Help and project links -- Prefer a standalone terminal app? Self-updating installers available for Win, Mac and Linux at [dormouse.sh](https://dormouse.sh/#download) -- You can try it in a [browser playground](https://dormouse.sh/playground) -- [GitHub](https://github.com/diffplug/dormouse) +- [Browser playground](https://dormouse.sh/playground) — no install required +- [Report an issue](https://github.com/diffplug/dormouse/issues) +- [Source on GitHub](https://github.com/diffplug/dormouse) +- [Supply chain](https://dormouse.sh/supply-chain) - Brought to you by [DiffPlug](https://www.diffplug.com/) diff --git a/vscode-ext/images/alert-armed.gif b/vscode-ext/images/alert-armed.gif new file mode 100644 index 000000000..60cfe9b7c Binary files /dev/null and b/vscode-ext/images/alert-armed.gif differ diff --git a/vscode-ext/images/alert-disabled.gif b/vscode-ext/images/alert-disabled.gif new file mode 100644 index 000000000..21d5a25e7 Binary files /dev/null and b/vscode-ext/images/alert-disabled.gif differ diff --git a/vscode-ext/images/alert-enabled.gif b/vscode-ext/images/alert-enabled.gif new file mode 100644 index 000000000..484d1c8cf Binary files /dev/null and b/vscode-ext/images/alert-enabled.gif differ diff --git a/vscode-ext/images/alert-ringing.gif b/vscode-ext/images/alert-ringing.gif new file mode 100644 index 000000000..aec94e405 Binary files /dev/null and b/vscode-ext/images/alert-ringing.gif differ diff --git a/vscode-ext/images/hero.jpg b/vscode-ext/images/hero.jpg new file mode 100644 index 000000000..edbc04cce Binary files /dev/null and b/vscode-ext/images/hero.jpg differ diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 417af00e5..63a74bc26 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -1,7 +1,7 @@ { "name": "dormouse", "displayName": "Dormouse — Terminal Multiplexer", - "description": "A persistent multitasking terminal — tmux keybindings, mouse support, and a built-in alert system that buzzes you when builds, agents, or scripts finish.", + "description": "A persistent multitasking terminal — a real tiling layout, tmux keybindings, mouse support, browser panes your agents can drive, and alerts when a task needs you.", "version": "1.1.0", "publisher": "diffplug", "license": "FSL-1.1-MIT", @@ -11,6 +11,9 @@ "type": "git", "url": "https://github.com/diffplug/dormouse" }, + "bugs": { + "url": "https://github.com/diffplug/dormouse/issues" + }, "engines": { "vscode": "^1.85.0" }, @@ -110,10 +113,10 @@ "build": "pnpm stage:dor-cli && node scripts/esbuild.mjs && cp -RL node_modules/node-pty dist/node-pty && rm -rf dist/shell-integration && cp -RL ../standalone/sidecar/shell-integration dist/shell-integration", "stage:dor-cli": "pnpm --filter dor build && node ../scripts/stage-dor-cli.mjs vscode-ext/dor-cli", "watch": "pnpm --filter server-lib-common build && pnpm stage:dor-cli && node scripts/esbuild.mjs --watch", - "package": "vsce package --no-dependencies --out dormouse.vsix", + "package": "vsce package --no-dependencies --baseImagesUrl https://dormouse.sh/guide --out dormouse.vsix", "dogfood": "node ../scripts/dogfood-vscode.mjs", - "publish:marketplace": "vsce publish --no-dependencies", - "publish:openvsx": "ovsx publish --no-dependencies" + "publish:marketplace": "vsce publish --no-dependencies --baseImagesUrl https://dormouse.sh/guide", + "publish:openvsx": "ovsx publish --no-dependencies --baseImagesUrl https://dormouse.sh/guide" }, "dependencies": { "node-pty": "1.2.0-beta.15", diff --git a/website/package.json b/website/package.json index 1f4d363b1..8e728a1c2 100644 --- a/website/package.json +++ b/website/package.json @@ -6,12 +6,12 @@ "type": "module", "scripts": { "dev": "react-router dev", - "predev": "node scripts/generate-changelog.js", - "prebuild": "node scripts/generate-changelog.js", + "predev": "node scripts/generate-changelog.js && node scripts/generate-docs.js", + "prebuild": "node scripts/generate-changelog.js && node scripts/generate-docs.js", "build": "react-router build", "postbuild": "node scripts/flatten-react-router-build.js", "preview": "vite preview --outDir dist --mode test", - "pretest": "node scripts/generate-changelog.js", + "pretest": "node scripts/generate-changelog.js && node scripts/generate-docs.js", "test": "vitest run" }, "dependencies": { diff --git a/website/public/_redirects b/website/public/_redirects index 5e81baa93..4b30e5d1c 100644 --- a/website/public/_redirects +++ b/website/public/_redirects @@ -1 +1,5 @@ +# Follows DOCS_DEFAULT_PATH in website/src/lib/docs-pages.ts; checkDocsEntrypoint +# pins the pair (docs/specs/website-docs.md -> Reference page chrome). +/docs /docs/agent-skill 302 +/docs/ /docs/agent-skill 302 /changelog/after/* /__spa-fallback 200 diff --git a/website/react-router.config.ts b/website/react-router.config.ts index 5fc5d71ea..e63fa7e50 100644 --- a/website/react-router.config.ts +++ b/website/react-router.config.ts @@ -1,4 +1,5 @@ import type { Config } from "@react-router/dev/config"; +import { DOCS_PAGES } from "./src/lib/docs-pages"; export default { appDirectory: "src", @@ -11,8 +12,7 @@ export default { "/playground/desktop", "/playground/pocket", "/pocket", - "/changelog", - "/supply-chain", + ...DOCS_PAGES.map((page) => page.path), ]; }, } satisfies Config; diff --git a/website/scripts/docs-parser.js b/website/scripts/docs-parser.js new file mode 100644 index 000000000..b59351c7e --- /dev/null +++ b/website/scripts/docs-parser.js @@ -0,0 +1,494 @@ +/** + * Hand-rolled Markdown parser for the public docs pipeline. + * + * Deliberately supports a *subset* of CommonMark rather than pretending to be + * complete. A third-party parser degrades gracefully on constructs it does not + * know; a hand-rolled one silently mangles them. So anything outside the subset + * is a hard error (`UnsupportedMarkdownError`), and the public-doc lint turns + * that into a build failure instead of a rendering bug nobody notices. + * + * Raw HTML is rejected with one exception: a narrow `<img>` allowlist, because + * the canonical README uses inline 22px alert-state icons and portable Markdown + * has no image-sizing syntax (docs/specs/website-docs.md -> /docs rendering + * contract). + * + * No dependencies, by design. Runs in Node during codegen and its output is + * plain JSON consumed by the website. + */ + +export class UnsupportedMarkdownError extends Error { + constructor(message, line) { + super(line == null ? message : `${message} (line ${line})`); + this.name = 'UnsupportedMarkdownError'; + this.line = line; + } +} + +/** Attributes an `<img>` may carry. Everything else is rejected. */ +export const IMG_ALLOWED_ATTRS = new Set(['src', 'alt', 'width', 'height', 'title']); + +// --------------------------------------------------------------------------- +// Slugger — mirrors github-slugger so /docs anchors match GitHub's own. +// --------------------------------------------------------------------------- + +/** + * Whether a URL carries a scheme (`https:`, `mailto:`, `vscode:`). + * + * The parser owns this because it owns the `<img>` URL policy, and four + * decisions turn on the same question: rejecting a raw `<img src>`, rejecting a + * guide image, deciding whether a link leaves the site, and deciding whether a + * link is local enough to resolve on disk. Spelled once so those four cannot + * disagree about what a scheme looks like. + */ +export function hasScheme(url) { + return /^[a-z][a-z0-9+.-]*:/i.test(url); +} + +/** `//host/path` — no scheme, but not local either. */ +export function isProtocolRelative(url) { + return url.startsWith('//'); +} + +export function slugify(text) { + return text + .trim() + .toLowerCase() + // Strip everything that is not alphanumeric, space, hyphen, or underscore. + // \p{L}/\p{N} keep non-ASCII headings usable. + .replace(/[^\p{L}\p{N} _-]+/gu, '') + // github-slugger replaces each space individually rather than collapsing + // runs, so "`dor list` - find" (em dash stripped from between two spaces) + // becomes "dor-list--find". Matching that exactly is what keeps /docs + // anchors identical to GitHub's for the same heading. + .replace(/ /g, '-'); +} + +export function createSlugger() { + const seen = new Map(); + return (text) => { + const base = slugify(text) || 'section'; + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + return n === 0 ? base : `${base}-${n}`; + }; +} + +// --------------------------------------------------------------------------- +// Inline parsing +// --------------------------------------------------------------------------- + +function parseImgTag(raw, line) { + const inner = raw.replace(/^<img\s*/i, '').replace(/\/?>$/, ''); + const attrs = {}; + const re = /([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*)"/g; + let m; + while ((m = re.exec(inner))) { + const [, name, value] = m; + const key = name.toLowerCase(); + if (!IMG_ALLOWED_ATTRS.has(key)) { + throw new UnsupportedMarkdownError(`<img> attribute "${name}" is not allowed`, line); + } + attrs[key] = value; + } + // Anything left over means unquoted or malformed attributes we did not parse. + const leftover = inner.replace(re, '').trim(); + if (leftover.length > 0) { + throw new UnsupportedMarkdownError(`could not parse <img> attributes: "${leftover}"`, line); + } + if (!attrs.src) throw new UnsupportedMarkdownError('<img> requires a src', line); + // A source is either a repo-relative local file (the authoring default, so + // GitHub renders it natively) or an absolute https URL. Anything else -- + // http, protocol-relative, data: -- is rejected here; the public-doc lint + // additionally requires relative files to exist and bans third-party hosts. + if (hasScheme(attrs.src) && !/^https:\/\//i.test(attrs.src)) { + throw new UnsupportedMarkdownError(`<img> src must be relative or https: "${attrs.src}"`, line); + } + return { + type: 'image', + src: attrs.src, + alt: attrs.alt ?? '', + width: attrs.width, + height: attrs.height, + title: attrs.title, + }; +} + +/** + * Parse inline content into a flat-ish node list. Emphasis may nest one level + * inside link text; anything deeper is not needed by our sources. + */ +export function parseInline(text, line) { + const nodes = []; + let buf = ''; + let i = 0; + + const flush = () => { + if (buf) { + nodes.push({ type: 'text', value: buf }); + buf = ''; + } + }; + + while (i < text.length) { + const ch = text[i]; + + // Backslash escape + if (ch === '\\' && i + 1 < text.length) { + buf += text[i + 1]; + i += 2; + continue; + } + + // Inline code — longest matching backtick run + if (ch === '`') { + let ticks = 0; + while (text[i + ticks] === '`') ticks++; + const fence = '`'.repeat(ticks); + const end = text.indexOf(fence, i + ticks); + if (end !== -1) { + flush(); + nodes.push({ type: 'code', value: text.slice(i + ticks, end) }); + i = end + ticks; + continue; + } + buf += ch; + i++; + continue; + } + + // Allowlisted inline <img> + if (text.startsWith('<img', i)) { + const end = text.indexOf('>', i); + if (end === -1) throw new UnsupportedMarkdownError('unterminated <img> tag', line); + flush(); + nodes.push(parseImgTag(text.slice(i, end + 1), line)); + i = end + 1; + continue; + } + + // Any other raw HTML is rejected outright. + if (ch === '<' && /^<\/?[a-zA-Z]/.test(text.slice(i))) { + const tag = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)/.exec(text.slice(i)); + throw new UnsupportedMarkdownError(`raw HTML <${tag ? tag[1] : '?'}> is not allowed`, line); + } + + // Image / link + if (ch === '!' && text[i + 1] === '[') { + const parsed = matchLink(text, i + 1); + if (parsed) { + flush(); + nodes.push({ type: 'image', src: parsed.href, alt: parsed.label, title: parsed.title }); + i = parsed.end; + continue; + } + } + if (ch === '[') { + const parsed = matchLink(text, i); + if (parsed) { + flush(); + nodes.push({ type: 'link', href: parsed.href, title: parsed.title, children: parseInline(parsed.label, line) }); + i = parsed.end; + continue; + } + } + + // Strong / emphasis + if (ch === '*' || ch === '_') { + const strong = ch + ch; + if (text.startsWith(strong, i)) { + const end = text.indexOf(strong, i + 2); + if (end !== -1) { + flush(); + nodes.push({ type: 'strong', children: parseInline(text.slice(i + 2, end), line) }); + i = end + 2; + continue; + } + } + const end = text.indexOf(ch, i + 1); + // Avoid treating snake_case as emphasis. + if (end !== -1 && !(ch === '_' && /\w/.test(text[i - 1] ?? ''))) { + flush(); + nodes.push({ type: 'em', children: parseInline(text.slice(i + 1, end), line) }); + i = end + 1; + continue; + } + } + + buf += ch; + i++; + } + + flush(); + return nodes; +} + +/** Match `[label](href "title")` starting at `start`. Returns null if not one. */ +function matchLink(text, start) { + if (text[start] !== '[') return null; + let depth = 0; + let i = start; + for (; i < text.length; i++) { + if (text[i] === '\\') { i++; continue; } + if (text[i] === '[') depth++; + else if (text[i] === ']') { + depth--; + if (depth === 0) break; + } + } + if (depth !== 0 || text[i + 1] !== '(') return null; + const label = text.slice(start + 1, i); + const close = text.indexOf(')', i + 2); + if (close === -1) return null; + const target = text.slice(i + 2, close).trim(); + const titleMatch = /^(\S+)\s+"([^"]*)"$/.exec(target); + return titleMatch + ? { label, href: titleMatch[1], title: titleMatch[2], end: close + 1 } + : { label, href: target, title: undefined, end: close + 1 }; +} + +// --------------------------------------------------------------------------- +// Block parsing +// --------------------------------------------------------------------------- + +const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; + +/** Closing-fence matchers, one per marker character, compiled once. */ +const FENCE_CLOSE = { '`': /^\s*`{3,}\s*$/, '~': /^\s*~{3,}\s*$/ }; + +/** + * Split a table row into cells, honouring backslash-escaped pipes so a cell + * containing `` `\|` `` (which the shortcut table needs) survives intact. + */ +function splitRow(row, line) { + const trimmed = row.trim().replace(/^\|/, '').replace(/\|$/, ''); + const cells = []; + let cur = ''; + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i]; + if (ch === '\\' && trimmed[i + 1] === '|') { cur += '|'; i++; continue; } + if (ch === '\\') { cur += ch + (trimmed[i + 1] ?? ''); i++; continue; } + if (ch === '|') { cells.push(cur); cur = ''; continue; } + cur += ch; + } + cells.push(cur); + return cells.map((c) => parseInline(c.trim(), line)); +} + +function isDelimiterRow(row) { + return /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(row); +} + +function alignmentsFrom(row) { + return row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => { + const s = c.trim(); + if (s.startsWith(':') && s.endsWith(':')) return 'center'; + if (s.endsWith(':')) return 'right'; + if (s.startsWith(':')) return 'left'; + return null; + }); +} + +/** + * Parse Markdown into a block node tree. + * + * @param {string} markdown + * @param {{ slug?: (text: string) => string }} [options] + */ +export function parseMarkdown(markdown, options = {}) { + const slug = options.slug ?? createSlugger(); + const lines = markdown.replace(/\r\n?/g, '\n').split('\n'); + const blocks = []; + const headings = []; + let i = 0; + + while (i < lines.length) { + const raw = lines[i]; + const lineNo = i + 1; + + if (raw.trim() === '') { i++; continue; } + + // Fenced code + const fence = /^(\s*)(`{3,}|~{3,})\s*([\w+-]*)\s*$/.exec(raw); + if (fence) { + const closeRe = FENCE_CLOSE[fence[2][0]]; + const lang = fence[3] || null; + const body = []; + i++; + let closed = false; + while (i < lines.length) { + if (closeRe.test(lines[i])) { closed = true; i++; break; } + body.push(lines[i]); + i++; + } + if (!closed) throw new UnsupportedMarkdownError('unterminated fenced code block', lineNo); + blocks.push({ type: 'code', lang, value: body.join('\n') }); + continue; + } + + // ATX heading + const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(raw); + if (heading) { + const depth = heading[1].length; + const children = parseInline(heading[2], lineNo); + const text = inlineToText(children); + const id = slug(text); + blocks.push({ type: 'heading', depth, id, text, children }); + headings.push({ depth, id, text }); + i++; + continue; + } + + // Thematic break + if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(raw)) { + blocks.push({ type: 'thematicBreak' }); + i++; + continue; + } + + // Table + if (raw.includes('|') && i + 1 < lines.length && isDelimiterRow(lines[i + 1])) { + const header = splitRow(raw, lineNo); + const align = alignmentsFrom(lines[i + 1]); + i += 2; + const rows = []; + while (i < lines.length && lines[i].trim() !== '' && lines[i].includes('|')) { + rows.push(splitRow(lines[i], i + 1)); + i++; + } + blocks.push({ type: 'table', align, header, rows }); + continue; + } + + // Blockquote + if (/^\s*>\s?/.test(raw)) { + const body = []; + while (i < lines.length && (/^\s*>\s?/.test(lines[i]) || (lines[i].trim() !== '' && body.length > 0))) { + body.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + blocks.push({ type: 'blockquote', children: parseMarkdown(body.join('\n'), { slug }).blocks }); + continue; + } + + // Lists + if (LIST_ITEM.test(raw)) { + const { node, next } = parseList(lines, i, slug); + blocks.push(node); + i = next; + continue; + } + + // Standalone <img> block + if (/^\s*<img\b/i.test(raw)) { + blocks.push({ type: 'paragraph', children: markStandalone(parseInline(raw.trim(), lineNo)) }); + i++; + continue; + } + + // Any other block-level raw HTML is rejected. + if (/^\s*<\/?[a-zA-Z]/.test(raw)) { + const tag = /^\s*<\/?([a-zA-Z][a-zA-Z0-9-]*)/.exec(raw); + throw new UnsupportedMarkdownError(`raw HTML <${tag ? tag[1] : '?'}> is not allowed`, lineNo); + } + + // Paragraph — consume until blank line or a line that starts a new block. + const para = []; + while (i < lines.length && lines[i].trim() !== '') { + const l = lines[i]; + if (para.length > 0 && (/^(#{1,6})\s+/.test(l) || LIST_ITEM.test(l) || /^\s*>\s?/.test(l) || /^(\s*)(`{3,}|~{3,})/.test(l))) break; + para.push(l.trim()); + i++; + } + blocks.push({ type: 'paragraph', children: markStandalone(parseInline(para.join(' '), lineNo)) }); + } + + return { blocks, headings }; +} + +/** + * Tag an image that is the whole paragraph as standalone art. + * + * The distinction between page art and an inline icon is known here and + * nowhere else; without it a renderer has to guess from something incidental + * like the presence of a width attribute, which then silently reclassifies any + * image that gains one. + */ +function markStandalone(children) { + const visible = children.filter((n) => !(n.type === 'text' && n.value.trim() === '')); + if (visible.length === 1 && visible[0].type === 'image') visible[0].standalone = true; + return children; +} + +function parseList(lines, start, slug) { + const first = LIST_ITEM.exec(lines[start]); + const baseIndent = first[1].length; + const ordered = /\d/.test(first[2]); + const items = []; + let i = start; + + while (i < lines.length) { + const m = LIST_ITEM.exec(lines[i]); + if (!m) { + // A blank line inside a list is allowed if a further item follows. + if (lines[i].trim() === '' && LIST_ITEM.test(lines[i + 1] ?? '')) { i++; continue; } + break; + } + const indent = m[1].length; + if (indent < baseIndent) break; + if (indent > baseIndent) { + const { node, next } = parseList(lines, i, slug); + if (items.length === 0) throw new UnsupportedMarkdownError('nested list without a parent item', i + 1); + items[items.length - 1].children.push(node); + i = next; + continue; + } + const contentLines = [m[3]]; + i++; + // Continuation lines: indented further, not themselves list items. + while (i < lines.length && lines[i].trim() !== '' && !LIST_ITEM.test(lines[i]) && /^\s+/.test(lines[i])) { + contentLines.push(lines[i].trim()); + i++; + } + items.push({ type: 'listItem', children: [{ type: 'paragraph', tight: true, children: parseInline(contentLines.join(' '), i) }] }); + } + + return { node: { type: 'list', ordered, items }, next: i }; +} + +/** + * Visit every node in a parsed tree, blocks and inlines alike. + * + * Every value is descended into, rather than a named list of container keys: + * such a list is a mirror of the node schema, and it drifts the moment a node + * type gains a child array. (The first such mirror missed table body cells + * entirely, since a row is an array of cell arrays rather than a node.) The + * guard below discards strings, numbers, and nulls, so `align: ['left', null]` + * and every `value`/`raw` string costs nothing. + * + * Accepts a node, an array, or an arbitrarily nested array of either. + */ +export function visit(tree, fn) { + if (Array.isArray(tree)) { + for (const entry of tree) visit(entry, fn); + return; + } + if (!tree || typeof tree !== 'object') return; + fn(tree); + for (const value of Object.values(tree)) visit(value, fn); +} + +export function inlineToText(nodes) { + return nodes + .map((n) => { + switch (n.type) { + case 'text': return n.value; + case 'code': return n.value; + case 'image': return n.alt ?? ''; + case 'link': + case 'strong': + case 'em': return inlineToText(n.children); + default: return ''; + } + }) + .join(''); +} diff --git a/website/scripts/docs-parser.test.js b/website/scripts/docs-parser.test.js new file mode 100644 index 000000000..93e8f28b6 --- /dev/null +++ b/website/scripts/docs-parser.test.js @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { + parseMarkdown, + parseInline, + createSlugger, + slugify, + inlineToText, + IMG_ALLOWED_ATTRS, + UnsupportedMarkdownError, +} from './docs-parser.js'; + +describe('slugger', () => { + it('matches GitHub-style slugs', () => { + expect(slugify('Alerts and TODOs')).toBe('alerts-and-todos'); + expect(slugify('Browsers for you and your agents')).toBe('browsers-for-you-and-your-agents'); + expect(slugify('`dor list` — find surfaces')).toBe('dor-list--find-surfaces'); + }); + + it('dedupes repeated headings', () => { + const slug = createSlugger(); + expect(slug('Usage')).toBe('usage'); + expect(slug('Usage')).toBe('usage-1'); + expect(slug('Usage')).toBe('usage-2'); + }); +}); + +describe('inline', () => { + it('parses code, links, and strong', () => { + const nodes = parseInline('see `dor list` in the [docs](https://example.com) **now**'); + expect(nodes.map((n) => n.type)).toEqual(['text', 'code', 'text', 'link', 'text', 'strong']); + }); + + it('honours backslash escapes', () => { + expect(inlineToText(parseInline('a \\| b'))).toBe('a | b'); + }); + + it('does not treat snake_case as emphasis', () => { + expect(inlineToText(parseInline('surface_id_value'))).toBe('surface_id_value'); + }); + + it('preserves every allowlisted attribute on an https img', () => { + // Keyed off the allowlist itself: allowing an attribute the parser then + // drops on the way out fails here, where a fixed list of today's five + // would keep passing. A new entry must gain a sample below. + const sample = { + src: 'https://x.test/a.png', + alt: 'bell', + width: '22', + height: '22', + title: 'Alert ringing', + }; + expect(Object.keys(sample).sort()).toEqual([...IMG_ALLOWED_ATTRS].sort()); + + const attrs = Object.entries(sample).map(([name, value]) => `${name}="${value}"`).join(' '); + const [img] = parseInline(`<img ${attrs} />`); + expect(img).toMatchObject({ type: 'image', ...sample }); + }); + + it('rejects a disallowed img attribute', () => { + expect(() => parseInline('<img src="https://x.test/a.png" onerror="alert(1)" />')) + .toThrow(UnsupportedMarkdownError); + }); + + it('accepts a repo-relative img src', () => { + const [img] = parseInline('<img width="22" height="22" alt="bell" src="media/alert-armed.gif" />'); + expect(img).toMatchObject({ type: 'image', src: 'media/alert-armed.gif' }); + }); + + it('rejects a non-https absolute img src', () => { + expect(() => parseInline('<img src="http://x.test/a.png" />')).toThrow(/relative or https/); + expect(() => parseInline('<img src="data:image/gif;base64,AA" />')).toThrow(/relative or https/); + }); + + it('rejects every other raw HTML tag', () => { + expect(() => parseInline('a <script>x</script> b')).toThrow(/raw HTML <script>/); + expect(() => parseInline('line<br>break')).toThrow(/raw HTML <br>/); + }); +}); + +describe('blocks', () => { + it('parses headings with ids', () => { + const { blocks, headings } = parseMarkdown('# One\n\n## Two Words\n'); + expect(blocks[0]).toMatchObject({ type: 'heading', depth: 1, id: 'one' }); + expect(headings).toEqual([ + { depth: 1, id: 'one', text: 'One' }, + { depth: 2, id: 'two-words', text: 'Two Words' }, + ]); + }); + + it('parses fenced code and keeps it verbatim', () => { + const { blocks } = parseMarkdown('```sh\ndor list\n indented\n```\n'); + expect(blocks[0]).toEqual({ type: 'code', lang: 'sh', value: 'dor list\n indented' }); + }); + + it('throws on an unterminated fence', () => { + expect(() => parseMarkdown('```\nnope\n')).toThrow(/unterminated fenced code/); + }); + + it('parses a table with an escaped pipe inside inline code', () => { + const md = '| Key | Action |\n|-----|--------|\n| `\\|` or tmux `%` | Split |\n'; + const { blocks } = parseMarkdown(md); + expect(blocks[0].type).toBe('table'); + expect(inlineToText(blocks[0].rows[0][0])).toBe('| or tmux %'); + expect(inlineToText(blocks[0].rows[0][1])).toBe('Split'); + }); + + it('parses nested lists', () => { + const { blocks } = parseMarkdown('- one\n- two\n - nested\n'); + expect(blocks[0].type).toBe('list'); + expect(blocks[0].items).toHaveLength(2); + const nested = blocks[0].items[1].children.find((c) => c.type === 'list'); + expect(inlineToText(nested.items[0].children[0].children)).toBe('nested'); + }); + + it('parses ordered lists', () => { + const { blocks } = parseMarkdown('1. first\n2. second\n'); + expect(blocks[0]).toMatchObject({ type: 'list', ordered: true }); + expect(blocks[0].items).toHaveLength(2); + }); + + it('keeps a list item with an inline img', () => { + const md = '- <img width="22" height="22" alt="b" src="https://x.test/b.png" /> ringing\n'; + const { blocks } = parseMarkdown(md); + const kids = blocks[0].items[0].children[0].children; + expect(kids[0]).toMatchObject({ type: 'image', width: '22' }); + expect(inlineToText(kids).trim()).toBe('b ringing'); + }); + + it('parses blockquotes', () => { + const { blocks } = parseMarkdown('> quoted line\n'); + expect(blocks[0].type).toBe('blockquote'); + expect(blocks[0].children[0].type).toBe('paragraph'); + }); + + it('rejects block-level raw HTML that is not img', () => { + expect(() => parseMarkdown('<div>hi</div>\n')).toThrow(/raw HTML <div>/); + }); +}); diff --git a/website/scripts/generate-docs.js b/website/scripts/generate-docs.js new file mode 100644 index 000000000..00d19a3a5 --- /dev/null +++ b/website/scripts/generate-docs.js @@ -0,0 +1,636 @@ +/** + * Build-time codegen for the public documentation pages. + * + * Reads the canonical sources, parses them with the in-repo Markdown parser, + * and writes one gitignored data file per document. Browser code never imports + * Dor implementation modules (they use Node APIs) or reads Markdown at runtime + * — everything it needs is in the generated JSON, split per document so + * /docs/dor does not ship the agent skill along with it. + * + * The guide half of this pipeline has no page of its own right now (see + * docs/specs/website-docs.md -> Canonical product guide). It is kept whole and + * still runs on every build, because the guide's media sync is what puts + * vscode-ext/images/ on dormouse.sh, where the packaged Marketplace listing + * loads its images from, and because the guide data is what a future page + * would render. + * + * Wired into website `predev` / `pretest` / `prebuild`, mirroring + * generate-changelog.js. + * + * See docs/specs/website-docs.md. + */ + +import { readFile, readdir, writeFile, mkdir, copyFile, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createSlugger, hasScheme, parseMarkdown, slugify, visit } from './docs-parser.js'; +import { + parseSnapshot, + parseHelp, + reconstruct, + rootCommandNames, + usageLines, + definitionRows, + labelledBody, + proseParagraphs, + MalformedSnapshotError, +} from './help-parser.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, '..', '..'); +const dataDir = join(__dirname, '..', 'src', 'data'); +/** + * Guide media lives next to the guide; the site serves a copy under /guide/. + * + * It is `vscode-ext/images/`, not `vscode-ext/media/`, because the latter is + * the webview bundle's output directory — Vite empties it on every extension + * build, which would delete anything committed there. + * + * The published copy gets `public/guide/` to itself, and `syncGuideMedia` + * deletes that directory wholesale on every build. Nothing else may write + * there; hand-authored site assets stay at `public/` root, where they are + * tracked by git rather than swept away. + * + * Load-bearing beyond this repo: `vsce --baseImagesUrl` is passed + * `SITE_IMAGE_BASE`, which turns the guide's `images/hero.jpg` into + * `https://dormouse.sh/guide/images/hero.jpg` on the Marketplace and Open VSX, + * so the listing's images 404 if the site stops serving these. + */ +const mediaSrcDir = join(repoRoot, 'vscode-ext', 'images'); +const MEDIA_SRC_PREFIX = 'images/'; +const mediaOutDir = join(__dirname, '..', 'public', 'guide', MEDIA_SRC_PREFIX); +const MEDIA_URL_BASE = `/guide/${MEDIA_SRC_PREFIX}`; +/** This site's own origin, as the canonical sources are forced to spell it. */ +export const SITE_ORIGIN = 'https://dormouse.sh'; +/** + * What `vsce --baseImagesUrl` must be given, everywhere it is invoked. + * + * `vsce` resolves the guide's repo-relative `images/x` against this, so it is + * the site copy's parent, not the directory itself. Pinned by + * scripts/public-docs-lint.mjs -> checkImageBaseUrl. + */ +export const SITE_IMAGE_BASE = `${SITE_ORIGIN}/guide`; +/** Where the unpublished half of SELF_HOST.md is still readable. */ +const SELF_HOST_CANONICAL_URL = 'https://github.com/diffplug/dormouse/blob/main/SELF_HOST.md'; + +/** Every page's shell supplies its own title and breadcrumb, so no published + * document keeps the `#` heading its file leads with. */ +const DROP_DOCUMENT_TITLE = { + id: 'drop-document-title', + reason: 'The docs page shell supplies its own title and breadcrumb.', + /** @param {{type: string, depth?: number}} block */ + match: (block) => block.type === 'heading' && block.depth === 1, + operation: 'remove', +}; + +/** + * The complete guide delta, per docs/specs/website-docs.md. + * + * The website applies only structural operations to the canonical README. Each + * entry names exactly one target and fails the build when that target is absent + * or ambiguous — no regexes over prose, no line-number patches. + */ +const DOCS_DELTA = [DROP_DOCUMENT_TITLE]; + +/** Match the one heading whose text is exactly `text`. */ +const headingNamed = (text) => (block) => block.type === 'heading' && block.text === text; + +/** + * The complete self-host delta, per docs/specs/website-docs.md. + * + * SELF_HOST.md is two documents in one file: a runbook a reader can follow, + * and material addressed to the assistant running it or to a maintainer of + * `deploy/local/`. Only the runbook is published; the rest is removed here + * rather than split out of the file, because the assistant reading + * `@SELF_HOST.md` in a checkout needs all of it in one place. + */ +const SELF_HOST_DELTA = [ + DROP_DOCUMENT_TITLE, + { + id: 'drop-repo-invocation', + reason: 'The opening blockquote tells a reader to open the file in a checkout.', + match: (block) => block.type === 'blockquote', + operation: 'remove', + }, + { + id: 'drop-assistant-instructions', + reason: 'Addressed to the assistant running the runbook, not to a reader.', + match: headingNamed('Instructions to the assistant'), + operation: 'remove-section', + }, + { + id: 'drop-final-handoff', + reason: 'Tells the assistant what to report back; meaningless on a page.', + match: headingNamed('Final handoff'), + operation: 'remove-section', + }, + { + id: 'drop-installer-contract', + reason: 'The maintainer half of the file, audited by scripts/deploy-lint.mjs.', + match: headingNamed('Installer contract (maintainers)'), + operation: 'remove-section', + }, +]; + +/** + * Sections of dor/skill.md reused verbatim as the CLI page's introduction. + * + * Each entry is the prefix its heading starts with — skill headings carry + * descriptive suffixes ("Targeting: three ways to name a surface") — and is + * also the section's title and, slugged, its anchor on /docs/dor. + */ +const CLI_INTRO_SECTIONS = ['Targeting', 'Surface handles']; + +/** + * The heading every command section sits under on /docs/dor. + * + * Emitted rather than spelled in the page, because the table of contents nests + * every command under it and a parent whose id is not on the page links + * nowhere; one record is what keeps the two the same id. + */ +const CLI_COMMANDS_SECTION = { id: 'commands', title: 'Commands' }; + +/** + * Apply one document's delta. + * + * `remove` drops the single matched block. `remove-section` drops a matched + * heading and everything under it — every following block up to the next + * heading of the same or shallower depth — so removing `## X` takes its `###` + * subsections with it. + * + * Every rule must match exactly one block, and a rule that matches nothing + * fails the build: a section renamed in the canonical source must be a + * decision, not a silent republication of what the delta meant to withhold. + * + * Returns the ids the delta removed, so the caller can find links left + * pointing at them. + */ +export function applyDelta(blocks, rules, label) { + const applied = []; + const removedIds = []; + let result = blocks; + + for (const rule of rules) { + const matches = result.filter(rule.match); + if (matches.length === 0) { + throw new Error(`${label} delta "${rule.id}" matched nothing; its target is gone`); + } + if (matches.length > 1) { + throw new Error(`${label} delta "${rule.id}" matched ${matches.length} blocks; target is ambiguous`); + } + const [target] = matches; + + let dropped; + if (rule.operation === 'remove') { + dropped = [target]; + } else if (rule.operation === 'remove-section') { + if (target.type !== 'heading') { + throw new Error(`${label} delta "${rule.id}" is remove-section but matched a ${target.type}`); + } + dropped = [target, ...sectionBlocks(result, target)]; + } else { + throw new Error(`${label} delta "${rule.id}" has unknown operation "${rule.operation}"`); + } + + const gone = new Set(dropped); + result = result.filter((b) => !gone.has(b)); + for (const block of dropped) { + visit([block], (node) => { + if (node.type === 'heading') removedIds.push(node.id); + }); + } + applied.push({ + id: rule.id, + reason: rule.reason, + operation: rule.operation, + target: target.text ?? null, + blocks: dropped.length, + }); + } + + return { blocks: result, applied, removedIds }; +} + +/** + * Send in-document links that the delta orphaned to the canonical file. + * + * A `#anchor` into a removed section would otherwise scroll nowhere. The + * surviving prose still has a reason to point there — the material exists, it + * just isn't published here — so the link keeps its text and gains the origin + * that still serves it. + */ +function resolveRemovedAnchors(blocks, removedIds, canonicalUrl) { + const removed = new Set(removedIds); + const rewritten = []; + visit(blocks, (node) => { + if (node.type !== 'link' || !node.href?.startsWith('#')) return; + const id = node.href.slice(1); + if (!removed.has(id)) return; + node.href = `${canonicalUrl}#${id}`; + rewritten.push({ from: `#${id}`, to: node.href }); + }); + return rewritten; +} + +/** No `#anchor` link may point at an id the page does not contain. */ +function assertAnchorsResolve(blocks, headings, where) { + const ids = new Set(headings.map((h) => h.id)); + const dangling = []; + visit(blocks, (node) => { + if (node.type !== 'link' || !node.href?.startsWith('#')) return; + if (!ids.has(node.href.slice(1))) dangling.push(node.href); + }); + if (dangling.length > 0) { + throw new Error(`dangling anchor link(s) on ${where}: ${[...new Set(dangling)].join(', ')}`); + } +} + +/** Headings that survive the delta, in document order. */ +function headingsOf(blocks) { + const headings = []; + visit(blocks, (node) => { + if (node.type === 'heading') headings.push({ depth: node.depth, id: node.id, text: node.text }); + }); + return headings; +} + +/** Nested table of contents from an already-filtered heading list. */ +function buildToc(headings) { + const top = []; + for (const h of headings) { + if (h.depth === 2) { + top.push({ id: h.id, text: h.text, children: [] }); + } else if (h.depth === 3 && top.length > 0) { + top[top.length - 1].children.push({ id: h.id, text: h.text, children: [] }); + } + } + return top; +} + +/** Fail unless exactly one heading matches, naming the rule that looked. */ +function findExactlyOneHeading(headings, predicate, label) { + const matches = headings.filter(predicate); + if (matches.length === 0) throw new Error(`no heading matches "${label}"`); + if (matches.length > 1) throw new Error(`heading "${label}" is ambiguous (${matches.length} matches)`); + return matches[0]; +} + +/** Fail on any duplicate id, so a page's anchors stay addressable. */ +function assertUniqueIds(ids, where) { + const dupes = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))]; + if (dupes.length > 0) throw new Error(`duplicate anchor ids on ${where}: ${dupes.join(', ')}`); +} + +/** + * Rewrite the guide's relative image sources to the path the site serves, and + * report which media files were actually used. + * + * The README is the source of truth and references images the way GitHub + * expects — repo-relative — so dropping a file into vscode-ext/images/ and + * linking it just works on GitHub, in the packaged extension, and here. + */ +function resolveGuideMedia(blocks, available) { + const used = new Set(); + visit(blocks, (node) => { + if (node.type !== 'image' || !node.src) return; + if (hasScheme(node.src) || node.src.startsWith('/')) { + throw new Error(`guide image "${node.src}" must be a repo-relative local file`); + } + const rel = node.src.replace(/^\.\//, ''); + if (!rel.startsWith(MEDIA_SRC_PREFIX)) { + throw new Error(`guide image "${node.src}" must live under vscode-ext/${MEDIA_SRC_PREFIX}`); + } + const file = rel.slice(MEDIA_SRC_PREFIX.length); + if (!available.includes(file)) { + throw new Error(`guide references ${rel}, which is not in vscode-ext/${MEDIA_SRC_PREFIX}`); + } + used.add(file); + node.src = MEDIA_URL_BASE + file; + }); + return { used: [...used], unused: available.filter((f) => !used.has(f)) }; +} + +/** + * Rewrite links that point back at this site to root-relative paths. + * + * The canonical sources spell these absolutely on purpose: the Marketplace, + * Open VSX, and GitHub all render them away from this origin, where a relative + * path means nothing (see docs/specs/website-docs.md -> Marketplace and Open + * VSX constraints). On the site itself the same URL is a bug — every click + * leaves the origin, so a link followed from a dev server lands on production. + * + * Only the origin is dropped; path, query, and fragment survive verbatim, so + * `.../docs/dor#agent-browser` keeps its deep link. + */ +function localizeSiteLinks(blocks) { + const localized = []; + visit(blocks, (node) => { + if (node.type !== 'link' || !node.href) return; + // Relative hrefs and bare fragments are already local; a non-HTTP scheme + // (mailto:, vscode:) has no origin to compare and must be left alone. + if (!/^https?:\/\//i.test(node.href)) return; + const url = new URL(node.href); + if (url.origin !== SITE_ORIGIN) return; + const from = node.href; + node.href = `${url.pathname}${url.search}${url.hash}`; + localized.push({ from, to: node.href }); + }); + return localized; +} + +/** + * Copy the guide's media next to the site. Only `main()` should call this. + * + * Required by the Marketplace listing, not by any page here — see mediaSrcDir. + */ +async function syncGuideMedia(files) { + await rm(mediaOutDir, { recursive: true, force: true }); + await mkdir(mediaOutDir, { recursive: true }); + await Promise.all(files.map((f) => copyFile(join(mediaSrcDir, f), join(mediaOutDir, f)))); +} + +/** + * One published page from one canonical Markdown file. + * + * Both Markdown pages need the same steps in the same order, and the only + * difference is an option: `canonicalUrl` sends links into withheld sections + * off-site rather than leaving them dangling. Written once because two copies + * of this had already drifted on the order of the last three steps, which is + * how one page quietly loses a guarantee the other still has. + */ +async function buildDocument({ file, delta, label, fallbackTitle, canonicalUrl }) { + const markdown = await readFile(join(repoRoot, file), 'utf8'); + + // One slugger for the whole document, so ids are unique across the page. + const parsed = parseMarkdown(markdown, { slug: createSlugger() }); + const title = parsed.blocks.find((b) => b.type === 'heading' && b.depth === 1)?.text ?? fallbackTitle; + + const { blocks, applied, removedIds } = applyDelta(parsed.blocks, delta, label); + // Derived from the post-delta tree, so the delta is expressed once: whatever + // it removes disappears from the heading inventory and the TOC for free. + const headings = headingsOf(blocks); + assertUniqueIds(headings.map((h) => h.id), label); + + // Both rewrites run before the assertion: a link into a withheld section is + // not dangling once it has gained the origin that still serves it. + const withheldLinks = canonicalUrl ? resolveRemovedAnchors(blocks, removedIds, canonicalUrl) : []; + const localizedLinks = localizeSiteLinks(blocks); + assertAnchorsResolve(blocks, headings, label); + + return { + source: file, + title, + blocks, + headings, + toc: buildToc(headings), + delta: applied, + withheldLinks, + localizedLinks, + }; +} + +async function buildGuide() { + const page = await buildDocument({ + file: 'vscode-ext/README.md', + delta: DOCS_DELTA, + label: '/docs', + fallbackTitle: 'Documentation', + }); + const available = (await readdir(mediaSrcDir)).sort(); + return { ...page, media: { available, ...resolveGuideMedia(page.blocks, available) } }; +} + +/** + * `/docs/self-host` from SELF_HOST.md, minus the halves the delta withholds. + * + * The file stays canonical: an assistant reads it in a checkout, and + * `scripts/deploy-lint.mjs` audits its Installer contract against + * `deploy/local/`. Publishing a copy would mean two files to keep true about + * how a server is installed. + */ +const buildSelfHost = () => + buildDocument({ + file: 'SELF_HOST.md', + delta: SELF_HOST_DELTA, + label: '/docs/self-host', + fallbackTitle: 'Self-host', + canonicalUrl: SELF_HOST_CANONICAL_URL, + }); + +/** Blocks belonging to a heading: everything up to the next heading of <= depth. */ +function sectionBlocks(blocks, heading) { + const start = blocks.indexOf(heading); + const out = []; + for (let i = start + 1; i < blocks.length; i++) { + const b = blocks[i]; + if (b.type === 'heading' && b.depth <= heading.depth) break; + out.push(b); + } + return out; +} + +/** Backticked tokens inside a heading's inline nodes. */ +function headingCodeTokens(heading) { + return (heading.children ?? []).filter((n) => n.type === 'code').map((n) => n.value.trim()); +} + +async function buildCli(skill) { + const helpDir = join(repoRoot, 'dor', 'test', 'snapshots', 'help'); + const files = (await readdir(helpDir)).filter((f) => f.endsWith('.md')).sort(); + const texts = await Promise.all(files.map((f) => readFile(join(helpDir, f), 'utf8'))); + + const snapshots = new Map(); + files.forEach((file, i) => { + const id = file.replace(/\.md$/, ''); + if (snapshots.has(id)) throw new MalformedSnapshotError(`duplicate command id "${id}"`); + const parsed = parseSnapshot(texts[i], file); + const nodes = parseHelp(parsed.raw); + if (reconstruct(nodes) !== parsed.raw) { + throw new MalformedSnapshotError(`${file}: parsed nodes do not reconstruct the raw help`); + } + snapshots.set(id, { id, file, ...parsed, nodes }); + }); + + const root = snapshots.get('dor'); + if (!root) throw new MalformedSnapshotError('missing root help snapshot dor.md'); + + // The root help owns command order and inventory. + const inventory = rootCommandNames(root.nodes); + const missing = inventory.filter((c) => !snapshots.has(c)); + if (missing.length > 0) { + throw new MalformedSnapshotError(`root help lists commands with no snapshot: ${missing.join(', ')}`); + } + const extra = [...snapshots.keys()].filter((id) => id !== 'dor' && !inventory.includes(id)); + if (extra.length > 0) { + throw new MalformedSnapshotError(`snapshots with no entry in root help: ${extra.join(', ')}`); + } + + const toSection = (snap) => { + const pick = (kind) => snap.nodes.filter((n) => n.kind === kind); + return { + id: snap.id, + title: snap.title, + invocation: snap.invocation, + usage: pick('usage').flatMap(usageLines), + prose: pick('prose').flatMap(proseParagraphs), + definitions: [...pick('flags'), ...pick('arguments'), ...pick('commands')].map((n) => ({ + label: n.label, + rows: definitionRows(n), + })), + blocks: [...pick('examples'), ...pick('textOutput'), ...pick('jsonOutput')].map((n) => ({ + label: n.label, + body: labelledBody(n), + })), + // The collapsed disclosure shows this byte for byte. + raw: snap.raw, + }; + }; + + // Intro sections lifted from the skill, not re-authored here. + const skillHeadings = skill.blocks.filter((b) => b.type === 'heading'); + const intro = CLI_INTRO_SECTIONS.map((prefix) => { + const heading = findExactlyOneHeading(skillHeadings, (h) => h.text.startsWith(prefix), prefix); + return { id: slugify(prefix), title: prefix, blocks: sectionBlocks(skill.blocks, heading) }; + }); + + const rootSection = toSection(root); + const commands = inventory.map((name) => toSection(snapshots.get(name))); + + // Every id addressable on /docs/dor, from all of its sources: the intro + // sections, the root section, the Commands heading, the command sections, + // and any heading inside a lifted intro block. Nothing else may collide with + // a command anchor that SKILL_REFERENCES links into. + const anchors = [ + ...intro.map((s) => s.id), + ...intro.flatMap((s) => headingsOf(s.blocks).map((h) => h.id)), + 'dor', + CLI_COMMANDS_SECTION.id, + ...commands.map((c) => c.id), + ]; + assertUniqueIds(anchors, '/docs/dor'); + + // Emitted, not assembled in the page: every docs page reads `toc` off its own + // data file, so the table of contents has one owner for all three. The + // commands nest one level down, so the rail shows this page as four entries + // rather than fourteen. + const entry = ({ id, title }) => ({ id, text: title, children: [] }); + const toc = [ + ...intro.map(entry), + entry(rootSection), + { ...entry(CLI_COMMANDS_SECTION), children: commands.map(entry) }, + ]; + + return { + source: 'dor/test/snapshots/help/', + intro, + root: rootSection, + commandsHeading: CLI_COMMANDS_SECTION, + commands, + anchors, + toc, + }; +} + +async function buildSkill() { + const markdown = await readFile(join(repoRoot, 'dor', 'skill.md'), 'utf8'); + const parsed = parseMarkdown(markdown, { slug: createSlugger() }); + // Before buildCli lifts its intro sections out of these same block objects, + // so /docs/dor inherits the rewrite rather than needing its own. + const localizedLinks = localizeSiteLinks(parsed.blocks); + return { source: 'dor/skill.md', blocks: parsed.blocks, headings: parsed.headings, localizedLinks }; +} + +/** + * Attach a CLI reference link to each skill heading that has a counterpart. + * + * Derived, never listed. The prose sections come from CLI_INTRO_SECTIONS; a + * command section is any skill heading whose backticked tokens name a `dor` + * subcommand, so documenting a new command in dor/skill.md earns its link with + * no table to remember. A heading that names a subcommand with no section on + * /docs/dor fails the build rather than publishing silently unlinked — the one + * failure a hardcoded list could not see. + */ +function linkSkillHeadings(skill, cli) { + const anchors = new Set(cli.anchors); + const headings = skill.blocks.filter((b) => b.type === 'heading'); + const links = {}; + + for (const prefix of CLI_INTRO_SECTIONS) { + const anchor = slugify(prefix); + if (!anchors.has(anchor)) { + throw new Error(`skill reference target "#${anchor}" does not exist in the CLI reference`); + } + const heading = findExactlyOneHeading(headings, (h) => h.text.startsWith(prefix), prefix); + links[heading.id] = { href: `/docs/dor#${anchor}`, label: prefix }; + } + + for (const heading of headings) { + // A heading may name a command more than one way ("`dor ab` / + // `dor agent-browser`"). It is labelled by the spelling it leads with and + // linked to the first that has a section. + const named = headingCodeTokens(heading) + .map((token) => /^dor (\S+)$/.exec(token)?.[1]) + .filter((name) => name !== undefined); + if (named.length === 0) continue; + const anchor = named.find((name) => anchors.has(name)); + if (anchor === undefined) { + throw new Error( + `skill heading "${heading.text}" names dor ${named.join('/')}, which has no section in the CLI reference`, + ); + } + links[heading.id] = { href: `/docs/dor#${anchor}`, label: `dor ${named[0]}` }; + } + + return links; +} + +export async function generateDocs() { + const guide = await buildGuide(); + const selfhost = await buildSelfHost(); + const skill = await buildSkill(); + const cli = await buildCli(skill); + const references = linkSkillHeadings(skill, cli); + + return { + guide, + selfhost, + cli, + skill: { + source: skill.source, + blocks: skill.blocks, + headings: skill.headings, + localizedLinks: skill.localizedLinks, + toc: buildToc(skill.headings.filter((h) => h.depth > 1)), + references, + }, + }; +} + +async function main() { + const data = await generateDocs(); + await mkdir(dataDir, { recursive: true }); + // One file per page: importing a single combined module made every docs + // route ship all three documents in one shared chunk. + await Promise.all([ + ...Object.entries(data).map(([name, value]) => + writeFile(join(dataDir, `docs.${name}.json`), `${JSON.stringify(value, null, 2)}\n`, 'utf8'), + ), + syncGuideMedia(data.guide.media.available), + ]); + const { guide, selfhost, cli, skill } = data; + console.log( + `Wrote docs data: guide ${guide.headings.length} headings, ` + + `self-host ${selfhost.headings.length} headings (${selfhost.delta.length} delta rules), ` + + `cli ${cli.commands.length} commands + ${cli.intro.length} intro sections, ` + + `skill ${Object.keys(skill.references).length} reference links, ` + + `${guide.media.available.length} media file(s), ` + + `${guide.localizedLinks.length + selfhost.localizedLinks.length + skill.localizedLinks.length} link(s) localized`, + ); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((error) => { + console.error(`generate-docs failed: ${error.message}`); + process.exit(1); + }); +} diff --git a/website/scripts/generate-docs.test.js b/website/scripts/generate-docs.test.js new file mode 100644 index 000000000..2e977ffb0 --- /dev/null +++ b/website/scripts/generate-docs.test.js @@ -0,0 +1,238 @@ +import { describe, it, expect } from 'vitest'; +import { applyDelta, generateDocs } from './generate-docs.js'; +import { createSlugger, parseMarkdown, visit } from './docs-parser.js'; + +const data = await generateDocs(); + +/** + * Every link href in the generated data: both published pages, plus the guide + * data, which has no page today but is generated and must stay correct. + */ +function generatedHrefs() { + const hrefs = []; + const collect = (blocks) => + visit(blocks, (node) => { + if (node.type === 'link' && node.href) hrefs.push(node.href); + }); + collect(data.guide.blocks); + collect(data.selfhost.blocks); + collect(data.skill.blocks); + for (const section of data.cli.intro) collect(section.blocks); + return hrefs; +} + +describe('product guide', () => { + it('drops exactly the document title and records the delta', () => { + expect(data.guide.delta).toHaveLength(1); + expect(data.guide.delta[0]).toMatchObject({ id: 'drop-document-title', operation: 'remove' }); + expect(data.guide.blocks.some((b) => b.type === 'heading' && b.depth === 1)).toBe(false); + }); + + it('has unique, stable heading ids', () => { + const ids = data.guide.headings.map((h) => h.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toContain('alerts-and-todos'); + expect(ids).toContain('browsers-for-you-and-your-agents'); + }); +}); + +describe('delta operations', () => { + const doc = () => + parseMarkdown( + ['# Title', '', '## Keep', '', 'kept', '', '## Drop', '', 'gone', '', '### Nested', '', 'also gone', '', '## After', '', 'kept too'].join('\n'), + { slug: createSlugger() }, + ).blocks; + + const dropRule = { id: 'drop', reason: 'test', operation: 'remove-section', match: (b) => b.type === 'heading' && b.text === 'Drop' }; + + it('removes a section and its subsections, and stops at the next same-depth heading', () => { + const { blocks, applied, removedIds } = applyDelta(doc(), [dropRule], 'test'); + const headings = blocks.filter((b) => b.type === 'heading').map((h) => h.text); + expect(headings).toEqual(['Title', 'Keep', 'After']); + expect(removedIds).toEqual(['drop', 'nested']); + expect(applied[0].blocks).toBe(4); + }); + + it('fails when a rule matches nothing, rather than publishing what it withholds', () => { + const gone = { ...dropRule, match: (b) => b.type === 'heading' && b.text === 'Renamed' }; + expect(() => applyDelta(doc(), [gone], 'test')).toThrow(/matched nothing/); + }); + + it('fails when a rule is ambiguous', () => { + const both = { ...dropRule, match: (b) => b.type === 'heading' && b.depth === 2 }; + expect(() => applyDelta(doc(), [both], 'test')).toThrow(/matched 3 blocks/); + }); + + it('refuses remove-section on a block that is not a heading', () => { + const wrong = { ...dropRule, match: (b) => b.type === 'paragraph' && b.children?.[0]?.value === 'kept' }; + expect(() => applyDelta(doc(), [wrong], 'test')).toThrow(/remove-section but matched a paragraph/); + }); +}); + +describe('self-host runbook', () => { + it('withholds the assistant and maintainer halves, and nothing else', () => { + expect(data.selfhost.delta.map((r) => r.id)).toEqual([ + 'drop-document-title', + 'drop-repo-invocation', + 'drop-assistant-instructions', + 'drop-final-handoff', + 'drop-installer-contract', + ]); + const text = data.selfhost.headings.map((h) => h.text); + for (const withheld of ['Instructions to the assistant', 'Final handoff', 'Installer contract (maintainers)']) { + expect(text).not.toContain(withheld); + } + }); + + it('takes a removed section subheadings and all', () => { + // The Installer contract's four ### subsections must go with their ##. + const ids = data.selfhost.headings.map((h) => h.id); + for (const id of ['mechanism-map', 'invariants', 'mechanical-traps', 'operator-surface-and-test-hooks']) { + expect(ids, `#${id} outlived its parent section`).not.toContain(id); + } + expect(data.selfhost.headings.every((h) => h.depth === 2)).toBe(true); + }); + + it('keeps every checkpoint the runbook walks through', () => { + const ids = data.selfhost.headings.map((h) => h.id); + expect(ids.filter((id) => id.startsWith('checkpoint-'))).toHaveLength(6); + expect(ids).toContain('prerequisites'); + expect(ids).toContain('what-the-installer-does'); + }); + + it('sends links orphaned by the delta to the canonical file', () => { + // Without this the surviving prose points at an anchor that is no longer + // on the page, and the link silently scrolls nowhere. + expect(data.selfhost.withheldLinks.length).toBeGreaterThan(0); + for (const { from, to } of data.selfhost.withheldLinks) { + expect(from.startsWith('#')).toBe(true); + expect(to).toBe(`https://github.com/diffplug/dormouse/blob/main/SELF_HOST.md${from}`); + } + }); + + it('leaves no in-document link pointing at a missing heading', () => { + const ids = new Set(data.selfhost.headings.map((h) => h.id)); + const dangling = []; + visit(data.selfhost.blocks, (node) => { + if (node.type === 'link' && node.href?.startsWith('#') && !ids.has(node.href.slice(1))) { + dangling.push(node.href); + } + }); + expect(dangling).toEqual([]); + }); +}); + +describe('cli reference', () => { + it('covers every command in the root inventory, in order', () => { + expect(data.cli.commands.length).toBeGreaterThan(0); + expect(data.cli.commands[0].id).toBe('split'); + expect(data.cli.commands.map((c) => c.id)).toContain('agent-browser'); + }); + + it('provides the anchors the skill links into', () => { + const anchors = new Set(data.cli.anchors); + for (const a of ['targeting', 'surface-handles', 'dor', 'list', 'split', 'ensure', 'send', 'read', 'kill', 'agent-browser', 'iframe']) { + expect(anchors, `missing anchor #${a}`).toContain(a); + } + }); + + it('exposes a collision-free anchor namespace', () => { + expect(new Set(data.cli.anchors).size).toBe(data.cli.anchors.length); + }); + + it('keeps exact help alongside the parsed view', () => { + for (const cmd of data.cli.commands) { + expect(cmd.raw).toContain('USAGE'); + expect(cmd.usage.length).toBeGreaterThan(0); + } + }); + + it('nests every command under one heading, and that heading is an anchor', () => { + // Flat, this page alone is fourteen rail entries; nested under an id that + // is not on the page, the parent is a link that scrolls nowhere. + const { commandsHeading, toc } = data.cli; + expect(toc.map((e) => e.id)).toEqual(['targeting', 'surface-handles', 'dor', commandsHeading.id]); + // `anchors` is built from a separate list, so this crosses a real boundary. + expect(data.cli.anchors).toContain(commandsHeading.id); + for (const entry of toc.slice(0, -1)) expect(entry.children).toEqual([]); + }); + + it('lifts its intro sections from the skill rather than re-authoring them', () => { + expect(data.cli.intro.map((s) => s.id)).toEqual(['targeting', 'surface-handles']); + for (const section of data.cli.intro) expect(section.blocks.length).toBeGreaterThan(0); + }); +}); + +describe('agent skill', () => { + it('reproduces every heading in dor/skill.md, with matching ids', async () => { + // Independently parse the file and compare, rather than asserting the + // generator's own copy of its own input equals its own input. + const { readFile } = await import('node:fs/promises'); + const { parseMarkdown, createSlugger } = await import('./docs-parser.js'); + const onDisk = await readFile(new URL('../../dor/skill.md', import.meta.url), 'utf8'); + const expected = parseMarkdown(onDisk, { slug: createSlugger() }).headings; + expect(data.skill.headings).toEqual(expected); + }); + + it('does not ship the raw skill markdown to the browser', () => { + // Nothing renders it, and it is ~10 KB on every docs page. + expect(data.skill.markdown).toBeUndefined(); + }); + + it('resolves every reference into an existing CLI anchor', () => { + const anchors = new Set(data.cli.anchors); + const refs = Object.values(data.skill.references); + // If this ever finds nothing the derivation has silently stopped matching. + expect(refs.length).toBeGreaterThan(0); + for (const ref of refs) { + expect(anchors).toContain(ref.href.replace('/docs/dor#', '')); + } + }); + + it('links every skill heading that names a dor command', () => { + // The derivation's whole point: a command documented in dor/skill.md gets + // its reference with no table to update, so none may be left out. + const named = data.skill.blocks.filter( + (b) => b.type === 'heading' && (b.children ?? []).some((n) => n.type === 'code' && /^dor \S+$/.test(n.value)), + ); + expect(named.length).toBeGreaterThan(0); + for (const heading of named) expect(data.skill.references).toHaveProperty(heading.id); + }); + + it('anchors each reference to a real skill heading id', () => { + const ids = new Set(data.skill.headings.map((h) => h.id)); + for (const id of Object.keys(data.skill.references)) expect(ids).toContain(id); + }); +}); + +describe('same-site links', () => { + it('rewrites the guide\'s absolute site links to root-relative paths', () => { + // The guide has to spell these absolutely for the Marketplace, so if this + // ever finds nothing the rewrite has silently stopped matching. + expect(data.guide.localizedLinks.length).toBeGreaterThan(0); + expect(data.guide.localizedLinks).toContainEqual({ + from: 'https://dormouse.sh/docs/dor', + to: '/docs/dor', + }); + }); + + it('keeps the fragment on a localized deep link', () => { + expect(data.guide.localizedLinks).toContainEqual({ + from: 'https://dormouse.sh/docs/dor#agent-browser', + to: '/docs/dor#agent-browser', + }); + // A bare origin still has to address the homepage, not the empty string. + for (const { to } of data.guide.localizedLinks) expect(to.startsWith('/')).toBe(true); + }); + + it('leaves no absolute site link in the generated data', () => { + const offenders = generatedHrefs().filter((href) => href.startsWith('https://dormouse.sh')); + expect(offenders, 'these would navigate off the current origin').toEqual([]); + }); + + it('does not touch links to other origins', () => { + const external = generatedHrefs().filter((href) => /^https?:\/\//i.test(href)); + expect(external.length).toBeGreaterThan(0); + for (const href of external) expect(href).not.toContain('dormouse.sh'); + }); +}); diff --git a/website/scripts/help-parser.js b/website/scripts/help-parser.js new file mode 100644 index 000000000..06554894d --- /dev/null +++ b/website/scripts/help-parser.js @@ -0,0 +1,197 @@ +/** + * Narrow parser for `dor <command> --help` snapshot envelopes. + * + * Deliberately shallow: it recognizes only the column-zero markers the CLI + * actually emits and leaves everything else as ordered prose. Semantic parsing + * may fall back to prose, but it may never silently discard source text — every + * node keeps the exact source slice it came from, and `reconstruct()` rebuilds + * the original help byte for byte. `docs/specs/website-docs.md` -> /docs/dor. + * + * No dependencies. + */ + +export class MalformedSnapshotError extends Error { + constructor(message) { + super(message); + this.name = 'MalformedSnapshotError'; + } +} + +/** Column-zero markers, and the node kind each opens. Anything else at column + * zero is prose. This object is the whole inventory — a marker added here is + * recognized, with no second list to keep in step. */ +const MARKER_KIND = { + USAGE: 'usage', + COMMANDS: 'commands', + FLAGS: 'flags', + ARGUMENTS: 'arguments', + 'Examples:': 'examples', + 'Text output:': 'textOutput', + 'JSON output:': 'jsonOutput', +}; + +function markerFor(line) { + if (/^\s/.test(line)) return null; + const marker = line.trimEnd(); + return marker in MARKER_KIND ? marker : null; +} + +/** + * Parse the snapshot file: `# <title>`, `Invocation: \`<cmd>\``, then one + * ```text fence holding the raw help. + */ +export function parseSnapshot(markdown, file) { + const text = markdown.replace(/\r\n?/g, '\n'); + const title = /^#\s+(.+?)\s*$/m.exec(text); + if (!title) throw new MalformedSnapshotError(`${file}: missing "# <title>" heading`); + + const invocation = /^Invocation:\s*`([^`]+)`\s*$/m.exec(text); + if (!invocation) throw new MalformedSnapshotError(`${file}: missing "Invocation: \`...\`" line`); + + const fence = /^```text\n([\s\S]*?)\n?```\s*$/m.exec(text); + if (!fence) throw new MalformedSnapshotError(`${file}: missing \`\`\`text help block`); + + return { title: title[1], invocation: invocation[1], raw: fence[1] }; +} + +/** + * Split raw help into ordered nodes. Each node carries `raw`, the exact slice + * of the source it represents, including its own trailing newlines. + */ +export function parseHelp(raw) { + const lines = raw.split('\n'); + const nodes = []; + let current = null; + + const push = () => { + if (current && current.lines.length > 0) { + nodes.push({ kind: current.kind, label: current.label, raw: current.lines.join('\n') }); + } + current = null; + }; + + for (const line of lines) { + const marker = markerFor(line); + if (marker) { + push(); + current = { kind: MARKER_KIND[marker], label: marker, lines: [line] }; + continue; + } + // A marker section owns only its indented body. The first column-zero + // non-blank line that is not itself a marker ends it and begins prose -- + // without this, `dor list`'s description paragraphs get absorbed into its + // USAGE node and render as if they were usage lines. + const startsProse = line.trim() !== '' && !/^\s/.test(line); + if (current && current.kind !== 'prose' && startsProse) { + push(); + } + if (!current) current = { kind: 'prose', label: null, lines: [] }; + current.lines.push(line); + } + push(); + + return nodes; +} + +/** Rebuild the original raw help from parsed nodes. Must be byte-identical. */ +export function reconstruct(nodes) { + return nodes.map((n) => n.raw).join('\n'); +} + +/** Usage lines: the indented invocations under `USAGE`. */ +export function usageLines(node) { + return node.raw.split('\n').slice(1).map((l) => l.trim()).filter(Boolean); +} + +/** + * The most frequent value, breaking ties toward the highest or lowest. + * + * Both column heuristics below are this same argmax: descriptions align on one + * column (ties go right, since a term may contain its own short gap like + * "-h --help"), and terms share one indent (ties go left). + */ +function mostCommon(values, tieBreak) { + const tally = new Map(); + for (const v of values) tally.set(v, (tally.get(v) ?? 0) + 1); + let winner = 0; + let best = 0; + for (const [value, count] of tally) { + const wins = count > best || (count === best && (tieBreak === 'highest' ? value > winner : value < winner)); + if (wins) { + winner = value; + best = count; + } + } + return winner; +} + +/** + * Definition rows under FLAGS / ARGUMENTS / COMMANDS. The CLI aligns them on + * whitespace runs; a row whose description wraps continues the previous row. + */ +export function definitionRows(node) { + const lines = node.raw.split('\n').slice(1).filter((l) => l.trim() !== ''); + if (lines.length === 0) return []; + + // The CLI aligns descriptions on a common column. Find it by taking the end + // of every 2+ space run and keeping the column that appears in the most + // lines (ties resolve to the rightmost, since a term may itself contain a + // short gap -- "-h --help"). + const column = mostCommon( + lines.flatMap((line) => + [...line.matchAll(/\s{2,}/g)] + .map((m) => m.index + m[0].length) + .filter((col) => col < line.length), + ), + 'highest', + ); + + // The column most terms start at, used to tell a wrapped description from a + // new term: a continuation begins nearer the description column than the + // term column. `dor split --help` wraps at indent 18 against a description + // column of 19, so an exact `indent >= column` test would miss it. + const termIndent = mostCommon(lines.map((line) => line.length - line.trimStart().length), 'lowest'); + + const rows = []; + for (const line of lines) { + const indent = line.length - line.trimStart().length; + const isContinuation = + column > 0 && Math.abs(indent - column) < Math.abs(indent - termIndent); + + if (isContinuation && rows.length > 0) { + const prev = rows[rows.length - 1]; + prev.description = `${prev.description} ${line.trim()}`.trim(); + continue; + } + + // A real gap at the description column splits term from description. + if (column > 0 && /\s\s$/.test(line.slice(0, column)) && line.length > column) { + rows.push({ term: line.slice(0, column).trim(), description: line.slice(column).trim() }); + continue; + } + + // Otherwise the whole line is the term; its description wraps to the next. + rows.push({ term: line.trim(), description: '' }); + } + return rows; +} + +/** Body lines of a labelled block (`Examples:`, `Text output:`, ...). */ +export function labelledBody(node) { + return node.raw.split('\n').slice(1).join('\n').replace(/\s+$/, ''); +} + +/** Prose paragraphs, blank-line separated, with single-newline unwrapping. */ +export function proseParagraphs(node) { + return node.raw + .split(/\n\s*\n/) + .map((p) => p.split('\n').map((l) => l.trim()).filter(Boolean).join(' ').trim()) + .filter(Boolean); +} + +/** Command names from the root help's COMMANDS section, in listed order. */ +export function rootCommandNames(nodes) { + const commands = nodes.find((n) => n.kind === 'commands'); + if (!commands) throw new MalformedSnapshotError('root help has no COMMANDS section'); + return definitionRows(commands).map((r) => r.term); +} diff --git a/website/scripts/help-parser.test.js b/website/scripts/help-parser.test.js new file mode 100644 index 000000000..316ebf84b --- /dev/null +++ b/website/scripts/help-parser.test.js @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + parseSnapshot, + parseHelp, + reconstruct, + rootCommandNames, + usageLines, + definitionRows, + labelledBody, + MalformedSnapshotError, +} from './help-parser.js'; + +const helpDir = join(process.cwd(), '..', 'dor', 'test', 'snapshots', 'help'); + +async function allSnapshots() { + const files = (await readdir(helpDir)).filter((f) => f.endsWith('.md')); + return Promise.all( + files.map(async (file) => ({ file, ...parseSnapshot(await readFile(join(helpDir, file), 'utf8'), file) })), + ); +} + +describe('snapshot envelope', () => { + it('parses every shipped snapshot', async () => { + const snaps = await allSnapshots(); + expect(snaps.length).toBeGreaterThan(0); + for (const s of snaps) { + expect(s.title).toMatch(/^dor/); + expect(s.invocation).toMatch(/--help$/); + expect(s.raw).toContain('USAGE'); + } + }); + + it('rejects a malformed envelope', () => { + expect(() => parseSnapshot('no heading here', 'x.md')).toThrow(MalformedSnapshotError); + expect(() => parseSnapshot('# dor x\n\nno invocation\n', 'x.md')).toThrow(/Invocation/); + expect(() => parseSnapshot('# dor x\n\nInvocation: `dor x --help`\n', 'x.md')).toThrow(/text help block/); + }); +}); + +describe('losslessness', () => { + it('reconstructs every snapshot byte for byte', async () => { + for (const snap of await allSnapshots()) { + expect(reconstruct(parseHelp(snap.raw)), snap.file).toBe(snap.raw); + } + }); + + it('keeps unclassified content as ordered prose', () => { + const raw = 'USAGE\n dor x\n\nSome prose here.\n\nFLAGS\n --json Print JSON.'; + const nodes = parseHelp(raw); + expect(nodes.map((n) => n.kind)).toEqual(['usage', 'prose', 'flags']); + expect(reconstruct(nodes)).toBe(raw); + }); +}); + +describe('semantic extraction', () => { + it('reads usage lines', () => { + const [usage] = parseHelp('USAGE\n dor list [--json]\n dor list --help'); + expect(usageLines(usage)).toEqual(['dor list [--json]', 'dor list --help']); + }); + + it('splits terms from descriptions on the aligned column', async () => { + const snaps = await allSnapshots(); + const list = snaps.find((s) => s.file === 'list.md'); + const flags = parseHelp(list.raw).find((n) => n.kind === 'flags'); + const rows = definitionRows(flags); + // "-h --help" contains its own gap, so a naive first-gap split would + // truncate the term to "-h". + expect(rows.find((r) => r.term === '-h --help')?.description).toBe('Print help information and exit'); + expect(rows.find((r) => r.term === '[--ports]')?.description).toMatch(/listening ports/); + }); + + it('joins a description that wrapped onto the next line', async () => { + const snaps = await allSnapshots(); + const split = snaps.find((s) => s.file === 'split.md'); + const flags = parseHelp(split.raw).find((n) => n.kind === 'flags'); + const [first] = definitionRows(flags); + expect(first.term).toBe('[--left|--right|--up|--down|--auto]'); + expect(first.description).toBe('Split direction. Mutually exclusive; default is --auto.'); + }); + + it('never leaves a definition row without a description', async () => { + for (const snap of await allSnapshots()) { + for (const node of parseHelp(snap.raw).filter((n) => ['flags', 'arguments', 'commands'].includes(n.kind))) { + for (const row of definitionRows(node)) { + expect(row.description, `${snap.file}: "${row.term}"`).not.toBe(''); + } + } + } + }); + + it('reads labelled blocks', () => { + const [block] = parseHelp('Text output:\n * surface:1 terminal'); + expect(labelledBody(block)).toBe(' * surface:1 terminal'); + }); + + it('reads the root command inventory in listed order', async () => { + const snaps = await allSnapshots(); + const root = snaps.find((s) => s.file === 'dor.md'); + const names = rootCommandNames(parseHelp(root.raw)); + expect(names[0]).toBe('split'); + expect(names).toContain('agent-browser'); + expect(new Set(names).size).toBe(names.length); + }); + + it('throws when there is no COMMANDS section', () => { + expect(() => rootCommandNames(parseHelp('USAGE\n dor x'))).toThrow(/no COMMANDS/); + }); +}); diff --git a/website/src/components/DocsLayout.tsx b/website/src/components/DocsLayout.tsx new file mode 100644 index 000000000..f6d8a358d --- /dev/null +++ b/website/src/components/DocsLayout.tsx @@ -0,0 +1,273 @@ +/** + * Shared chrome for every page in the docs section: site header, the left + * navigation rail, and prev/next. + * + * The rail lists all of `DOCS_PAGES` and nests the current page's own sections + * under it, so a reader can move between pages and within one from the same + * control. There is no separate "on this page" — one rail, not two. + * + * See docs/specs/website-docs.md -> Reference page chrome. + */ +import { useEffect, useState, type ReactNode } from "react"; +import { ListIcon, XIcon } from "@phosphor-icons/react"; +import { + getAppliedThemeSnapshot, + subscribeToActiveTheme, + useRestoredTheme, +} from "dormouse-lib/lib/themes"; +import SiteHeader from "./SiteHeader"; +// Imported statically, not lazily. The control renders twice — once in the +// mobile bar, once floating — and a second `Suspense` boundary over the same +// `lazy()` component never resolved, leaving the bar's picker a permanent +// `<template>`. The chunk that saved was ~6KB gzip; a picker that never +// appears is the worse trade. +import DocsThemeControl from "./DocsThemeControl"; +import { ACCENT_TEXT_CLASS, MUTED_ACCENT_LINK_CLASS, TOC_INDENT_CLASS } from "./docs-tokens"; +import { DOCS_PAGES, docsRailPosition, type DocsPage, type TocEntry } from "../lib/docs-pages"; +import { DOCS_THEME_ID } from "../lib/docs-theme"; +import { docsAccentFor } from "../lib/docs-accent"; + +/** Repaints the site's own tokens from the picked theme; see index.css. */ +const THEMED_BODY_CLASS = "docs-themed"; + +/** The header is translucent over the page, so it takes the theme's own + * widget background rather than the site's near-black. */ +const DOCS_HEADER_STYLE: React.CSSProperties = { + background: "color-mix(in srgb, var(--color-bg) 85%, transparent)", + backdropFilter: "blur(12px)", +}; + +function TocList({ entries, nested = false }: { entries: TocEntry[]; nested?: boolean }) { + if (entries.length === 0) return null; + return ( + <ul className={nested ? `mt-1 space-y-1 ${TOC_INDENT_CLASS}` : "space-y-1"}> + {entries.map((entry) => ( + <li key={entry.id}> + <a + href={`#${entry.id}`} + className={`block py-0.5 text-sm ${MUTED_ACCENT_LINK_CLASS}`} + > + {entry.text} + </a> + <TocList entries={entry.children} nested /> + </li> + ))} + </ul> + ); +} + +/** + * The rail's contents, shared by the sticky sidebar and the mobile drawer. + * + * The current page's sections are the only ones expanded — every page's + * headings at once would bury the entries that let a reader leave the + * page they are on. + * + * Sizing is the caller's: the page list never shrinks, and the expanded + * sections scroll within whatever height is left. So everything shows when it + * fits, and when it does not the page list stays reachable while the sections + * give up the space. + */ +function DocsNav({ + activePath, + toc, + className, +}: { + activePath: string; + toc: TocEntry[]; + className?: string; +}) { + return ( + <nav aria-label="Documentation" className={className}> + <ul className="flex min-h-0 flex-col gap-1"> + {DOCS_PAGES.map((page) => { + const active = page.path === activePath; + return ( + <li key={page.path} className={active ? "flex min-h-0 flex-col" : "shrink-0"}> + <a + href={page.path} + aria-current={active ? "page" : undefined} + className={`block shrink-0 py-1 font-display text-sm ${ + active ? ACCENT_TEXT_CLASS : MUTED_ACCENT_LINK_CLASS + }`} + > + {page.label} + </a> + {active && toc.length > 0 ? ( + <div className={`min-h-0 overflow-y-auto pb-2 ${TOC_INDENT_CLASS}`}> + <TocList entries={toc} /> + </div> + ) : null} + </li> + ); + })} + </ul> + </nav> + ); +} + +/** One end of the prev/next pair, or nothing when the rail has no neighbor. */ +function NeighborLink({ page, rel }: { page: DocsPage | undefined; rel: "prev" | "next" }) { + if (!page) return <span />; + return ( + <a + href={page.path} + rel={rel} + className={`group flex flex-col gap-1 ${rel === "next" ? "text-right" : ""}`} + > + <span className="text-xs uppercase tracking-wide opacity-50"> + {rel === "prev" ? "Previous" : "Next"} + </span> + <span className={`font-display group-hover:underline ${ACCENT_TEXT_CLASS}`}>{page.label}</span> + </a> + ); +} + +export default function DocsLayout({ + activePath, + title, + intro, + toc, + children, +}: { + activePath: string; + /** Defaults to this page's rail label. */ + title?: string; + intro?: ReactNode; + toc: TocEntry[]; + children: ReactNode; +}) { + // These pages are long-form reading, so they follow the reader's theme + // rather than the site's black (docs/specs/website-docs.md). + useRestoredTheme(DOCS_THEME_ID); + useEffect(() => { + document.body.classList.add(THEMED_BODY_CLASS); + return () => document.body.classList.remove(THEMED_BODY_CLASS); + }, []); + + // Links follow the picked theme's own accent rather than a per-kind default + // (website/src/lib/docs-accent.ts). Left alone, `--docs-accent` keeps the + // value index.css gives it, so a reader with no JS still gets a legible one. + useEffect(() => { + const paint = () => { + // The applied snapshot, not the stored id: `getActiveThemeId` already + // falls back to the first bundled theme, so a reader without storage + // would get the page painted in one theme and its links derived from + // another's accent the moment those two stop coinciding. + const theme = getAppliedThemeSnapshot()?.theme; + const background = theme?.vars?.["--vscode-editor-background"]; + const accent = theme?.accent; + if (!theme || !accent || !background) return; + const link = docsAccentFor(accent, background); + if (link) document.body.style.setProperty("--docs-accent", link); + }; + paint(); + return subscribeToActiveTheme(paint); + }, []); + + const [navOpen, setNavOpen] = useState(false); + useEffect(() => { + if (!navOpen) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setNavOpen(false); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [navOpen]); + + const { current, prev, next } = docsRailPosition(activePath); + // Three of five pages name themselves exactly as the rail does; the two that + // differ pass their own, so the rail label stays the one owner of the rest. + const heading = title ?? current?.label ?? ""; + + return ( + <> + <SiteHeader activePath={activePath} style={DOCS_HEADER_STYLE} /> + + <div className="min-h-screen bg-[var(--color-bg)] pt-16 pb-16 text-[var(--color-text)] md:pt-20"> + {/* Narrow screens get the rail on demand: the docs are a small part of + a phone visit, and the page list plus a page's sections above every + article would bury the article. */} + <div + className="sticky top-16 z-10 border-b border-[var(--color-text)]/15 md:top-20 lg:hidden" + style={DOCS_HEADER_STYLE} + > + <div className="flex items-center gap-2 pr-3 md:pr-5"> + <button + type="button" + aria-expanded={navOpen} + aria-controls="docs-nav-drawer" + onClick={() => setNavOpen((open) => !open)} + className="flex min-w-0 flex-1 items-center gap-2 px-4 py-3 text-left text-sm md:px-6" + > + {navOpen ? <XIcon size={16} weight="bold" /> : <ListIcon size={16} weight="bold" />} + <span className="font-display opacity-70">Docs</span> + {current ? ( + <> + <span aria-hidden="true" className="opacity-30">/</span> + <span className={`font-display ${ACCENT_TEXT_CLASS}`}>{current.label}</span> + </> + ) : null} + </button> + <DocsThemeControl variant="inline" /> + </div> + {navOpen ? ( + <div + id="docs-nav-drawer" + className="max-h-[70dvh] overflow-y-auto border-t border-[var(--color-text)]/15 px-4 py-4 md:px-6" + // A section link is a same-document hash, so nothing navigates + // and the drawer would sit over the section just jumped to. + onClick={() => setNavOpen(false)} + > + <DocsNav activePath={activePath} toc={toc} /> + </div> + ) : null} + </div> + + <div className="mx-auto max-w-6xl px-4 pt-8 md:px-6"> + <div className="grid gap-10 lg:grid-cols-[14rem_1fr] lg:gap-14"> + <aside className="hidden lg:block" aria-hidden={navOpen ? true : undefined}> + {/* Sticky and height-bounded so the sections below can scroll + while the page list stays put. */} + <DocsNav + activePath={activePath} + toc={toc} + className="sticky top-28 flex max-h-[calc(100dvh-9rem)] flex-col" + /> + </aside> + + <div className="min-w-0"> + <h1 className="mb-2 font-display text-[clamp(1.75rem,3vw+0.5rem,2.5rem)]">{heading}</h1> + {intro && <div className="mb-8 text-lg opacity-70">{intro}</div>} + + <main>{children}</main> + + {prev || next ? ( + <nav + aria-label="Previous and next page" + className="mt-16 grid grid-cols-2 gap-6 border-t border-[var(--color-text)]/20 pt-8 text-sm" + > + <NeighborLink page={prev} rel="prev" /> + <NeighborLink page={next} rel="next" /> + </nav> + ) : null} + + <footer className="mt-10 text-sm opacity-60"> + <a + href="https://github.com/diffplug/dormouse/issues" + className="hover:underline" + target="_blank" + rel="noopener noreferrer" + > + Report an issue + </a> + </footer> + </div> + </div> + </div> + </div> + + <DocsThemeControl /> + </> + ); +} diff --git a/website/src/components/DocsThemeControl.test.tsx b/website/src/components/DocsThemeControl.test.tsx new file mode 100644 index 000000000..0a6421dc4 --- /dev/null +++ b/website/src/components/DocsThemeControl.test.tsx @@ -0,0 +1,74 @@ +/** + * @vitest-environment jsdom + */ +import { act } from "react"; +import { hydrateRoot, type Root } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@phosphor-icons/react", () => ({ XIcon: () => null })); +vi.mock("dormouse-lib/components/ThemePicker", () => ({ + ThemePicker: () => <button type="button">Theme picker</button>, +})); + +import DocsThemeControl from "./DocsThemeControl"; +import { dismissThemePrompt } from "../lib/docs-theme"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; + localStorage.clear(); + vi.restoreAllMocks(); +}); + +function prerender(): string { + // The real prerender runs in Node, which has no localStorage at all. jsdom + // always provides one, so without this the helper never reproduces the + // disagreement between the prerendered markup and the first client render. + vi.stubGlobal("localStorage", undefined); + try { + return renderToString(<DocsThemeControl />); + } finally { + vi.unstubAllGlobals(); + } +} + +async function hydrate(markup: string): Promise<HTMLDivElement> { + container = document.createElement("div"); + container.innerHTML = markup; + document.body.appendChild(container); + await act(async () => { + root = hydrateRoot(container!, <DocsThemeControl />); + }); + return container; +} + +describe("DocsThemeControl hydration", () => { + it("does not flash or mismatch a prompt the reader already dismissed", async () => { + dismissThemePrompt(); + const markup = prerender(); + expect(markup).not.toContain('role="status"'); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const hydrated = await hydrate(markup); + + expect(hydrated.querySelector('[role="status"]')).toBeNull(); + expect(consoleError.mock.calls.flat().join(" ")).not.toMatch(/hydration/i); + }); + + it("reveals the prompt after hydration for a new reader", async () => { + const markup = prerender(); + expect(markup).not.toContain('role="status"'); + + const hydrated = await hydrate(markup); + + expect(hydrated.querySelector('[role="status"]')?.textContent).toContain("Don't like the colors?"); + }); +}); diff --git a/website/src/components/DocsThemeControl.tsx b/website/src/components/DocsThemeControl.tsx new file mode 100644 index 000000000..dbb4d8214 --- /dev/null +++ b/website/src/components/DocsThemeControl.tsx @@ -0,0 +1,110 @@ +/** + * The docs pages' theme picker, and the prompt that points a reader at it. + * + * The picker is the same `compact` ThemePicker the Pocket playground pages + * use, for the same reason: these pages have no baseboard, so there is no + * Settings dialog to put it in (docs/specs/theme.md -> Where the user picks a + * theme). + * + * Two placements, because the docs have two shapes. Wide screens float it + * bottom right, clear of the rail, opening upward. Narrow screens have a + * sticky bar already naming the section, so it sits at the end of that line + * and opens downward rather than adding a second floating control to a small + * viewport. + */ +import { useEffect, useState } from "react"; +import { XIcon } from "@phosphor-icons/react"; +import { ThemePicker } from "dormouse-lib/components/ThemePicker"; +import { dismissThemePrompt, isThemePromptDismissed } from "../lib/docs-theme"; + +/** + * Both floating panels sit over the page's own themed background, so they take + * the picked theme's widget colors. Literal fallbacks rather than the site + * palette: the control is pinned to the viewport and must stay legible in the + * moment before a theme is applied. + */ +const PANEL_STYLE: React.CSSProperties = { + borderColor: "var(--vscode-panel-border, rgba(255,255,255,0.2))", + backgroundColor: "var(--vscode-editorWidget-background, #1e1e1e)", + color: "var(--vscode-editor-foreground, #d4d4d4)", +}; + +export default function DocsThemeControl({ + variant = "floating", +}: { + variant?: "floating" | "inline"; +}) { + // Prerender and the first client render must agree without consulting + // browser-only storage. Unknown stays hidden; after hydration, only a reader + // who has not answered sees the prompt, so a dismissed prompt never flashes. + const [dismissed, setDismissed] = useState<boolean | null>(null); + useEffect(() => setDismissed(isThemePromptDismissed()), []); + + const dismiss = () => { + dismissThemePrompt(); + setDismissed(true); + }; + + const inline = variant === "inline"; + + const prompt = + dismissed === false ? ( + <div + role="status" + // Explicit width when inline: an absolutely-positioned box shrinks to + // fit its containing block, which here is the picker, so the prose + // would wrap one word per line. + className={`rounded-lg border py-2 pl-3 pr-8 text-sm leading-snug shadow-2xl ${ + inline ? "absolute right-0 top-full z-20 mt-2 w-[15rem]" : "relative max-w-[15rem]" + }`} + style={PANEL_STYLE} + > + Don't like the colors? Pick a theme. + {/* Closing counts as answering the prompt, so it does not return on + the next page: a reader who declined has still seen the offer. */} + <button + type="button" + aria-label="Dismiss theme prompt" + onClick={dismiss} + className="absolute right-1.5 top-1.5 rounded p-1 opacity-50 hover:opacity-100" + > + <XIcon size={12} weight="bold" /> + </button> + {/* Points at the picker, which is below on wide screens and above in + the bar. */} + <span + aria-hidden="true" + className={`absolute right-6 size-2 rotate-45 ${ + inline ? "-top-1 border-l border-t" : "-bottom-1 border-b border-r" + }`} + style={PANEL_STYLE} + /> + </div> + ) : null; + + const picker = ( + <div className="rounded border px-1.5 py-1 shadow-2xl" style={PANEL_STYLE}> + <ThemePicker + variant="compact" + menuSide={inline ? "below" : "above"} + onPick={dismiss} + /> + </div> + ); + + if (inline) { + return ( + <div className="relative shrink-0 print:hidden"> + {picker} + {prompt} + </div> + ); + } + + return ( + <div className="fixed bottom-4 right-4 z-40 hidden flex-col items-end gap-2 print:hidden lg:flex"> + {prompt} + {picker} + </div> + ); +} diff --git a/website/src/components/DorCommandReference.tsx b/website/src/components/DorCommandReference.tsx new file mode 100644 index 000000000..61f69d4c0 --- /dev/null +++ b/website/src/components/DorCommandReference.tsx @@ -0,0 +1,103 @@ +/** + * One `dor` command section on /docs/dor. + * + * Renders the semantic nodes the help parser produced, and keeps the original + * help text available byte for byte in a collapsed disclosure — the parser is + * deliberately shallow, so the exact source is always one click away. + * + * See docs/specs/website-docs.md -> /docs/dor reference. + */ + +import { AnchoredHeading } from "./MarkdownDocument"; +import { ACCENT_TEXT_CLASS, PRE_CLASS, TABLE_CLASS, TABLE_ROW_CLASS, TABLE_WRAP_CLASS } from "./docs-tokens"; + +export type DefinitionGroup = { label: string; rows: { term: string; description: string }[] }; +export type LabelledBlock = { label: string; body: string }; + +export type CommandSection = { + id: string; + title: string; + invocation: string; + usage: string[]; + prose: string[]; + definitions: DefinitionGroup[]; + blocks: LabelledBlock[]; + raw: string; +}; + +/** + * `depth` keeps the document outline agreeing with the table of contents. The + * eleven subcommands sit under the page's `Commands` heading, so they render as + * `h3`; the root `dor` section is that heading's peer and keeps the default. + * + * **Must** push a section's own labels one level below its heading. Left fixed + * at `h3` while the commands moved to `h3`, every `FLAGS` and `Text output:` + * became a peer of the command it belongs to, flattening eleven commands and + * their labels into one run for a reader navigating by heading. Pinned by + * `website/src/pages/DorDocs.test.tsx`. + */ +export default function DorCommandReference({ + section, + depth = 2, +}: { + section: CommandSection; + depth?: number; +}) { + const LabelTag = `h${Math.min(depth + 1, 6)}` as "h3"; + return ( + <section className="mb-14"> + <AnchoredHeading id={section.id} depth={depth} spacing="mb-1">{section.title}</AnchoredHeading> + <p className="mb-4 font-mono text-sm opacity-60">{section.invocation}</p> + + {section.usage.length > 0 && ( + <pre className={`${PRE_CLASS} mb-4`}> + <code>{section.usage.join("\n")}</code> + </pre> + )} + + {section.prose.map((paragraph, i) => ( + <p key={i} className="mb-4 text-lg leading-relaxed opacity-80"> + {paragraph} + </p> + ))} + + {section.definitions.map((group, i) => ( + <div key={i} className="mb-6"> + <LabelTag className="mb-2 font-display text-sm uppercase tracking-wide opacity-50">{group.label}</LabelTag> + <div className={TABLE_WRAP_CLASS}> + <table className={TABLE_CLASS}> + <tbody> + {group.rows.map((row, r) => ( + <tr key={r} className={TABLE_ROW_CLASS}> + <td className={`py-2 pr-4 align-top font-mono text-sm whitespace-nowrap ${ACCENT_TEXT_CLASS}`}> + {row.term} + </td> + <td className="py-2 align-top opacity-80">{row.description}</td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + ))} + + {section.blocks.map((block, i) => ( + <div key={i} className="mb-4"> + <LabelTag className="mb-2 font-display text-sm uppercase tracking-wide opacity-50">{block.label}</LabelTag> + <pre className={PRE_CLASS}> + <code>{block.body}</code> + </pre> + </div> + ))} + + <details className="mt-4 rounded-lg border border-[var(--color-text)]/15"> + <summary className="cursor-pointer px-4 py-2 text-sm opacity-70 hover:opacity-100"> + Exact <code className="font-mono">{section.invocation}</code> output + </summary> + <pre className="overflow-x-auto border-t border-[var(--color-text)]/15 p-4 font-mono text-sm"> + <code>{section.raw}</code> + </pre> + </details> + </section> + ); +} diff --git a/website/src/components/MarkdownDocument.test.tsx b/website/src/components/MarkdownDocument.test.tsx new file mode 100644 index 000000000..4c1c6ca63 --- /dev/null +++ b/website/src/components/MarkdownDocument.test.tsx @@ -0,0 +1,58 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import MarkdownDocument, { type BlockNode } from "./MarkdownDocument"; + +describe("MarkdownDocument headings", () => { + it("preserves every supported Markdown heading depth", () => { + for (let depth = 1; depth <= 6; depth += 1) { + const blocks: BlockNode[] = [{ + type: "heading", + depth, + id: `depth-${depth}`, + text: `Depth ${depth}`, + children: [{ type: "text", value: `Depth ${depth}` }], + }]; + const markup = renderToStaticMarkup(<MarkdownDocument blocks={blocks} />); + + expect(markup).toMatch(new RegExp(`^<h${depth}\\b`)); + expect(markup).toContain(`</h${depth}>`); + } + }); +}); + +describe("inline code wrapping", () => { + const render = (value: string) => + renderToStaticMarkup( + <MarkdownDocument + blocks={[{ type: "paragraph", children: [{ type: "code", value }] }]} + />, + ); + + it("offers a break after every path separator", () => { + // The 47-character path below is one unbreakable word to the line breaker, + // and on a phone it pushed the whole article sideways rather than just + // itself. + const markup = render("~/Library/LaunchAgents/sh.dormouse.server.plist"); + expect(markup).toContain("~/<wbr/>Library/<wbr/>LaunchAgents/<wbr/>sh.<wbr/>dormouse."); + }); + + it("leaves the copied text exactly as authored", () => { + // <wbr> contributes nothing to textContent, so selecting the span still + // yields a path a reader can paste into a shell. + const value = "~/.config/systemd/user/dormouse-server.service"; + const text = render(value).replace(/<[^>]*>/g, ""); + expect(text).toBe(value); + }); + + it("never offers a break inside a run of separators", () => { + // `--watch` parted at its dashes reads as a hyphenated word break, and + // `https://` parted at its slashes reads as a typo. + expect(render("--watch")).toContain(">--<wbr/>watch<"); + expect(render("https://host")).toContain(">https://<wbr/>host<"); + }); + + it("carries a backstop for a token with no separator to break on", () => { + // A long hash offers nowhere to break, so the class has to allow it. + expect(render("abcdef0123456789abcdef0123456789")).toMatch(/class="[^"]*break-words/); + }); +}); diff --git a/website/src/components/MarkdownDocument.tsx b/website/src/components/MarkdownDocument.tsx new file mode 100644 index 000000000..032dd9c38 --- /dev/null +++ b/website/src/components/MarkdownDocument.tsx @@ -0,0 +1,282 @@ +/** + * Renders the block tree produced by `website/scripts/docs-parser.js`. + * + * The parser guarantees the tree contains only the supported subset, and that + * any `image` node came either from Markdown or from the narrow `<img>` + * allowlist — so nothing here needs to sanitize, and no HTML string is ever + * injected (`dangerouslySetInnerHTML` is deliberately absent). + * + * See docs/specs/website-docs.md -> Markdown rendering contract. + */ +import { Fragment, type ReactNode } from "react"; +import { + CODE_CLASS, + LINK_CLASS, + PRE_CLASS, + SCROLL_MT_CLASS, + TABLE_CLASS, + TABLE_ROW_CLASS, + TABLE_WRAP_CLASS, +} from "./docs-tokens"; + +export type InlineNode = + | { type: "text"; value: string } + | { type: "code"; value: string } + | { type: "image"; src: string; alt?: string; width?: string; height?: string; title?: string; standalone?: boolean } + | { type: "link"; href: string; title?: string; children: InlineNode[] } + | { type: "strong"; children: InlineNode[] } + | { type: "em"; children: InlineNode[] }; + +export type BlockNode = + | { type: "heading"; depth: number; id: string; text: string; children: InlineNode[] } + | { type: "paragraph"; tight?: boolean; children: InlineNode[] } + | { type: "code"; lang: string | null; value: string } + | { type: "list"; ordered: boolean; items: { type: "listItem"; children: BlockNode[] }[] } + | { type: "table"; align: (string | null)[]; header: InlineNode[][]; rows: InlineNode[][][] } + | { type: "blockquote"; children: BlockNode[] } + | { type: "thematicBreak" }; + +/** + * Same-site links stay in the tab; anything else opens safely in a new one. + * + * A scheme is the whole test because same-site links reach here root-relative: + * the generator localizes the absolute URLs the canonical sources are required + * to use (`localizeSiteLinks` in website/scripts/generate-docs.js). + * + * Spelled out rather than importing `hasScheme` from the parser that owns the + * policy: that module is Node-side build code, and pulling it in would ship the + * whole Markdown parser to the browser to share one regex. + */ +function isExternal(href: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(href); +} + +/** + * An inline code span, wrappable at the separators inside it. + * + * A path like `~/Library/LaunchAgents/sh.dormouse.server.plist` is one + * unbreakable word to the line breaker, so on a phone it pushed the whole page + * wider than the viewport — the article scrolled sideways, not just the token. + * + * `<wbr>` offers a break after each *run* of `/`, `.`, `-` and `_` rather than + * letting the text break anywhere, so a path splits where a reader expects and + * not mid-segment — and never inside a run, which would part `--watch` at its + * dashes or `https://` at its slashes. It contributes nothing to `textContent`, + * so copying the span still yields the original string. `CODE_CLASS` carries + * `break-words` as the backstop for a token with no separators at all, such as + * a long hash. + * + * Pinned by `website/src/components/MarkdownDocument.test.tsx`. + */ +function CodeSpan({ value }: { value: string }) { + const parts = value.split(/(?<=[/._-])(?![/._-])/); + return ( + <code className={CODE_CLASS}> + {parts.map((part, i) => ( + <Fragment key={i}> + {part} + {i < parts.length - 1 ? <wbr /> : null} + </Fragment> + ))} + </code> + ); +} + +function Inline({ nodes }: { nodes: InlineNode[] }): ReactNode { + return nodes.map((node, i) => { + switch (node.type) { + case "text": + return <Fragment key={i}>{node.value}</Fragment>; + case "code": + return <CodeSpan key={i} value={node.value} />; + case "strong": + return <strong key={i} className="font-semibold"><Inline nodes={node.children} /></strong>; + case "em": + return <em key={i} className="italic"><Inline nodes={node.children} /></em>; + case "image": + return ( + <img + key={i} + src={node.src} + alt={node.alt ?? ""} + title={node.title} + width={node.width} + height={node.height} + // Standalone art is capped to the column so nothing forces a + // horizontal scroll on mobile; inline icons keep intrinsic size. + className={node.standalone ? "block h-auto max-w-full rounded-lg my-6" : "inline-block align-text-bottom"} + loading="lazy" + /> + ); + case "link": { + const external = isExternal(node.href); + return ( + <a + key={i} + href={node.href} + title={node.title} + className={LINK_CLASS} + {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})} + > + <Inline nodes={node.children} /> + </a> + ); + } + default: + return null; + } + }); +} + +const HEADING_BASE = `font-display ${SCROLL_MT_CLASS}`; +/** Size per depth; the shared base is applied alongside. */ +const HEADING_TEXT: Record<number, string> = { + 1: "text-3xl", + 2: "text-2xl", + 3: "text-xl", + 4: "text-lg", + 5: "text-base", + 6: "text-base", +}; +/** Flow spacing per depth, for a heading rendered inline in a document. A + * caller that supplies its own section spacing overrides it. */ +const HEADING_FLOW: Record<number, string> = { + 1: "mt-12 mb-4", + 2: "mt-12 mb-4", + 3: "mt-8 mb-3", + 4: "mt-6 mb-2", + 5: "mt-4 mb-2", + 6: "mt-4 mb-2", +}; + +/** + * A heading that links to itself. + * + * Every `h1`–`h6` inside a Markdown document comes through here, so the anchor + * affordance and the `scroll-mt` that keeps a jumped-to heading clear of the + * sticky header have one owner. `spacing` replaces the depth's flow margins + * for a caller whose surrounding section already spaces it. + */ +export function AnchoredHeading({ + id, + depth = 2, + spacing, + children, +}: { + id: string; + depth?: number; + spacing?: string; + children: ReactNode; +}) { + const level = depth in HEADING_TEXT ? depth : 6; + const Tag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; + return ( + <Tag id={id} className={`${HEADING_BASE} ${HEADING_TEXT[level]} ${spacing ?? HEADING_FLOW[level]}`}> + <a href={`#${id}`} className="no-underline hover:underline underline-offset-4"> + {children} + </a> + </Tag> + ); +} + +function Block({ node }: { node: BlockNode }): ReactNode { + switch (node.type) { + case "heading": + return ( + <AnchoredHeading id={node.id} depth={node.depth}> + <Inline nodes={node.children} /> + </AnchoredHeading> + ); + case "paragraph": + return ( + <p className={node.tight ? "leading-relaxed" : "text-lg leading-relaxed opacity-80 mb-4"}> + <Inline nodes={node.children} /> + </p> + ); + case "code": + return ( + <pre className={`${PRE_CLASS} mb-4`}> + <code>{node.value}</code> + </pre> + ); + case "list": { + const Tag = node.ordered ? "ol" : "ul"; + return ( + <Tag className={`mb-4 space-y-2 pl-6 text-lg opacity-80 ${node.ordered ? "list-decimal" : "list-disc"}`}> + {node.items.map((item, i) => ( + <li key={i} className="leading-relaxed"> + <Blocks nodes={item.children} /> + </li> + ))} + </Tag> + ); + } + case "table": + return ( + <div className={`mb-6 ${TABLE_WRAP_CLASS}`}> + <table className={TABLE_CLASS}> + <thead> + <tr className="border-b border-[var(--color-text)]/25"> + {node.header.map((cell, i) => ( + <th key={i} className="py-2 pr-4 font-display font-normal whitespace-nowrap"> + <Inline nodes={cell} /> + </th> + ))} + </tr> + </thead> + <tbody> + {node.rows.map((row, r) => ( + <tr key={r} className={TABLE_ROW_CLASS}> + {row.map((cell, c) => ( + <td key={c} className="py-2 pr-4 align-top opacity-80"> + <Inline nodes={cell} /> + </td> + ))} + </tr> + ))} + </tbody> + </table> + </div> + ); + case "blockquote": + return ( + <blockquote className="mb-4 border-l-2 border-[var(--docs-accent)]/50 pl-4 opacity-80"> + <Blocks nodes={node.children} /> + </blockquote> + ); + case "thematicBreak": + return <hr className="my-10 border-[var(--color-text)]/15" />; + default: + return null; + } +} + +function Blocks({ nodes }: { nodes: BlockNode[] }): ReactNode { + return nodes.map((node, i) => <Block key={i} node={node} />); +} + +/** + * Render a parsed document. + * + * `renderAfterHeading` lets a page splice its own content in after a heading + * (the agent-skill page uses it for CLI reference links) without the page + * having to slice the block list up and reassemble the document itself. + */ +export default function MarkdownDocument({ + blocks, + renderAfterHeading, +}: { + blocks: BlockNode[]; + renderAfterHeading?: (heading: Extract<BlockNode, { type: "heading" }>) => ReactNode; +}) { + return ( + <> + {blocks.map((node, i) => ( + <Fragment key={i}> + <Block node={node} /> + {node.type === "heading" && renderAfterHeading?.(node)} + </Fragment> + ))} + </> + ); +} diff --git a/website/src/components/NotifySignupForm.tsx b/website/src/components/NotifySignupForm.tsx index 6a4bf0da0..d73e140bf 100644 --- a/website/src/components/NotifySignupForm.tsx +++ b/website/src/components/NotifySignupForm.tsx @@ -1,5 +1,6 @@ import { useState, type FormEvent } from "react"; import { CircleNotchIcon } from "@phosphor-icons/react"; +import { SITE_LINK_CLASS } from "./site-tokens"; const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; @@ -79,7 +80,7 @@ export function NotifySignupForm() { This signs you up for my personal devlog{" "} <a href="https://nedshed.dev" - className="text-[var(--color-caramel)] underline-offset-2 hover:underline" + className={SITE_LINK_CLASS} > nedshed.dev </a>{" "} diff --git a/website/src/components/SiteHeader.tsx b/website/src/components/SiteHeader.tsx index 3417aaaff..505f7e012 100644 --- a/website/src/components/SiteHeader.tsx +++ b/website/src/components/SiteHeader.tsx @@ -1,13 +1,31 @@ import { forwardRef } from "react"; +import { DOCS_PAGES } from "../lib/docs-pages"; export const STATIC_PAGE_HEADER_STYLE: React.CSSProperties = { background: "rgba(10, 10, 10, 0.85)", backdropFilter: "blur(12px)", }; -const NAV_LINKS: readonly { href: string; label: string; external?: boolean; hideOnMobile?: boolean }[] = [ +const NAV_LINKS: readonly { + href: string; + label: string; + external?: boolean; + hideOnMobile?: boolean; + /** Paths this entry highlights for, when the href itself is never a page. */ + covers?: readonly string[]; +}[] = [ { href: "/playground", label: "Playground" }, { href: "/#download", label: "Download", hideOnMobile: true }, + // Desktop only: on a phone the docs are reached from the homepage's own + // links, and the four marketing destinations earn the narrow bar first. + // `/docs` only ever redirects, so it can never equal the current path — it + // highlights for the pages it leads to instead. + { + href: "/docs", + label: "Docs", + hideOnMobile: true, + covers: DOCS_PAGES.map((page) => page.path), + }, { href: "https://github.com/diffplug/dormouse", label: "GitHub", external: true }, ]; @@ -95,8 +113,8 @@ const SiteHeader = forwardRef<HTMLElement, SiteHeaderProps>( <div className="ml-auto flex min-w-0 items-center gap-3 md:gap-8"> {controls ? <div className="min-w-0">{controls}</div> : null} <nav className="flex shrink-0 items-center gap-5 md:gap-10"> - {navLinks.map(({ href, label, external, hideOnMobile }) => { - const isActive = activePath === href; + {navLinks.map(({ href, label, external, hideOnMobile, covers }) => { + const isActive = activePath === href || (activePath !== undefined && (covers?.includes(activePath) ?? false)); return ( <a key={href} diff --git a/website/src/components/docs-tokens.ts b/website/src/components/docs-tokens.ts new file mode 100644 index 000000000..94d771ea8 --- /dev/null +++ b/website/src/components/docs-tokens.ts @@ -0,0 +1,68 @@ +/** + * Shared class strings for the docs pages. + * + * One definition each, so the link, inline code, code-block, and table chrome + * render identically across every reference page instead of drifting into + * near-copies. + */ + +/** + * Prose links take the active theme's own accent, corrected for contrast, not + * the site's caramel. + * + * Caramel is 5.56:1 on the site's black but 3.43–3.78:1 on every bundled light + * theme, so a reader who picks one would drop the whole page's links below + * WCAG AA. `--vscode-textLink-foreground` cannot stand in: no bundled theme + * defines it, so it resolves to one registry default per theme kind. Brand + * caramel stays everywhere the reader cannot retheme it — the wordmark, the + * site header, the homepage — and is the fallback here for the moment before a + * theme is applied. + * + * `--docs-accent` holds that fallback chain, defined once in + * website/src/index.css; DocsLayout's effect overwrites it with the corrected + * accent once a theme resolves (website/src/lib/docs-accent.ts). + */ +export const LINK_CLASS = "text-[var(--docs-accent)] underline-offset-2 hover:underline"; + +/** The same accent for callers composing their own class. Written out rather + * than interpolated: Tailwind scans source statically. */ +export const ACCENT_TEXT_CLASS = "text-[var(--docs-accent)]"; +export const ACCENT_HOVER_TEXT_CLASS = "hover:text-[var(--docs-accent)]"; +export const ACCENT_HOVER_BORDER_CLASS = "hover:border-[var(--docs-accent)]"; + +/** + * How far a jumped-to anchor clears the chrome above it. + * + * Below `lg` the docs carry a sticky nav bar under the fixed site header as + * well; at `lg` the bar is gone and only the header remains. **Must** stay + * ahead of both, or a tapped rail entry lands the heading underneath them. + * + * Measured: the bar is 45px and the header 64px, 80px from `md` up (it has no + * `lg` step). So the two steps that clear both — 112px and 128px — have 3px in + * hand, while `lg` clears the header alone by 16px. Growing the bar's padding + * or its type needs the two tight steps raised with it. + */ +export const SCROLL_MT_CLASS = "scroll-mt-28 md:scroll-mt-32 lg:scroll-mt-24"; + +/** A navigation link that sits back until hovered, as the rail's do. */ +export const MUTED_ACCENT_LINK_CLASS = `opacity-70 hover:opacity-100 ${ACCENT_HOVER_TEXT_CLASS}`; + +/** The rule down the left of a nested list, indenting what hangs off it. */ +export const TOC_INDENT_CLASS = "border-l border-[var(--color-text)]/15 pl-3"; + +/** Inline `code` spans. `break-words` is the backstop under CodeSpan's + * separator breaks: a token with no separator at all must still not push the + * page wider than the viewport. */ +export const CODE_CLASS = + "text-[0.9em] bg-[var(--color-text)]/15 px-1.5 py-0.5 rounded font-mono break-words"; + +/** Fenced code blocks and other monospace panels. */ +export const PRE_CLASS = + "overflow-x-auto rounded-lg border border-[var(--color-text)]/15 bg-[var(--color-text)]/[0.04] p-4 font-mono text-sm"; + +/** Tables: the scroll container, the table itself, and a body row's rule. A + * table wider than the column scrolls inside its own box rather than pushing + * the page sideways on a phone. */ +export const TABLE_WRAP_CLASS = "overflow-x-auto"; +export const TABLE_CLASS = "w-full border-collapse text-left"; +export const TABLE_ROW_CLASS = "border-b border-[var(--color-text)]/10"; diff --git a/website/src/components/site-tokens.ts b/website/src/components/site-tokens.ts new file mode 100644 index 000000000..aee014b72 --- /dev/null +++ b/website/src/components/site-tokens.ts @@ -0,0 +1,15 @@ +/** + * Shared class strings for the site's own pages. + * + * The counterpart to `docs-tokens.ts`: those follow the reader's picked theme, + * because the reference pages are long-form reading. These are the brand + * palette, which the marketing pages are locked to. + */ + +/** Links in body copy. Body copy is dimmed with a text-color alpha + * (`text-[…]/70`) rather than `opacity`, which would composite the link along + * with the surrounding text and dim it too. */ +export const SITE_LINK_CLASS = "text-[var(--color-caramel)] underline-offset-2 hover:underline"; + +/** Inline `code` spans in body copy. */ +export const SITE_CODE_CLASS = "text-sm bg-[var(--color-text)]/20 px-1.5 py-0.5 rounded"; diff --git a/website/src/index.css b/website/src/index.css index d7a0cb758..af9819e97 100644 --- a/website/src/index.css +++ b/website/src/index.css @@ -9,6 +9,20 @@ --color-caramel: #b47624; } +/* The reference pages' accent, one definition for the four class strings in + website/src/components/docs-tokens.ts that read it (that file carries the + contrast reasoning). + + On `body`, not `:root`: `applyTheme` writes --vscode-* onto body.style, and + a var() in a declaration on html cannot read a property set on its child, so + the chain would silently collapse to the caramel fallback on every theme — + the exact contrast failure the fallback exists to avoid. Unscoped by + `.docs-themed` so it still resolves during prerender, before DocsLayout's + effect adds that class. */ +body { + --docs-accent: var(--vscode-textLink-foreground, var(--color-caramel)); +} + html { background: var(--color-bg); color: var(--color-text); @@ -37,6 +51,28 @@ html body { height: auto; } +/* Reference pages follow the reader's picked theme instead of the site's + black; the rest of the site is unaffected because only DocsLayout adds the + class (docs/specs/website-docs.md -> Reference page chrome). + + `applyTheme` writes --vscode-* onto body.style, so the remap has to live on + body too — html cannot read a custom property set on its child. That leaves + html painting the canvas its own black behind the page, which shows on + overscroll, so html gives the canvas up and lets body's background + propagate. */ +html:has(body.docs-themed) { + background: transparent; +} + +body.docs-themed { + --color-bg: var(--vscode-editor-background); + --color-text: var(--vscode-editor-foreground); + --color-surface: var(--vscode-editorWidget-background); + background: var(--color-bg); + color: var(--color-text); + min-height: 100dvh; +} + html body.pocket-marketing-body { overflow: auto; } diff --git a/website/src/lib/docs-accent.test.ts b/website/src/lib/docs-accent.test.ts new file mode 100644 index 000000000..042e9d8c3 --- /dev/null +++ b/website/src/lib/docs-accent.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { getBundledThemes } from "dormouse-lib/lib/themes"; +import { contrastRatio, docsAccentFor } from "./docs-accent"; + +const rgb = (hex: string): [number, number, number] => { + const h = hex.replace("#", ""); + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as [number, number, number]; +}; + +const themes = getBundledThemes().map((theme) => ({ + id: theme.id, + accent: theme.accent, + background: (theme.vars ?? {})["--vscode-editor-background"], +})); + +describe("docs link colour", () => { + it("has a theme to derive from at all", () => { + expect(themes.length).toBeGreaterThan(0); + for (const t of themes) { + expect(t.accent, `${t.id} accent`).toBeTruthy(); + expect(t.background, `${t.id} background`).toBeTruthy(); + } + }); + + it("clears WCAG AA on every bundled theme", () => { + // The raw accents do not: seven of eleven fall below 4.5:1 against their + // own background, which is why the correction exists. + for (const t of themes) { + const link = docsAccentFor(t.accent, t.background); + expect(link, t.id).not.toBeNull(); + expect(contrastRatio(rgb(link!), rgb(t.background)), `${t.id} (${link})`).toBeGreaterThanOrEqual(4.5); + } + }); + + it("actually varies by theme, not merely by light and dark", () => { + // The defect this replaced: `--vscode-textLink-foreground` resolves to one + // registry default per theme kind, so every dark theme shared a link + // colour. Two distinct values would mean we had reproduced that. + const distinct = new Set(themes.map((t) => docsAccentFor(t.accent, t.background))); + expect(distinct.size).toBeGreaterThan(2); + }); + + it("clears AA after rounding, not merely before it", () => { + // `toHex` rounds, so a candidate measured unrounded can clear 4.5 while the + // colour actually returned falls under it. This pair did, at 4.479:1 — and + // every bundled theme clears with room, so nothing else here would catch it. + const link = docsAccentFor("#007fd4", "#c1c1c1")!; + expect(contrastRatio(rgb(link), rgb("#c1c1c1"))).toBeGreaterThanOrEqual(4.5); + }); + + it("keeps an accent that already contrasts, rather than washing it out", () => { + // #99947c on #272822 is 4.87:1 already, so it should come back untouched. + expect(docsAccentFor("#99947c", "#272822")).toBe("#99947c"); + }); + + it("flattens alpha against the background before judging it", () => { + // Half-transparent white over black is grey, not white. + expect(docsAccentFor("#ffffff80", "#000000")).not.toBe("#ffffff80"); + expect(docsAccentFor("#ffffff80", "#000000")).toMatch(/^#[0-9a-f]{6}$/); + }); + + it("returns null on a colour it cannot read, leaving the CSS fallback", () => { + expect(docsAccentFor("var(--nope)", "#000000")).toBeNull(); + expect(docsAccentFor("#000000", "rgb(0 0 0)")).toBeNull(); + }); +}); diff --git a/website/src/lib/docs-accent.ts b/website/src/lib/docs-accent.ts new file mode 100644 index 000000000..814ededae --- /dev/null +++ b/website/src/lib/docs-accent.ts @@ -0,0 +1,96 @@ +/** + * The docs pages' link colour, derived from the theme the reader picked. + * + * **Never** take it from `--vscode-textLink-foreground`. No bundled theme + * defines `textLink.foreground`, so that variable always resolves to the + * colour registry's default for the theme's *kind* — one blue on every dark + * theme, one on every light one — and links looked identical whichever theme + * was chosen. Every theme does carry an `accent`, which is the colour a reader + * recognises it by. + * + * **Must** correct that accent for contrast before using it as body text. + * Taken raw, 7 of the 11 bundled accents fall below WCAG AA against their own + * background and four carry alpha, so using them directly would undo the + * contrast work that moved these links off brand caramel in the first place. + * + * Correction walks the accent toward whichever of white or black contrasts + * more with the background, stopping at the first step that clears the + * threshold, so a theme keeps as much of its accent as contrast allows. + * Pinned by + * `website/src/lib/docs-accent.test.ts`, which checks every bundled theme. + */ + +/** WCAG AA for body text. */ +const MIN_CONTRAST = 4.5; + +type Rgb = [number, number, number]; + +/** `#rgb`, `#rrggbb`, and `#rrggbbaa`; alpha separated out. */ +function parseHex(color: string): { rgb: Rgb; alpha: number } | null { + const hex = color.trim().replace(/^#/, ""); + // `#rgb` and `#rgba` both expand by doubling each digit. + const full = hex.length === 3 || hex.length === 4 ? [...hex].map((c) => c + c).join("") : hex; + if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(full)) return null; + const n = (i: number) => parseInt(full.slice(i, i + 2), 16); + return { rgb: [n(0), n(2), n(4)], alpha: full.length === 8 ? n(6) / 255 : 1 }; +} + +const toHex = ([r, g, b]: Rgb) => + `#${[r, g, b].map((v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0")).join("")}`; + +/** Rounded, because `toHex` rounds: an unrounded candidate can clear the + * threshold while the colour actually returned falls under it. Covers the + * alpha flatten too, which goes through here. */ +const mix = (a: Rgb, b: Rgb, t: number): Rgb => [ + Math.round(a[0] + (b[0] - a[0]) * t), + Math.round(a[1] + (b[1] - a[1]) * t), + Math.round(a[2] + (b[2] - a[2]) * t), +]; + +function luminance([r, g, b]: Rgb): number { + const channel = (v: number) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +export function contrastRatio(a: Rgb, b: Rgb): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +/** + * A link colour for `accent` that is legible on `background`. + * + * Returns `null` when either colour is unparseable, so the caller can leave + * the stylesheet's own fallback in place rather than guess. + */ +export function docsAccentFor( + accent: string, + background: string, + minContrast = MIN_CONTRAST, +): string | null { + const fg = parseHex(accent); + const bg = parseHex(background); + if (!fg || !bg) return null; + + // Alpha is against the page, so flatten before measuring anything. + const base = fg.alpha < 1 ? mix(bg.rgb, fg.rgb, fg.alpha) : fg.rgb; + if (contrastRatio(base, bg.rgb) >= minContrast) return toHex(base); + + // Whichever end contrasts more, measured rather than guessed from a + // luminance midpoint: that is not where the crossover sits, and a mid-tone + // background is neither light nor dark. + const white: Rgb = [255, 255, 255]; + const black: Rgb = [0, 0, 0]; + const toward: Rgb = contrastRatio(white, bg.rgb) >= contrastRatio(black, bg.rgb) ? white : black; + + // The last step is `toward` itself, so the best available colour is always + // among these — there is nothing left to fall back to. + for (let step = 1; step <= 20; step += 1) { + const candidate = mix(base, toward, step / 20); + if (contrastRatio(candidate, bg.rgb) >= minContrast) return toHex(candidate); + } + return toHex(toward); +} diff --git a/website/src/lib/docs-pages.ts b/website/src/lib/docs-pages.ts new file mode 100644 index 000000000..532204819 --- /dev/null +++ b/website/src/lib/docs-pages.ts @@ -0,0 +1,81 @@ +/** + * Every page in the docs section, in the order the left rail lists them. + * + * One owner for "which pages exist and how they are ordered". The route table, + * the prerender list, the rail, its prev/next links, and + * `scripts/public-docs-lint.mjs` all derive from this, because a page added to + * one of those and missed in another ships unreachable, unrendered, or + * unchecked. + * + * The changelog and the supply chain live here too. They are not generated + * from Markdown like the three references, but a reader meets them the same + * way — long-form material reached from the rail rather than from the + * marketing nav. + * + * **Must stay erasable-syntax TypeScript with no imports.** + * `scripts/public-docs-lint.mjs` imports this module directly and relies on + * Node's type stripping, which erases types but resolves nothing: an + * extensionless specifier, a path alias, or a browser-only import here fails + * `pnpm test` at its first step, far from whatever change caused it. + * + * See docs/specs/website-docs.md -> Reference page chrome. + */ +export type DocsPage = { + /** URL path; also the route pattern and the prerender entry. */ + path: string; + /** Route module, resolved against the app directory (`website/src`). */ + module: string; + /** How the left rail names it. */ + label: string; + /** + * The off-site documents required to link this page. + * + * The generated references are published where a reader may never reach the + * site — the guide is a Marketplace listing — so each names the documents + * that must offer a way in. Running a relay server is not part of installing + * an editor extension, so the guide carries no self-host obligation. + * + * Named for the obligation rather than for a state: every page here is + * published, routed, and prerendered. `checkRoutesToReferences` reads this. + */ + linkedFrom?: readonly ("guide" | "root-readme")[]; +}; + +const BOTH_READMES = ["guide", "root-readme"] as const; + +export const DOCS_PAGES: readonly DocsPage[] = [ + { path: "/changelog", module: "./pages/Changelog.tsx", label: "Changelog" }, + { path: "/supply-chain", module: "./pages/SupplyChain.tsx", label: "Supply chain" }, + { path: "/docs/self-host", module: "./pages/SelfHostDocs.tsx", label: "Self hosting", linkedFrom: ["root-readme"] }, + { path: "/docs/agent-skill", module: "./pages/AgentSkillDocs.tsx", label: "dor agent skill", linkedFrom: BOTH_READMES }, + { path: "/docs/dor", module: "./pages/DorDocs.tsx", label: "dor CLI reference", linkedFrom: BOTH_READMES }, +]; + +/** + * One heading in a page's table of contents, as the rail nests it. + * + * Owned here rather than by the component that renders it, because the rail is + * the only thing that consumes both this and the page list, while five + * unrelated producers satisfy it: `website/scripts/generate-docs.js` emits it + * for the three generated references, and the changelog and the supply chain + * derive it in their own page modules from the data they already render. + */ +export type TocEntry = { id: string; text: string; children: TocEntry[] }; + +/** + * Where `/docs` sends a reader. Changing this line changes where it lands; + * `website/public/_redirects` follows it, pinned by `checkDocsEntrypoint` + * (docs/specs/website-docs.md -> Reference page chrome). + */ +export const DOCS_DEFAULT_PATH = "/docs/agent-skill"; + +/** Where `path` sits in the rail, and what sits either side of it. */ +export function docsRailPosition(path: string): { + current?: DocsPage; + prev?: DocsPage; + next?: DocsPage; +} { + const i = DOCS_PAGES.findIndex((page) => page.path === path); + if (i === -1) return {}; + return { current: DOCS_PAGES[i], prev: DOCS_PAGES[i - 1], next: DOCS_PAGES[i + 1] }; +} diff --git a/website/src/lib/docs-rail.test.tsx b/website/src/lib/docs-rail.test.tsx new file mode 100644 index 000000000..00e8adfa0 --- /dev/null +++ b/website/src/lib/docs-rail.test.tsx @@ -0,0 +1,78 @@ +/** + * The rail's one contract, checked for every page in it. + * + * A `TocEntry` is a link to an anchor on the page it belongs to, so every id + * the rail names must be an id that page actually renders — nested entries + * included. The five pages produce their entries three different ways (a + * Markdown generator, a JSON changelog, a hand-written section list), which is + * exactly why the check belongs here once rather than in each page's own test. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { describe, expect, it } from "vitest"; +import { DOCS_PAGES, type TocEntry } from "./docs-pages"; + +import Changelog, { changelogToc } from "../pages/Changelog"; +import changelog from "../data/changelog.json"; +import SupplyChain, { SUPPLY_CHAIN_TOC } from "../pages/SupplyChain"; +import SelfHostDocs from "../pages/SelfHostDocs"; +import AgentSkillDocs from "../pages/AgentSkillDocs"; +import DorDocs from "../pages/DorDocs"; +import selfhost from "../data/docs.selfhost.json"; +import skill from "../data/docs.skill.json"; +import cli from "../data/docs.cli.json"; + +/** Every page in the rail, with the entries it hands the rail. */ +const PAGES: Record<string, { element: React.ReactElement; toc: TocEntry[] }> = { + "/changelog": { element: <Changelog />, toc: changelogToc(changelog.releases) }, + "/supply-chain": { element: <SupplyChain />, toc: SUPPLY_CHAIN_TOC }, + "/docs/self-host": { element: <SelfHostDocs />, toc: selfhost.toc }, + "/docs/agent-skill": { element: <AgentSkillDocs />, toc: skill.toc }, + "/docs/dor": { element: <DorDocs />, toc: cli.toc }, +}; + +const idsIn = (entries: TocEntry[]): string[] => + entries.flatMap((entry) => [entry.id, ...idsIn(entry.children)]); + +describe("every page in the rail", () => { + it("is covered by this test", () => { + // A page added to the rail without an entry here would go unchecked, and + // the loop below would pass by testing one fewer page. + expect(Object.keys(PAGES).sort()).toEqual(DOCS_PAGES.map((page) => page.path).sort()); + }); + + for (const page of DOCS_PAGES) { + it(`anchors every ${page.path} entry on an id the page renders`, () => { + const { element, toc } = PAGES[page.path]; + // MemoryRouter because a page may use <Link>; the docs pages do not, but + // the changelog does and the wrapper is harmless for the rest. + const markup = renderToStaticMarkup(<MemoryRouter>{element}</MemoryRouter>); + const rendered = new Set( + [...markup.matchAll(/id="([^"]+)"/g)].map(([, id]) => id), + ); + const ids = idsIn(toc); + expect(ids.length).toBeGreaterThan(0); + for (const id of ids) expect(rendered).toContain(id); + }); + } + + it("keeps the changelog rail honest on the filtered route the updater opens", () => { + // /changelog/after/:version renders only releases newer than the baseline. + // A rail built from every release links into articles that route omits. + const baseline = changelog.releases[2]; + const markup = renderToStaticMarkup( + <MemoryRouter initialEntries={[`/changelog/after/${baseline.version}`]}> + <Routes> + <Route path="/changelog/after/:version" element={<Changelog />} /> + </Routes> + </MemoryRouter>, + ); + const rendered = new Set([...markup.matchAll(/id="([^"]+)"/g)].map(([, id]) => id)); + const shown = changelog.releases.slice(0, 2); + const toc = changelogToc(shown); + expect(toc.length).toBe(shown.length); + for (const entry of toc) expect(rendered).toContain(entry.id); + // And nothing older leaks in. + expect(rendered).not.toContain(baseline.tag); + }); +}); diff --git a/website/src/lib/docs-theme.test.ts b/website/src/lib/docs-theme.test.ts new file mode 100644 index 000000000..bf61b3e59 --- /dev/null +++ b/website/src/lib/docs-theme.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DOCS_THEME_ID, dismissThemePrompt, isThemePromptDismissed } from "./docs-theme"; + +afterEach(() => { + // Unstub first: the storage-failure case replaces localStorage entirely. + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe("docs theme prompt", () => { + it("starts undismissed and stays dismissed once recorded", () => { + // Both the picker's onPick and the prompt's close button record it, so a + // reader who declined the offer is not asked again on the next page. + expect(isThemePromptDismissed()).toBe(false); + dismissThemePrompt(); + expect(isThemePromptDismissed()).toBe(true); + }); + + it("does not read the theme store's own key", () => { + // `restoreActiveTheme` persists the id it resolved, so this key exists + // after any page load whether or not the reader chose anything. A prompt + // keyed on it would never show twice (docs/specs/theme.md). + localStorage.setItem("dormouse:active-theme", DOCS_THEME_ID); + expect(isThemePromptDismissed()).toBe(false); + }); + + it("reports undismissed rather than throwing when storage is unavailable", () => { + // Safari in private mode throws on access; the prompt returning every + // visit is the acceptable failure, a page that will not render is not. + vi.stubGlobal("localStorage", { + get getItem(): never { + throw new DOMException("denied"); + }, + }); + expect(isThemePromptDismissed()).toBe(false); + expect(() => dismissThemePrompt()).not.toThrow(); + }); + + it("defaults to a theme that actually ships", async () => { + // Otherwise the default silently degrades to whichever theme happens to be + // first in the bundle, which is what `restoreActiveTheme` falls back to. + const { getBundledThemes } = await import("dormouse-lib/lib/themes"); + expect(getBundledThemes().map((t) => t.id)).toContain(DOCS_THEME_ID); + }); +}); diff --git a/website/src/lib/docs-theme.ts b/website/src/lib/docs-theme.ts new file mode 100644 index 000000000..d32d1b66c --- /dev/null +++ b/website/src/lib/docs-theme.ts @@ -0,0 +1,41 @@ +/** + * Theme state for the reference pages. + * + * The docs render long-form prose, so unlike the rest of the site they are not + * locked to the brand's black — a reader picks what they can read. The + * homepage keeps its own palette (docs/specs/website-docs.md -> Reference page + * chrome). + */ +import { loadJson, saveJson } from "dormouse-lib/lib/local-json-store"; + +/** + * Softer than the site's `#000`, still dark enough to arrive from a black + * homepage without a flash. Only the pre-choice default; the picker's own + * persistence takes over the moment someone chooses. + */ +export const DOCS_THEME_ID = "vscode.theme-defaults.dark-visual-studio"; + +/** + * Whether the reader is done with the theme prompt — they picked a theme, or + * they closed it. Either way they have seen the offer, and repeating it is a + * nuisance rather than a service. + * + * Deliberately not `dormouse:active-theme`: `restoreActiveTheme` persists the + * id it resolved, so that key exists after the first page load whether or not + * anyone chose anything, and a prompt keyed on it would never show twice + * (docs/specs/theme.md -> Where the user picks a theme). + */ +const DISMISSED_KEY = "dormouse:docs-theme-prompt-dismissed"; + +/** + * Storage is absent in prerender and throws outright in some privacy modes; + * `loadJson`/`saveJson` already collapse both to the fallback, so a reader with + * no storage is prompted every visit rather than seeing the page fail. + */ +export function isThemePromptDismissed(): boolean { + return loadJson(DISMISSED_KEY, false) === true; +} + +export function dismissThemePrompt(): void { + saveJson(DISMISSED_KEY, true); +} diff --git a/website/src/lib/site-meta.ts b/website/src/lib/site-meta.ts new file mode 100644 index 000000000..76c221562 --- /dev/null +++ b/website/src/lib/site-meta.ts @@ -0,0 +1,89 @@ +/** + * The head tags that differ per page: title, description, canonical, and the + * social cards that mirror them. + * + * One helper, because React Router renders only the deepest route's `meta` — + * its tags "are not rendered on descendant routes" — so a page that wants its + * own title replaces the whole set rather than merging with the root's. Every + * page therefore builds its tags here, and a page with nothing of its own + * inherits the root route's call. + * + * **Never** hardcode one of these in `root.tsx`'s `<head>`. A tag written there + * is emitted before `<Meta />`, so a page that set its own title shipped two + * `<title>` elements and every crawler read the first — the homepage's. Pinned + * by `checkPageHeadTags` in scripts/public-docs-lint.mjs. + */ +import type { MetaDescriptor } from "react-router"; + +/** + * This site's own origin. + * + * Mirrored by `SITE_ORIGIN` in website/scripts/generate-docs.js, which cannot + * import this module (it is plain Node build code, and this is TypeScript the + * browser bundle owns). `checkSiteOrigin` in scripts/public-docs-lint.mjs pins + * the two equal. + */ +export const SITE_ORIGIN = "https://dormouse.sh"; + +const DEFAULT_TITLE = "Dormouse — A dormouse knows when to wake up"; +const DEFAULT_DESCRIPTION = + "So many terminals — which one needs attention? Dormouse alerts you when a build, agent, or script stops printing. Multitasking terminal for mice, no plugins, no config."; +const OG_IMAGE = `${SITE_ORIGIN}/og-image.jpg`; +const OG_IMAGE_ALT = "Dormouse — multitasking terminal for mice"; + +/** + * The absolute URL a page should claim as its own. + * + * Trailing slash because that is what the host actually serves: a request for + * `/supply-chain` is answered with a 308 to `/supply-chain/`, and a canonical + * pointing at a redirect is a weaker signal than one pointing at the page. + */ +export function canonicalUrl(pathname: string): string { + return new URL(pathname.endsWith("/") ? pathname : `${pathname}/`, SITE_ORIGIN).href; +} + +/** + * Every per-page head tag, defaulting to the homepage's copy. + * + * `indexable: false` is for a route served through the SPA fallback rather than + * prerendered. Such a route is handed the fallback's static head, which already + * carries the homepage's `canonical` and `og:url`, and React Router's client + * `<Meta />` **appends** its own rather than replacing them — so emitting a + * second, correct canonical leaves two conflicting ones, which search engines + * discard together. These routes make no index claim at all and say so. + */ +export function siteMeta( + pathname: string, + page: { title?: string; description?: string; indexable?: boolean } = {}, +): MetaDescriptor[] { + const title = page.title ?? DEFAULT_TITLE; + const description = page.description ?? DEFAULT_DESCRIPTION; + if (page.indexable === false) { + return [ + { title }, + { name: "description", content: description }, + { name: "robots", content: "noindex, follow" }, + ]; + } + const url = canonicalUrl(pathname); + return [ + { title }, + { name: "description", content: description }, + { tagName: "link", rel: "canonical", href: url }, + { property: "og:type", content: "website" }, + { property: "og:site_name", content: "Dormouse" }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: url }, + { property: "og:image", content: OG_IMAGE }, + { property: "og:image:type", content: "image/jpeg" }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { property: "og:image:alt", content: OG_IMAGE_ALT }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { name: "twitter:image", content: OG_IMAGE }, + { name: "twitter:image:alt", content: OG_IMAGE_ALT }, + ]; +} diff --git a/website/src/pages/AgentSkillDocs.tsx b/website/src/pages/AgentSkillDocs.tsx new file mode 100644 index 000000000..4277d005e --- /dev/null +++ b/website/src/pages/AgentSkillDocs.tsx @@ -0,0 +1,79 @@ +/** + * `/docs/agent-skill` — renders `dor/skill.md` exactly. + * + * Page chrome adds a table of contents, heading ids, copy buttons, and + * contextual links into the CLI reference. Those links live in the page, never + * in `dor/skill.md`: an older installed CLI must stay self-contained and + * version-matched rather than pointing its instructions at the latest website. + */ +import { type MetaArgs } from "react-router"; +import { siteMeta } from "../lib/site-meta"; +import { useState } from "react"; +import skill from "../data/docs.skill.json"; +import DocsLayout from "../components/DocsLayout"; +import { ACCENT_HOVER_BORDER_CLASS, ACCENT_HOVER_TEXT_CLASS, LINK_CLASS } from "../components/docs-tokens"; +import MarkdownDocument, { type BlockNode } from "../components/MarkdownDocument"; + +export function meta({ location }: MetaArgs) { + return siteMeta(location.pathname, { + title: "Agent skill — Dormouse", + description: + "The agent skill Dormouse bundles: how an agent drives panes, terminals, and browser surfaces with dor.", + }); +} + +function CopyButton({ text, children }: { text: string; children: React.ReactNode }) { + const [copied, setCopied] = useState(false); + return ( + <button + type="button" + onClick={() => { + void navigator.clipboard?.writeText(text).then( + () => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, + () => undefined, + ); + }} + className={`rounded-md border border-[var(--color-text)]/20 px-3 py-1.5 font-mono text-sm ${ACCENT_HOVER_BORDER_CLASS} ${ACCENT_HOVER_TEXT_CLASS}`} + > + {copied ? "copied" : children} + </button> + ); +} + +type Reference = { href: string; label: string }; +const references: Record<string, Reference> = skill.references; + +export default function AgentSkillDocs() { + return ( + <DocsLayout + activePath="/docs/agent-skill" + title="Agent skill" + intro="The operating guide Dormouse bundles for coding agents, rendered exactly as the CLI prints it." + toc={skill.toc} + > + <div className="mb-8 flex flex-wrap gap-3"> + <CopyButton text="dor skill">dor skill</CopyButton> + <CopyButton text="dor skill --install">dor skill --install</CopyButton> + </div> + + <MarkdownDocument + blocks={skill.blocks as BlockNode[]} + renderAfterHeading={(heading) => { + const reference = references[heading.id]; + if (!reference) return null; + return ( + <p className="-mt-2 mb-4 text-sm opacity-60"> + CLI reference:{" "} + <a href={reference.href} className={`${LINK_CLASS} font-mono`}> + {reference.label} + </a> + </p> + ); + }} + /> + </DocsLayout> + ); +} diff --git a/website/src/pages/Changelog.test.tsx b/website/src/pages/Changelog.test.tsx new file mode 100644 index 000000000..fb23fb852 --- /dev/null +++ b/website/src/pages/Changelog.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { changelogToc, CHANGELOG_TOC_RELEASES } from "./Changelog"; +import changelog from "../data/changelog.json"; + +describe("changelog table of contents", () => { + it("caps the entries well short of what the page renders", () => { + // The cap is the point: without it the rail would carry an entry per + // release and dwarf the four other pages beside it. Anchors resolving is + // checked for every rail page in website/src/lib/docs-rail.test.tsx. + expect(changelog.releases.length).toBeGreaterThan(CHANGELOG_TOC_RELEASES); + expect(changelogToc(changelog.releases)).toHaveLength(CHANGELOG_TOC_RELEASES); + }); + + it("shows only what a filtered route renders, even below the cap", () => { + const two = changelog.releases.slice(0, 2); + expect(changelogToc(two).map((entry) => entry.id)).toEqual(two.map((r) => r.tag)); + expect(changelogToc([])).toEqual([]); + }); +}); diff --git a/website/src/pages/Changelog.tsx b/website/src/pages/Changelog.tsx index cb24826c1..ce2e5d244 100644 --- a/website/src/pages/Changelog.tsx +++ b/website/src/pages/Changelog.tsx @@ -1,7 +1,21 @@ import type { ReactNode } from "react"; -import { Link, useParams } from "react-router"; -import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader"; +import { Link, useParams, type MetaArgs } from "react-router"; +import DocsLayout from "../components/DocsLayout"; +import { ACCENT_TEXT_CLASS, SCROLL_MT_CLASS } from "../components/docs-tokens"; import changelog from "../data/changelog.json"; +import { type TocEntry } from "../lib/docs-pages"; +import { siteMeta } from "../lib/site-meta"; + +/** Shared with `/changelog/after/:version`, which is a filtered view of this + * page rather than a document of its own. */ +export const CHANGELOG_META = { + title: "Changelog — Dormouse", + description: "Every Dormouse release, what changed in it, and when it shipped.", +}; + +export function meta({ location }: MetaArgs) { + return siteMeta(location.pathname, CHANGELOG_META); +} interface ChangelogItem { text: string; @@ -67,7 +81,7 @@ function renderInlineMarkdown(text: string) { <a key={`link-${key}`} href={match[3]} - className="text-[var(--color-caramel)] hover:underline" + className={`${ACCENT_TEXT_CLASS} hover:underline`} target="_blank" rel="noopener noreferrer" > @@ -102,7 +116,7 @@ function ChangelogListItem({ item }: { item: ChangelogItem }) { function ReleaseSection({ section }: { section: ChangelogSection }) { return ( <section className="mt-5"> - <h3 className="mb-2 font-display text-base text-[var(--color-caramel)]"> + <h3 className={`mb-2 font-display text-base ${ACCENT_TEXT_CLASS}`}> {section.title} </h3> <ul className="ml-5 list-disc space-y-2 text-base leading-relaxed text-[var(--color-text)]/85"> @@ -118,7 +132,7 @@ function ReleaseArticle({ release }: { release: ChangelogRelease }) { const date = formatDate(release.date); return ( - <article id={release.tag} className="border-t border-[var(--color-text)]/10 py-8"> + <article id={release.tag} className={`${SCROLL_MT_CLASS} border-t border-[var(--color-text)]/10 py-8`}> <div className="flex flex-col gap-2 sm:flex-row sm:items-baseline sm:justify-between"> <div> <h2 className="font-display text-2xl text-[var(--color-text)]"> @@ -132,7 +146,7 @@ function ReleaseArticle({ release }: { release: ChangelogRelease }) { </div> <a href={`https://github.com/diffplug/dormouse/releases/tag/${release.tag}`} - className="text-sm text-[var(--color-caramel)] hover:underline" + className={`text-sm ${ACCENT_TEXT_CLASS} hover:underline`} target="_blank" rel="noopener noreferrer" > @@ -147,11 +161,38 @@ function ReleaseArticle({ release }: { release: ChangelogRelease }) { ); } +/** + * How many releases the rail lists. + * + * The page renders every release; the rail carries four other pages beside + * this one, and an entry per release would be longer than all of them. + */ +export const CHANGELOG_TOC_RELEASES = 5; + +/** + * The rail's entries for whichever releases the page is showing. + * + * **Must** be derived from what actually renders, not from every release: + * `/changelog/after/:version` filters the list, so a constant taken off the + * newest five would link to anchors the filtered page never emits — and that + * is the URL the standalone updater opens (`standalone/src/updater.ts`), where + * a reader one version behind sees one article. Pinned by + * `website/src/lib/docs-rail.test.tsx`. + * + * Each entry is anchored on the tag, which is both the id `ReleaseArticle` + * gives its `<article>` and the text its `<h2>` shows. + */ +export function changelogToc(visible: readonly ChangelogRelease[]): TocEntry[] { + return visible + .slice(0, CHANGELOG_TOC_RELEASES) + .map((release) => ({ id: release.tag, text: release.tag, children: [] })); +} + function FilterNotice({ children }: { children: ReactNode }) { return ( - <div className="mb-8 border-l-2 border-[var(--color-caramel)] pl-4 text-sm text-[var(--color-text)]/75"> + <div className="mb-8 border-l-2 border-[var(--docs-accent)] pl-4 text-sm text-[var(--color-text)]/75"> {children}{" "} - <Link to="/changelog" className="text-[var(--color-caramel)] hover:underline"> + <Link to="/changelog" className={`${ACCENT_TEXT_CLASS} hover:underline`}> Show all releases. </Link> </div> @@ -173,39 +214,26 @@ export default function Changelog() { : RELEASES; return ( - <> - <SiteHeader activePath="/changelog" style={STATIC_PAGE_HEADER_STYLE} /> - - <main className="min-h-screen bg-[var(--color-bg)] px-4 pt-24 pb-16 text-[var(--color-text)] md:px-6"> - <div className="mx-auto max-w-3xl"> - <div className="mb-10"> - <h1 className="mb-2 font-display text-[clamp(1.75rem,3vw+0.5rem,2.75rem)]"> - Changelog - </h1> - <p className="text-base leading-relaxed text-[var(--color-text)]/60"> - Release notes for Dormouse. - </p> - </div> - - {hasInvalidFilter ? ( - <FilterNotice>No such release "{versionParam}".</FilterNotice> - ) : null} - - {baselineVersion ? ( - <FilterNotice>Showing releases newer than v{baselineVersion}.</FilterNotice> - ) : null} + <DocsLayout + activePath="/changelog" + intro="Release notes for Dormouse." + toc={changelogToc(visibleReleases)} + > + {hasInvalidFilter ? <FilterNotice>No such release "{versionParam}".</FilterNotice> : null} + + {baselineVersion ? ( + <FilterNotice>Showing releases newer than v{baselineVersion}.</FilterNotice> + ) : null} - {visibleReleases.map((release) => ( - <ReleaseArticle key={release.version} release={release} /> - ))} + {visibleReleases.map((release) => ( + <ReleaseArticle key={release.version} release={release} /> + ))} - {baselineVersion && visibleReleases.length === 0 ? ( - <div className="border-t border-[var(--color-text)]/10 py-8 text-[var(--color-text)]/60"> - No releases newer than v{baselineVersion}. - </div> - ) : null} + {baselineVersion && visibleReleases.length === 0 ? ( + <div className="border-t border-[var(--color-text)]/10 py-8 text-[var(--color-text)]/60"> + No releases newer than v{baselineVersion}. </div> - </main> - </> + ) : null} + </DocsLayout> ); } diff --git a/website/src/pages/ChangelogAfter.tsx b/website/src/pages/ChangelogAfter.tsx index f7912ac87..99fa687af 100644 --- a/website/src/pages/ChangelogAfter.tsx +++ b/website/src/pages/ChangelogAfter.tsx @@ -1 +1,23 @@ +/** + * `/changelog/after/:version` — the changelog filtered to releases newer than + * the one a reader already has. `standalone/src/updater.ts` opens it after an + * update, so it is the changelog URL most readers actually arrive on. + * + * **Must** carry its own `meta`. Re-exporting only the component left the route + * with none, so it showed the SPA fallback's homepage title. + * + * **Never** claim a canonical here. This route is served by rewriting to + * `__spa-fallback.html`, whose static head already carries the homepage's + * `canonical` and `og:url`; React Router appends rather than replaces, so a + * second canonical would leave two conflicting ones. A per-installed-version + * view of one page does not belong in a search index anyway, so it asks not to + * be indexed and makes no index claim — `/changelog` is the page to find. + */ +import { siteMeta } from "../lib/site-meta"; +import { CHANGELOG_META } from "./Changelog"; + export { default } from "./Changelog"; + +export function meta() { + return siteMeta("/changelog", { ...CHANGELOG_META, indexable: false }); +} diff --git a/website/src/pages/DorDocs.test.tsx b/website/src/pages/DorDocs.test.tsx new file mode 100644 index 000000000..a724af238 --- /dev/null +++ b/website/src/pages/DorDocs.test.tsx @@ -0,0 +1,48 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import DorDocs from "./DorDocs"; +import cli from "../data/docs.cli.json"; + +const markup = renderToStaticMarkup(<DorDocs />); +/** Heading level per anchor, so the outline can be checked against the nesting. */ +const headingLevel = new Map( + [...markup.matchAll(/<h([1-6]) id="([^"]+)"/g)].map(([, level, id]) => [id, Number(level)]), +); + +describe("dor CLI reference outline", () => { + it("renders each command one heading level below the entry that nests it", () => { + // A reader on a screen reader navigates the outline, not the rail. If the + // commands stay `h2` peers of the `Commands` heading, the two disagree. + // Anchors resolving is checked in website/src/lib/docs-rail.test.tsx. + const commands = cli.toc.find((entry) => entry.id === cli.commandsHeading.id); + expect(commands?.children.length).toBeGreaterThan(0); + const parent = headingLevel.get(cli.commandsHeading.id); + expect(parent).toBeDefined(); + for (const child of commands?.children ?? []) { + expect(headingLevel.get(child.id)).toBe((parent ?? 0) + 1); + } + }); + + it("keeps each command's own labels below that command's heading", () => { + // Command sections carry their own `FLAGS` / `Text output:` headings. Left + // fixed while the commands moved down a level, each command and its labels + // become one flat run — worse than the outline the nesting replaced. + const headings = [...markup.matchAll(/<h([1-6])((?:\s[^>]*)?)>/g)].map(([, level, attrs]) => ({ + level: Number(level), + id: /id="([^"]+)"/.exec(attrs)?.[1], + })); + const commandIds = new Set(cli.commands.map((c) => c.id)); + let commandLevel; + let checked = 0; + for (const heading of headings) { + if (heading.id !== undefined) { + commandLevel = commandIds.has(heading.id) ? heading.level : undefined; + continue; + } + if (commandLevel === undefined) continue; + checked += 1; + expect(heading.level).toBeGreaterThan(commandLevel); + } + expect(checked).toBeGreaterThan(0); + }); +}); diff --git a/website/src/pages/DorDocs.tsx b/website/src/pages/DorDocs.tsx new file mode 100644 index 000000000..13b9fac18 --- /dev/null +++ b/website/src/pages/DorDocs.tsx @@ -0,0 +1,45 @@ +/** + * `/docs/dor` — exhaustive, lossless CLI reference generated from the help + * snapshots that `dor/test/cli-help.test.mjs` already proves match real output. + */ +import { type MetaArgs } from "react-router"; +import { siteMeta } from "../lib/site-meta"; +import cli from "../data/docs.cli.json"; +import DocsLayout from "../components/DocsLayout"; +import MarkdownDocument, { AnchoredHeading, type BlockNode } from "../components/MarkdownDocument"; +import DorCommandReference, { type CommandSection } from "../components/DorCommandReference"; + +export function meta({ location }: MetaArgs) { + return siteMeta(location.pathname, { + title: "dor CLI reference — Dormouse", + description: + "Every dor command, its flags, arguments, and output, generated from the CLI's own tested help text.", + }); +} + +export default function DorDocs() { + return ( + <DocsLayout + activePath="/docs/dor" + intro="dor is on the PATH of every terminal Dormouse launches. This page is generated from the CLI's own help output." + toc={cli.toc} + > + {cli.intro.map((section) => ( + <section key={section.id} className="mb-14"> + <AnchoredHeading id={section.id} spacing="mb-4">{section.title}</AnchoredHeading> + <MarkdownDocument blocks={section.blocks as BlockNode[]} /> + </section> + ))} + + <DorCommandReference section={cli.root as CommandSection} /> + + {/* The anchor the table of contents nests every command under. */} + <AnchoredHeading id={cli.commandsHeading.id} spacing="mb-8"> + {cli.commandsHeading.title} + </AnchoredHeading> + {cli.commands.map((section) => ( + <DorCommandReference key={section.id} section={section as CommandSection} depth={3} /> + ))} + </DocsLayout> + ); +} diff --git a/website/src/pages/Home.tsx b/website/src/pages/Home.tsx index 89f69b9d2..00c4ecea5 100644 --- a/website/src/pages/Home.tsx +++ b/website/src/pages/Home.tsx @@ -13,7 +13,6 @@ import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type import SiteHeader from "../components/SiteHeader"; import posterUrl from "../assets/video-climb-blink-and-stare.webp"; import videoUrl from "../assets/video-climb-blink-and-stare.mp4"; -import alertVideoUrl from "../assets/video-alert.mp4"; import copyPasteVideoUrl from "../assets/video-copy-paste.mp4"; import tmuxVideoUrl from "../assets/video-tmux.mp4"; import visualStudioIconUrl from "../assets/visual-studio-icon.svg"; @@ -21,7 +20,7 @@ import tinyIconUrl from "../assets/icon-tiny-dark.png"; import phoneMockupUrl from "../assets/phone-mockup.webp"; import standaloneLatest from "@standalone-latest"; import { prefersReducedMotion } from "dormouse-lib/lib/ui-geometry"; -import { NotifySignupForm } from "../components/NotifySignupForm"; +import { SITE_CODE_CLASS, SITE_LINK_CLASS } from "../components/site-tokens"; /** Multiplier on scroll required to drive the hero animation. * 1 = baseline, 2 = half as sensitive, 0.5 = twice as sensitive. */ @@ -759,7 +758,7 @@ export default function Home() { terminal </span> <span ref={word2Ref} style={{ opacity: 0, transform: "translateY(12px)" }}> - <span className="text-[var(--color-caramel)]">for mice</span> + <span className="text-[var(--color-caramel)]">for mice and thumbs</span> </span> <p ref={footnoteRef} @@ -774,60 +773,192 @@ export default function Home() { {/* ── Content sections — pulled up to appear as video starts scrolling ── */} <div ref={contentRef} className="relative z-10 bg-[var(--color-bg)]" style={{ marginTop: `-${(1 - UNPIN_THRESHOLD) * RUNWAY_VH}vh` }}> - {/* Section 1: narrow text over a full-width video — lead with the tmux story */} - <section className={`mx-auto max-w-5xl px-4 md:px-6 ${SECTION_PY}`}> - <div className="mx-auto max-w-2xl"> - <h2 className="font-display text-[clamp(1.5rem,2.5vw+0.5rem,2.25rem)] mb-6">Soft as a mouse, sharp as a tmux</h2> + {/* 1. Distribution + layout — one terminal, two homes */} + <section id="features" className={`mx-auto max-w-5xl px-4 md:px-6 ${SECTION_PY}`}> + <div> + <h2 className="font-display text-[clamp(1.5rem,2.5vw+0.5rem,2.25rem)] mb-6">Tmux with browsers, for VS Code and Standalone</h2> + <div className="grid gap-x-8 gap-y-4 md:grid-cols-2"> + <p className="text-lg leading-relaxed opacity-70"> + Soft as a mouse, sharp as a tmux. A real tiling layout for + terminals and browser embeds. + Do it all with the mouse, or keep your hands on the keyboard with + tmux keybinds. + </p> + <p className="text-lg leading-relaxed opacity-70"> + Inside VS Code it follows your theme exactly - hard to tell it isn't built in. + Standalone, it's a Tauri app that starts in a blink. Same features in both places. + </p> + </div> + </div> + <FeatureVideo src={tmuxVideoUrl} variant="intrinsic" className="mt-8" /> + </section> + {/* Section 2: image left, text right */} + <section id="notify" className={`mx-auto max-w-5xl px-4 md:px-6 ${SECTION_PY} grid md:grid-cols-[2fr_3fr] gap-8 md:gap-12 items-start`}> + <img + src={phoneMockupUrl} + alt="Dormouse Pocket running on a phone" + className="order-2 md:order-1 block w-full max-w-[280px] mx-auto md:max-w-none" + /> + <div className="order-1 md:order-2"> + <h2 className="font-display text-[clamp(1.5rem,2.5vw+0.5rem,2.25rem)] text-[var(--color-text)] mb-6"> + Push notifications you can self-host + </h2> + <p className="mb-4 text-lg leading-relaxed opacity-70"> + Your agent hits a permission prompt four minutes after you leave, + then sits there until you get back. Dormouse already knows that + pane is asking for a human — so it buzzes your phone. A real push + notification, delivered by Apple or Google, to an app that's + completely closed. + </p> + <p className="mb-4 text-lg leading-relaxed opacity-70"> + Then you answer it. Tap the terminal and a radial menu opens under + your thumb: drag down-right for{" "} + <code className={SITE_CODE_CLASS}>y</code>, + up-right for{" "} + <code className={SITE_CODE_CLASS}>n</code>, + or out to Esc, Ctrl+C, and a quit menu. One drag and the agent's + moving again. No keyboard, no squinting. + </p> + <p className="mb-4 text-lg leading-relaxed opacity-70"> + The relay is one Node process. No database — state is JSON on + disk, and push keys mint themselves on first boot. Put{" "} + <code className={SITE_CODE_CLASS}>tailscale serve</code>{" "} + in front of it and you're done: no account, no cloud dependency, + nothing anyone can shut off. Your laptop decides which phones get + notified — the server isn't allowed to choose for it. The{" "} + <a href="/docs/self-host" className={SITE_LINK_CLASS}>self-host runbook</a>{" "} + walks the whole install. + </p> + <p className="text-lg leading-relaxed opacity-70"> + <a href="/playground/pocket" className={SITE_LINK_CLASS}>Dormouse Pocket</a>{" "} + is in development — try the phone interface in your browser, and + sign up there to hear when it's ready. + </p> + </div> + </section> + + {/* 3. Port awareness — text left, context-menu mock right */} + <section className={`mx-auto max-w-5xl px-4 md:px-6 ${SECTION_PY} grid md:grid-cols-[2fr_3fr] gap-8 md:gap-12 items-center`}> + <div> + <h2 className="font-display text-xl mb-6">Terminals that know their ports</h2> <p className="text-lg leading-relaxed opacity-70 mb-4"> - Upgrade your VS Code or native terminal with a flexible multipane - layout. Sleep the tasks you're not watching down to a compact - status indicator. + Six panes running and something's serving{" "} + <code className={SITE_CODE_CLASS}>:3000</code>. + Which one? </p> <p className="text-lg leading-relaxed opacity-70"> - Do it all with the mouse, or keep your hands on the keyboard with - tmux keybinds. + Right-click a pane and Dormouse lists the ports that pane's + process tree is actually listening on — hit a number to open one. + No{" "} + <code className={SITE_CODE_CLASS}>lsof</code>, + no scrolling back to find where Vite printed the URL. </p> </div> - <FeatureVideo src={tmuxVideoUrl} variant="intrinsic" className="mt-8" /> + <div className="rounded-lg border border-[var(--color-text)]/15 bg-[var(--color-text)]/[0.04] p-4"> + <div className="mb-3 flex items-center gap-2 text-sm opacity-60"> + <TerminalIcon size={15} weight="bold" /> + <span className="font-mono">web — pnpm dev</span> + </div> + <div className="space-y-1 font-mono text-sm"> + {[ + { key: "1", port: "3000", label: "vite" }, + { key: "2", port: "24678", label: "vite hmr" }, + { key: "3", port: "5432", label: "postgres" }, + ].map(({ key, port, label }) => ( + <div + key={port} + className="flex items-center gap-3 rounded px-2 py-1.5 hover:bg-[var(--color-text)]/10" + > + <span className="w-4 text-center opacity-40">{key}</span> + <span className="text-[var(--color-caramel)]">localhost:{port}</span> + <span className="ml-auto opacity-50">{label}</span> + </div> + ))} + </div> + </div> </section> - {/* Section 2: image left, text right */} - <section id="features" className={`mx-auto max-w-5xl px-4 md:px-6 ${SECTION_PY} grid md:grid-cols-[3fr_2fr] gap-8 md:gap-12 items-start`}> - <FeatureVideo src={alertVideoUrl} className="order-2 md:order-1" /> + {/* 4. Browser surfaces — transcript + preview left, text right */} + <section className={`mx-auto max-w-5xl px-4 md:px-6 ${SECTION_PY} grid md:grid-cols-[2fr_3fr] gap-8 md:gap-12 items-center`}> + <div className="order-2 md:order-1 space-y-4"> + {/* Authored transcript. Command syntax tracks dor/test/snapshots/help/; + the output lines are illustrative and not generated from a test. */} + <div className="overflow-x-auto rounded-lg border border-[var(--color-text)]/15 bg-[var(--color-text)]/[0.04] p-4 font-mono text-sm leading-relaxed"> + <div><span className="opacity-40">$ </span>dor ensure -- pnpm dev</div> + <div className="opacity-60">created surface:3  "pnpm dev"</div> + <div className="mt-3"><span className="opacity-40">$ </span>dor ab open surface:3</div> + <div className="opacity-60">→ <span className="text-[var(--color-caramel)]">http://localhost:5173/</span></div> + </div> + <div className="overflow-hidden rounded-lg border border-[var(--color-text)]/15 bg-[var(--color-text)]/[0.04]"> + <div className="flex items-center gap-2 border-b border-[var(--color-text)]/10 px-3 py-2"> + <span className="size-2.5 rounded-full bg-[var(--color-text)]/20" /> + <span className="size-2.5 rounded-full bg-[var(--color-text)]/20" /> + <div className="ml-1 flex-1 truncate rounded bg-[var(--color-text)]/10 px-2 py-1 font-mono text-xs opacity-70"> + localhost:5173 + </div> + <span className="rounded border border-[var(--color-caramel)]/40 px-1.5 py-0.5 font-mono text-[0.65rem] text-[var(--color-caramel)]"> + screencast + </span> + </div> + <div className="space-y-2.5 p-4"> + <div className="h-2.5 w-1/3 rounded bg-[var(--color-text)]/20" /> + <div className="h-2 w-full rounded bg-[var(--color-text)]/10" /> + <div className="h-2 w-11/12 rounded bg-[var(--color-text)]/10" /> + <div className="h-2 w-4/5 rounded bg-[var(--color-text)]/10" /> + <div className="mt-4 grid grid-cols-3 gap-2.5"> + <div className="h-12 rounded bg-[var(--color-text)]/10" /> + <div className="h-12 rounded bg-[var(--color-text)]/10" /> + <div className="h-12 rounded bg-[var(--color-text)]/10" /> + </div> + </div> + </div> + </div> <div className="order-1 md:order-2"> - <h2 className="font-display text-xl mb-6">Stop watching terminals spin</h2> + <h2 className="font-display text-xl mb-6">Browsers for you (and your agents)</h2> <p className="text-lg leading-relaxed opacity-70 mb-4"> - Dormouse tracks activity the same way you do — visual motion. When a - pane stops changing for two seconds, it marks the task complete and - alerts you. + A browser is just another pane. Park your dev server next to the + terminal that's running it — same tiling layout, same keybinds, no + alt-tab and no second monitor. </p> - <p className="text-lg leading-relaxed opacity-70"> - Works with any CLI tool that prints to a terminal — no plugins, no - configuration. Also supports{" "} - <code className="text-sm bg-[var(--color-text)]/20 px-1.5 py-0.5 rounded">BEL</code>{" "} - and{" "} - <code className="text-sm bg-[var(--color-text)]/20 px-1.5 py-0.5 rounded">OSC 9/99/777</code>{" "} - for native TUI integration. + <p className="text-lg leading-relaxed opacity-70 mb-4"> + <code className={SITE_CODE_CLASS}>dor ab open surface:2</code>{" "} + aims a browser at the port that terminal is serving — the one from + the section above. Your agents run the same command, so when an + agent wants to see what it just built, it opens a pane you're + already watching. Pop it out to a real OS window when you need the + real thing. + </p> + <p className="text-lg leading-relaxed opacity-70 mb-4"> + Dormouse drives the{" "} + <code className={SITE_CODE_CLASS}>agent-browser</code>{" "} + you already have installed — it doesn't ship a browser of its own. + </p> + <p className="text-base leading-relaxed opacity-60"> + <a href="/docs/dor#agent-browser" className={SITE_LINK_CLASS}>CLI reference</a> + {" · "} + <a href="/docs/agent-skill" className={SITE_LINK_CLASS}>Agent skill</a> </p> </div> </section> - {/* Section 3: text left, image right */} + {/* 5. Selection and copy/paste — text left, video right */} <section className={`mx-auto max-w-5xl px-4 md:px-6 ${SECTION_PY} grid md:grid-cols-[2fr_3fr] gap-8 md:gap-12 items-start`}> <div> - <h2 className="font-display text-xl mb-6">Newlines and copy paste like you meant</h2> + <h2 className="font-display text-xl mb-6">Select and copy-paste like you meant</h2> <p className="text-lg leading-relaxed opacity-70 mb-4"> - You're used to{" "} - <code className="text-sm bg-[var(--color-text)]/20 px-1.5 py-0.5 rounded">Shift+Enter</code>{" "} - for a newline in the browser — but it's broken in your terminal? - Not anymore. Dormouse works the way you'd expect, no arcane - terminal knowledge required. + Click and drag in a "mouse conformant" terminal doesn't select + text; it fires escape code{" "} + <code className={SITE_CODE_CLASS}>{"\\e[<0;x;yM"}</code>{" "} + at whatever's running. Dormouse notices when a TUI has grabbed the + mouse and hands you a one-click override, so you can just — select + the thing. </p> <p className="text-lg leading-relaxed opacity-70"> - Click and drag in a "mouse conformant" terminal doesn't select text; - it sends escape code{" "} - <code className="text-sm bg-[var(--color-text)]/20 px-1.5 py-0.5 rounded">{"\\e[<0;x;yM"}</code>. - Dormouse lets you copy-paste like a human, not a terminal. + Then copy it the way you meant it. <strong className="font-semibold opacity-100">Raw</strong> keeps + the hard wraps; <strong className="font-semibold opacity-100">Rewrapped</strong> joins them back + into the line the program actually printed. Tap{" "} + <code className={SITE_CODE_CLASS}>e</code>{" "} + mid-drag to snap the selection out to the whole URL or file path. </p> </div> <FeatureVideo src={copyPasteVideoUrl} /> @@ -835,7 +966,7 @@ export default function Home() { <section id="download" className={`mx-auto max-w-5xl px-4 md:px-6 ${SECTION_PY}`} style={downloadAccentStyle}> <h2 className="font-display text-[clamp(1.5rem,2.5vw+0.5rem,2.25rem)] text-[var(--color-text)]">Get Dormouse</h2> - <p className="mb-4 text-lg leading-relaxed opacity-70">A dormouse knows when to wake up. Multitasking terminal for mice.</p> + <p className="mb-4 text-lg leading-relaxed opacity-70">A dormouse knows when to wake up. Multitasking terminal for mice and thumbs.</p> <DownloadButton href="/playground" icon={<TerminalIcon size={26} weight="bold" />} @@ -928,31 +1059,11 @@ export default function Home() { </div> </section> - <section id="notify" className={`mx-auto max-w-5xl px-4 md:px-6 ${SECTION_PY} grid md:grid-cols-[2fr_3fr] gap-8 md:gap-12 items-start`}> - <img - src={phoneMockupUrl} - alt="Dormouse Pocket running on a phone" - className="order-2 md:order-1 block w-full max-w-[280px] mx-auto md:max-w-none" - /> - <div className="order-1 md:order-2"> - <h2 className="font-display text-[clamp(1.5rem,2.5vw+0.5rem,2.25rem)] text-[var(--color-text)] mb-6"> - Walk away. Keep going. - </h2> - <p className="mb-4 text-lg leading-relaxed opacity-70"> - Coming next: <a href="/playground/pocket" className="text-[var(--color-caramel)] underline-offset-2 hover:underline">Dormouse Pocket</a>. - Tether a terminal session to your phone over WebRTC and take a stroll — Dormouse - buzzes your phone when something needs attention. A hosted auto-pairing service comes - later, so you can close the laptop and walk away, no setup dance. - </p> - <p className="mb-4 text-lg leading-relaxed opacity-70"> - Open source and free to self-host, or pay a small monthly fee for our hosted version. Early adopters get a launch discount. - </p> - <NotifySignupForm /> - </div> - </section> - <footer className="border-t border-[var(--color-text)]/20 py-10"> <div className="mx-auto max-w-5xl px-4 md:px-6 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-base text-center opacity-50"> + <a href="/docs/dor" className="underline hover:opacity-100">CLI reference</a> + <a href="/docs/agent-skill" className="underline hover:opacity-100">Agent skill</a> + <a href="/docs/self-host" className="underline hover:opacity-100">Self-host</a> <a href="/supply-chain" className="underline hover:opacity-100">Supply Chain</a> <a href="https://github.com/diffplug/dormouse/issues" className="underline hover:opacity-100">Report an issue</a> <p> diff --git a/website/src/pages/PlaygroundDesktop.tsx b/website/src/pages/PlaygroundDesktop.tsx index 55c534878..beb3c510c 100644 --- a/website/src/pages/PlaygroundDesktop.tsx +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -23,6 +23,7 @@ import { PANE_SPLASH, type DesktopPaneSpec, } from "../lib/playground-desktop-layout"; +import { SITE_LINK_CLASS } from "../components/site-tokens"; type FakePtyAdapter = import("dormouse-lib/lib/platform/fake-adapter").FakePtyAdapter; type TerminalRegistry = typeof import("dormouse-lib/lib/terminal-registry"); @@ -74,7 +75,7 @@ function DesktopPlaygroundUnavailable() { This screen is too small to run the desktop playground, but it is perfect for trying the{" "} <Link to={POCKET_PLAYGROUND_PATH} - className="text-[var(--color-caramel)] underline-offset-2 hover:underline" + className={SITE_LINK_CLASS} > Pocket playground </Link> diff --git a/website/src/pages/SelfHostDocs.tsx b/website/src/pages/SelfHostDocs.tsx new file mode 100644 index 000000000..4789c68e3 --- /dev/null +++ b/website/src/pages/SelfHostDocs.tsx @@ -0,0 +1,50 @@ +/** + * `/docs/self-host` — the runbook half of SELF_HOST.md. + * + * The file is canonical in the repository: an assistant reads it there to walk + * someone through the install, and `scripts/deploy-lint.mjs` audits the + * Installer contract at the end of it against `deploy/local/`. The generator's + * delta withholds that contract and the assistant-directed sections; see + * docs/specs/website-docs.md -> `/docs/self-host` runbook. + */ +import { type MetaArgs } from "react-router"; +import { siteMeta } from "../lib/site-meta"; +import selfhost from "../data/docs.selfhost.json"; +import DocsLayout from "../components/DocsLayout"; +import { CODE_CLASS, LINK_CLASS } from "../components/docs-tokens"; +import MarkdownDocument, { type BlockNode } from "../components/MarkdownDocument"; + +export function meta({ location }: MetaArgs) { + return siteMeta(location.pathname, { + title: "Self-host — Dormouse", + description: + "Run the Dormouse coordinating server on your own machine, reachable only from your tailnet. One installer, no database, no account.", + }); +} + +const REPO_URL = "https://github.com/diffplug/dormouse"; + +export default function SelfHostDocs() { + return ( + <DocsLayout + activePath="/docs/self-host" + title={selfhost.title} + intro="Everything Dormouse needs for phone notifications and remote control runs on hardware you own. This is the runbook." + toc={selfhost.toc} + > + <p className="mb-8 rounded-lg border border-[var(--color-caramel)]/30 bg-[var(--color-caramel)]/[0.06] p-4 leading-relaxed opacity-80"> + You do not have to follow this by hand. Clone{" "} + <a href={REPO_URL} className={LINK_CLASS} target="_blank" rel="noopener noreferrer"> + the repository + </a> + , start Claude Code in it, and say{" "} + <code className={CODE_CLASS}> + read @SELF_HOST.md and walk me through it + </code> + . It will run the checkpoints below with you, one at a time. + </p> + + <MarkdownDocument blocks={selfhost.blocks as BlockNode[]} /> + </DocsLayout> + ); +} diff --git a/website/src/pages/SupplyChain.test.tsx b/website/src/pages/SupplyChain.test.tsx new file mode 100644 index 000000000..b2be33646 --- /dev/null +++ b/website/src/pages/SupplyChain.test.tsx @@ -0,0 +1,16 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import SupplyChain, { SUPPLY_CHAIN_TOC } from "./SupplyChain"; + +describe("supply chain table of contents", () => { + it("names every section heading the page renders, in page order", () => { + // The entries and the headings both come off SECTIONS, so this fails the + // moment one of them stops. Anchors resolving is checked for every rail + // page in website/src/lib/docs-rail.test.tsx. + const markup = renderToStaticMarkup(<SupplyChain />); + const rendered = [...markup.matchAll(/<h2 id="([^"]+)"[^>]*>([^<]*)<\/h2>/g)].map( + ([, id, text]) => [id, text], + ); + expect(SUPPLY_CHAIN_TOC.map((entry) => [entry.id, entry.text])).toEqual(rendered); + }); +}); diff --git a/website/src/pages/SupplyChain.tsx b/website/src/pages/SupplyChain.tsx index d080f3e2a..269b3ec9c 100644 --- a/website/src/pages/SupplyChain.tsx +++ b/website/src/pages/SupplyChain.tsx @@ -3,15 +3,24 @@ import { tv } from "tailwind-variants"; import cargoDeps from "../data/dependencies-cargo.json"; import npmDeps from "../data/dependencies-npm.json"; import runtimeDeps from "../data/dependencies-runtime.json"; -import SiteHeader, { STATIC_PAGE_HEADER_STYLE } from "../components/SiteHeader"; +import DocsLayout from "../components/DocsLayout"; +import { LINK_CLASS, SCROLL_MT_CLASS } from "../components/docs-tokens"; +import { type MetaArgs } from "react-router"; +import { type TocEntry } from "../lib/docs-pages"; +import { siteMeta } from "../lib/site-meta"; -// Single source of truth for caramel links, so links in body copy render at -// the same full brightness as links in the dependency tables. Body copy is -// dimmed with a text-color alpha (text-[…]/70) rather than `opacity`, since -// `opacity` would composite the link along with the surrounding text. -const link = tv({ - base: "text-[var(--color-caramel)] underline-offset-2 hover:underline", -}); +export function meta({ location }: MetaArgs) { + return siteMeta(location.pathname, { + title: "Supply chain — Dormouse", + description: + "Every dependency Dormouse ships, with its version, license, and author, generated from the lockfiles.", + }); +} + +// Wrapped in `tv()` so the tables can compose it. The docs recipe, not the +// site's caramel: this page follows the reader's theme, where caramel drops +// below WCAG AA (website/src/components/docs-tokens.ts). +const link = tv({ base: LINK_CLASS }); type PackageDependency = { name: string; @@ -115,137 +124,149 @@ function DirectCargoTable({ deps }: { deps: DirectCargoDependency[] }) { ); } -function DependencySection({ - title, - count, - description, - children, -}: { +type SupplyChainSection = { + /** Anchor the rail links, and the `<h2>`'s id. */ + id: string; title: string; count: number; description: string; - children: ReactNode; -}) { + table: ReactNode; +}; + +/** + * The page's sections, in order. + * + * One owner for the heading a reader sees, the anchor it carries, and the + * table under it, so the rail cannot name a section the page has renamed or + * dropped. Anchors are spelled out rather than slugged from the title, so + * rewording a heading does not silently break a link someone saved. + */ +const SECTIONS: readonly SupplyChainSection[] = [ + { + id: "bundled-runtime", + title: "Bundled Runtime", + count: runtimeDeps.length, + description: + "The Standalone app ships a bundled NodeJS, which bundles other components under their own licenses.\nThe VS Code extension bundles no runtime — it runs on the editor's own Electron Node.", + table: <PackageTable deps={runtimeDeps} />, + }, + { + id: "npm-dependencies", + title: "npm Dependencies", + count: npmDeps.length, + description: + "Runtime npm packages used by the Standalone app, the VS Code extension, and the coordinating server you run yourself to pair a phone with your laptop.", + table: <PackageTable deps={npmDeps} />, + }, + { + id: "direct-cargo-dependencies", + title: "Direct Cargo Dependencies", + count: cargoDeps.direct.length, + description: + "Crates declared directly in standalone/src-tauri/Cargo.toml, including build and target-specific dependencies.", + table: <DirectCargoTable deps={cargoDeps.direct} />, + }, + { + id: "transitive-cargo-dependencies", + title: "Transitive Cargo Dependencies", + count: cargoDeps.transitive.length, + description: + "Every crate the direct dependencies pull into the locked Tauri build graph, including build-time and platform-specific crates that aren't all linked into the final binary.", + table: <PackageTable deps={cargoDeps.transitive} />, + }, +]; + +/** This page's table of contents, off the list that titles its sections. */ +export const SUPPLY_CHAIN_TOC: TocEntry[] = SECTIONS.map((section) => ({ + id: section.id, + text: section.title, + children: [], +})); + +function DependencySection({ section }: { section: SupplyChainSection }) { return ( <section className="mt-12"> <div className="mb-4 flex flex-col gap-1 border-b border-[var(--color-text)]/10 pb-3 md:flex-row md:items-end md:justify-between"> <div> <div className="flex items-baseline gap-2"> - <h2 className="font-display text-xl">{title}</h2> - <div className="font-mono text-md opacity-50">({count})</div> + <h2 id={section.id} className={`${SCROLL_MT_CLASS} font-display text-xl`}>{section.title}</h2> + <div className="font-mono text-md opacity-50">({section.count})</div> </div> - <p className="text-sm opacity-60 whitespace-pre-line">{description}</p> + <p className="text-sm opacity-60 whitespace-pre-line">{section.description}</p> </div> </div> - {children} + {section.table} </section> ); } export default function SupplyChain() { return ( - <> - <SiteHeader activePath="/supply-chain" style={STATIC_PAGE_HEADER_STYLE} /> - - <div className="min-h-screen bg-[var(--color-bg)] text-[var(--color-text)] pt-24 pb-16"> - <div className="mx-auto max-w-6xl px-4 md:px-6"> - <h1 className="font-display text-[clamp(1.5rem,2.5vw+0.5rem,2.25rem)] mb-2"> - Supply Chain - </h1> - <p className="text-base text-[var(--color-text)]/70 mb-2"> - Dormouse is a terminal, so users trust it with shells, source trees, credentials, and - local files. Our security procedures are documented in full (and audited nightly and immediately before every release) in{" "} - <a - href={securityPolicyUrl} - className={link()} - target="_blank" - rel="noopener noreferrer" - > - SECURITY.md - </a>, here is a summary: - </p> - <ul className="text-base text-[var(--color-text)]/70 mb-2 list-disc space-y-1 pl-5"> - <li> - We wait at least 24 hours before adopting any newly published dependency. - </li> - <li> - Signing and auto-update secrets for the Standalone app are stored offline, never in CI. - </li> - <li> - Publishing secrets for the VS Code extension are stored in CI locked by two separate maintainer accounts. - </li> - </ul> - - <p className="text-base text-[var(--color-text)]/70 mb-2"> - All bundled libraries are listed below. Thank you to every author and contributor. - Thanks also to{" "} - <a - href="https://github.com/reowens/ascii-splash" - className={link()} - target="_blank" - rel="noopener noreferrer" - > - ascii-splash - </a>{" "} - and{" "} - <a - href="https://github.com/remix-run/react-router" - className={link()} - target="_blank" - rel="noopener noreferrer" - > - react-router - </a>{" "} - and their transitive dependencies, which power this marketing site but don't ship in the app, so they're not listed below. - </p> - <div className="grid gap-3 border-y border-[var(--color-text)]/10 py-4 text-sm md:grid-cols-3"> - <div> - <div className="font-mono text-2xl">{npmDeps.length}</div> - <div className="opacity-60">npm packages (direct and transitive)</div> - </div> - <div> - <div className="font-mono text-2xl">{cargoDeps.direct.length}</div> - <div className="opacity-60">Cargo crates (direct)</div> - </div> - <div> - <div className="font-mono text-2xl">{cargoDeps.transitive.length}</div> - <div className="opacity-60">Cargo crates (transitive)</div> - </div> - </div> + <DocsLayout activePath="/supply-chain" toc={SUPPLY_CHAIN_TOC}> + <p className="text-base text-[var(--color-text)]/70 mb-2"> + Dormouse is a terminal, so users trust it with shells, source trees, credentials, and + local files. Our security procedures are documented in full (and audited nightly and immediately before every release) in{" "} + <a + href={securityPolicyUrl} + className={link()} + target="_blank" + rel="noopener noreferrer" + > + SECURITY.md + </a>, here is a summary: + </p> + <ul className="text-base text-[var(--color-text)]/70 mb-2 list-disc space-y-1 pl-5"> + <li> + We wait at least 24 hours before adopting any newly published dependency. + </li> + <li> + Signing and auto-update secrets for the Standalone app are stored offline, never in CI. + </li> + <li> + Publishing secrets for the VS Code extension are stored in CI locked by two separate maintainer accounts. + </li> + </ul> - <DependencySection - title="Bundled Runtime" - count={runtimeDeps.length} - description={"The Standalone app ships a bundled NodeJS, which bundles other components under their own licenses.\nThe VS Code extension bundles no runtime — it runs on the editor's own Electron Node."} - > - <PackageTable deps={runtimeDeps} /> - </DependencySection> - - <DependencySection - title="npm Dependencies" - count={npmDeps.length} - description="Runtime npm packages used by the Standalone app, the VS Code extension, and the coordinating server you run yourself to pair a phone with your laptop." - > - <PackageTable deps={npmDeps} /> - </DependencySection> - - <DependencySection - title="Direct Cargo Dependencies" - count={cargoDeps.direct.length} - description="Crates declared directly in standalone/src-tauri/Cargo.toml, including build and target-specific dependencies." - > - <DirectCargoTable deps={cargoDeps.direct} /> - </DependencySection> - - <DependencySection - title="Transitive Cargo Dependencies" - count={cargoDeps.transitive.length} - description="Every crate the direct dependencies pull into the locked Tauri build graph, including build-time and platform-specific crates that aren't all linked into the final binary." - > - <PackageTable deps={cargoDeps.transitive} /> - </DependencySection> + <p className="text-base text-[var(--color-text)]/70 mb-2"> + All bundled libraries are listed below. Thank you to every author and contributor. + Thanks also to{" "} + <a + href="https://github.com/reowens/ascii-splash" + className={link()} + target="_blank" + rel="noopener noreferrer" + > + ascii-splash + </a>{" "} + and{" "} + <a + href="https://github.com/remix-run/react-router" + className={link()} + target="_blank" + rel="noopener noreferrer" + > + react-router + </a>{" "} + and their transitive dependencies, which power this marketing site but don't ship in the app, so they're not listed below. + </p> + <div className="grid gap-3 border-y border-[var(--color-text)]/10 py-4 text-sm md:grid-cols-3"> + <div> + <div className="font-mono text-2xl">{npmDeps.length}</div> + <div className="opacity-60">npm packages (direct and transitive)</div> + </div> + <div> + <div className="font-mono text-2xl">{cargoDeps.direct.length}</div> + <div className="opacity-60">Cargo crates (direct)</div> + </div> + <div> + <div className="font-mono text-2xl">{cargoDeps.transitive.length}</div> + <div className="opacity-60">Cargo crates (transitive)</div> </div> </div> - </> + + {SECTIONS.map((section) => ( + <DependencySection key={section.id} section={section} /> + ))} + </DocsLayout> ); } diff --git a/website/src/root.tsx b/website/src/root.tsx index 91d52f587..dc5408d72 100644 --- a/website/src/root.tsx +++ b/website/src/root.tsx @@ -5,7 +5,21 @@ import { Outlet, Scripts, ScrollRestoration, + type MetaArgs, } from "react-router"; +import { siteMeta } from "./lib/site-meta"; + +/** + * Every page's title, description, canonical, and social cards. + * + * A page that wants its own overrides this by exporting `meta` and calling + * `siteMeta` itself; one that does not inherits these, still with a canonical + * pointing at its own path. None of it may move into `<head>` below — see + * website/src/lib/site-meta.ts for what that broke. + */ +export function meta({ location }: MetaArgs) { + return siteMeta(location.pathname); +} export function Layout({ children }: { children: ReactNode }) { return ( @@ -13,32 +27,11 @@ export function Layout({ children }: { children: ReactNode }) { <head> <meta charSet="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - - <title>Dormouse — A dormouse knows when to wake up - - - - - - - - - - - - - - - - - - - diff --git a/website/src/routes.ts b/website/src/routes.ts index 047a260b4..f9a33df4a 100644 --- a/website/src/routes.ts +++ b/website/src/routes.ts @@ -3,6 +3,7 @@ import { route, type RouteConfig, } from "@react-router/dev/routes"; +import { DOCS_PAGES } from "./lib/docs-pages"; export default [ index("./pages/Home.tsx"), @@ -10,7 +11,9 @@ export default [ route("playground/desktop", "./pages/PlaygroundDesktop.tsx"), route("playground/pocket", "./pages/PocketPlayground.tsx"), route("pocket", "./pages/Pocket.tsx"), - route("changelog", "./pages/Changelog.tsx"), + ...DOCS_PAGES.map((page) => route(page.path.slice(1), page.module)), + // Not in the rail: the standalone updater deep-links it after an update + // (standalone/src/updater.ts), so it is a parameterized view of the + // changelog rather than a page of its own. route("changelog/after/:version", "./pages/ChangelogAfter.tsx"), - route("supply-chain", "./pages/SupplyChain.tsx"), ] satisfies RouteConfig; diff --git a/website/vite.config.ts b/website/vite.config.ts index 6cfba38ed..fc7502de7 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -1,12 +1,52 @@ -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import { reactRouter } from "@react-router/dev/vite"; import tailwindcss from "@tailwindcss/vite"; +import fs from "node:fs"; import path from "node:path"; +/** + * Applies `public/_redirects` in dev, the way the host does in production. + * + * That file is a deploy artifact: Vite copies it into the build and never + * reads it, so `/docs` — which exists only as a redirect — answered 404 on the + * dev server while working everywhere else. The header's own **Docs** link + * pointed at it, so the entrypoint's only feedback loop was production. + * + * Reads the file per request rather than at startup, so editing a rule takes + * effect without a restart, and handles only the `3xx` rules — the `200` + * rewrite is the SPA fallback, which the dev server already does. + */ +function redirectsInDev(): Plugin { + return { + name: "dormouse-redirects-in-dev", + apply: "serve", + configureServer(server) { + const file = path.resolve(import.meta.dirname, "public/_redirects"); + server.middlewares.use((req, res, next) => { + const url = req.url?.split("?")[0]; + if (!url || !fs.existsSync(file)) return next(); + for (const line of fs.readFileSync(file, "utf8").split("\n")) { + const [from, to, status] = line.trim().split(/\s+/); + if (!from || from.startsWith("#") || !to) continue; + const code = Number(status); + if (!Number.isInteger(code) || code < 300 || code > 399) continue; + if (from !== url) continue; + res.statusCode = code; + res.setHeader("location", to); + res.end(); + return; + } + next(); + }); + }, + }; +} + export default defineConfig(({ mode }) => ({ plugins: [ mode === "test" ? null : reactRouter(), tailwindcss(), + redirectsInDev(), ], resolve: { alias: {