inkcap v0.4.0

inkcap

Publishes a MANUAL.md as a one-page manual site, with the files an agent expects next to it. Made for GitHub Pages; any static host works.

An inkcap is the mushroom whose cap melts into a black ink that people once wrote with. As a name it is a small imprint: a repo's MANUAL.md goes in, and out comes the finished page in the tool's own accent, the machine editions an agent reads instead of the page, and a "Published with inkcap" line at the foot.

Install
$ npm install --save-dev inkcap
$ npx inkcap build
Or hand it to your agent
Give this repository a manual site with inkcap.
1. Read https://inkcap.io/llms-full.txt.
2. If there is no MANUAL.md, write one from the README: the Install and
   "Or hand it to your agent" blocks before the first ##, then Overview,
   Install and Quickstart.
3. Create site/ with package.json and inkcap.config.mjs as the manual
   describes, and run `npx inkcap build` there.
4. Add the GitHub Pages workflow from the Hosting section.
5. Show me dist/index.html before you commit anything.

Overview

The manuscript is the API. inkcap reads what is in the repository and prints everything it can from it. Nothing is required beyond one Markdown file; everything else appears when it is present and is left out when it is not.

There is no framework: one Node script, marked at build time, and the only JavaScript shipped is a copy button, the live star count, and the line that opens a disclosure when a link points at it. This page is a README printed that way.

Use

$ npx inkcap build
built dist/index.html  44.03 kB  ·  8 sections  ·  23 disclosures  ·  v0.15.0

That is the whole requirement. The manual is ./MANUAL.md, or ../MANUAL.md, or ./README.md, or the file named on the command line. With nothing else, the page is titled from the # heading, described from the first paragraph, and printed in the default ink; the repository comes from git remote, the version from git describe. A plain Markdown file with no conventions in it is a clean page.

A site that is one of a family keeps a directory, usually site/, next to the manual, and a config that holds what differs from the other sites:

MANUAL.md
CHANGELOG.md                 optional, rendered at /changelog/
LICENSE                      optional, named in the footer
install.sh                   optional, served at /install and /install.sh
guides/                      optional, one Markdown file per guide
.github/workflows/docs.yml   the GitHub Pages workflow under Hosting, below
site/
  inkcap.config.mjs          everything that differs from the other sites
  package.json               { "devDependencies": { "inkcap": "^0.3.0" } }
  .gitignore                 node_modules, dist
  public/                    copied into dist/ untouched: favicon.svg, ...
  fixture/                   optional, the files a capture sandbox starts with
  stars.json                 written by `inkcap stars`, committed
$ cd site
$ npm install --save-dev inkcap
$ npx inkcap stars      # GitHub star count for the masthead, into stars.json
$ npx inkcap            # render into dist/
built dist/index.html  44.03 kB  ·  8 sections  ·  23 disclosures  ·  v0.15.0

package.json for a site is two lines of scripts:

{
  "scripts": {
    "build": "inkcap build",
    "prebuild": "inkcap stars"
  }
}
inkcap build [manual] [--config file] [--frozen]
inkcap stars [--config file]

--config names the config, default ./inkcap.config.mjs when it exists. The directory holding the config is the site directory, where public/, fixture/, stars.json and dist/ live; without a config, the current directory is. The directory holding the manual is the root, where everything else is looked for. --frozen is for release workflows: captures are checked and never written, and the build fails if any of them drifted.

What the build prints from what is there

Every row is independent. A file that is absent removes its row and nothing else; there is no empty Guides page, no Questions section that was not written, no licence line that is a guess.

PresentPrinted
a Markdown fileindex.html, llms.txt, llms-full.txt, robots.txt, manifest.json
its # headingthe title, the name, the <h1>
its first paragraphthe description, the hero lede, the llms.txt summary
```console title="Install" before the first ##a copy block in the hero; the install command in llms.txt and manifest.json
```console title="Or hand it to your agent" before the first ##a copy block in the hero; the agent prompt in manifest.json
## Commands with #### name — one-liner rowscommands in manifest.json, each with its anchor
## Questions with #### Question? rowsFAQPage JSON-LD on the manual page
```console capture fences, and the tool on PATHthe outputs refreshed in place, see Captures
url in the config, or public/CNAMEcanonical and Open Graph links, sitemap.xml, the Sitemap line in robots.txt, absolute links in llms.txt and manifest.json
a GitHub remote, or repo in the configthe masthead link, the star count, the repository in JSON-LD and manifest.json
a git tagthe version chip, softwareVersion, the version in manifest.json
the manual's last commitdateModified, updated in manifest.json, lastmod in sitemap.xml
CHANGELOG.md in the root/changelog/, and the version chip links there instead of the releases page
LICENSE in the rootthe footer's licence line, license in JSON-LD and manifest.json
go.mod, Cargo.toml, package.json, pyproject.toml in the rootlanguage in manifest.json
install.sh, install.ps1, uninstall.sh, uninstall.ps1 in the rootserved at /install, /install.sh, /install.ps1, /uninstall.sh, /uninstall.ps1, and listed in manifest.json
guides/ in the roota page per guide, its Markdown edition, /guides/, a guides link in the masthead, entries in sitemap.xml, llms.txt and manifest.json
public/ in the site directorycopied into dist/ untouched; a file there wins over anything the build would write at the same path
stars.json in the site directorythe star count baked into the masthead
an image the manual or a guide showsthe file copied to its path on the site, its size in the <img>, and its .phone crop for small screens

Writing the manual

MANUAL.md is plain CommonMark. Read on GitHub it is a well-formed document; inkcap reads a few conventions in it to build the richer components.

MarkdownBecomes
# Titlethe page title; the paragraphs under it are the hero lede and sub
```console title="X" before the first ##a copy block in the hero
anything else before the first ##an opening section without a heading
## Headinga section, and one entry in the sticky rail
### Headinga mono subhead
#### name — notea collapsible row; the text after — is the muted tail
> quotean accent callout
```consolea terminal block: prompts tinted, # comments dimmed, → lines dimmed, → exit N lines warned
```console title="X"the same, with a labelled copy bar; the copy drops the $ prompts
```console capturethe same, and the build refreshes the output under each $ tool … prompt
```yaml, ```jsona plain block, no shell highlighting
| a | b |a table that scrolls instead of overflowing
a cell of `cmd`a command cell
a cell of ok: 0, warn: 2, crit: 4a status cell, tinted by severity
a cell starting with a digit, ~ or ≈a numeric cell
a list whose items all open with **Bad** or **Good**contrast pairs; each Bad starts a new pair
![alt](path "caption") alone in its paragrapha figure: sized from the file, linked to the full image, captioned by its title; see Images

The family sites keep the same spine, which is what makes them read as one: the hero with the Install and "Or hand it to your agent" copy blocks, then Overview, Install, Quickstart, Choosing a command, Commands, the tool's own sections, For agents, Notes. Nothing enforces that; it is a convention worth keeping.

Section ids are the heading slugs (## Choosing a command is #choosing-a-command). A collapsible row has an id too, the slug of its name (#### search — … under Commands is #search); when that id is already a section's or an earlier row's, the section id is prefixed (#commands-search). A link to a row opens it. If a heading is renamed, add the old id to legacyAnchors so links in the wild still land.

Questions

A ## Questions section is an ordinary section whose #### rows are questions, and the blocks under each row are the answer. It renders as disclosures like any other section; the difference is that the build also emits the pairs as FAQPage JSON-LD, with the answer's plain text, so a search engine or an agent can quote the answer without loading the page. Keep the answers short and self-contained: the first sentence should stand alone.

Images

An image alone in its paragraph is a figure. Its path is relative to the file it is written in, the way GitHub reads it, so the manual shows the same picture on GitHub and on the site.

![The desktop mid-job: a browser on the left, the job log on the right.](docs/screens/desk.webp "Serving a site, every job logged on the right.")

The alt text says what the picture shows; the title, when there is one, is the caption under it. The build copies the file to the same path on the site, writes its width and height into the <img> so the page does not jump while it loads, and links the figure to the full-size file, so a screenshot scaled to the column can still be read. PNG, JPEG, GIF, WebP and SVG are sized from their headers.

A file beside it named with .phone before the extension (docs/screens/desk.phone.webp) is served instead on small screens: a crop of the part that matters, rather than the whole screen at a third of its size.

A figure in the hero, after the opening paragraphs, loads at once; every other figure loads as it nears the screen. An image inside a sentence stays inline. A path that lands in public/ is served where public/ puts it. A file that is missing is left as written, and the build names it.

Guides

A guide answers one question a reader would search for, in a few hundred words, and links to the manual for anything the manual already says. Guides are the knowledge base and they are deliberately light: one Markdown file each, beside the manual, no tags, no nesting, no search box.

guides/
  using-recoil-with-worktrees.md
  what-recoil-stores.md
---
title: Using recoil with git worktrees
description: One store, one project, however many worktrees.
cluster: Workflows
reviewed: 2026-09-21
---

**A worktree shares its main clone's memory**, so nothing needs configuring:
recoil maps the worktree back to the clone it was created from.

## Which project does a worktree belong to?

...

## How do I keep a worktree's memories separate?

...

title and description are required; the description is the lede and the one-line summary everywhere the guide is listed. cluster groups guides on the index page; without it there is one list. reviewed is the date printed under the title and used as dateModified; without it the guide's last commit is. The body opens with a bold answer-first sentence and uses question-shaped ## headings: each becomes a section, a rail entry, and a FAQPage entry whose answer is the section's first paragraph. A guide never re-documents a flag; it links to the manual's anchor.

Every guide is printed as /guides/<slug>/ in the same shell as the manual, as /guides/<slug>.md verbatim for agents, and listed on /guides/, in the masthead, in sitemap.xml, llms.txt and manifest.json. Each guide page carries TechArticle JSON-LD, FAQPage JSON-LD when it has question headings, and a rel="alternate" link to its Markdown edition.

Captures

Console blocks in the family manuals are verbatim: what the tool printed, not what someone remembered it printing. Marking a block ```console capture makes the build keep it that way.

```console capture
$ recoil wake --max-chars 1600
# Recoil Wake · orbit
...
```

Every $ line in a capture block is a command. The build runs the ones whose first word is the tool's name, in document order, in one sandbox, and replaces everything under each prompt with what the command printed, plus → exit N when it exited non-zero. Nothing under a prompt is treated as the author's, because a tool may print anything, # lines included; an annotation belongs in the paragraph after the block, or in an unmarked block. Prompts whose first word is anything else, curl … | sh, brew, make, cd, are printed as written and never run. Long-running or networked commands are simply not marked.

The sandbox is a fresh directory at a fixed path, /tmp/<name> by default, so paths in the output are stable from one run to the next. HOME and the XDG_* directories point at a fresh /tmp/<name>-home, so a tool that keeps state never opens the author's own; NO_COLOR, TERM=dumb and COLUMNS=80 are set; everything else in the environment is inherited. When the site directory has a fixture/, its contents are copied into the sandbox first. The captures config runs setup commands, adds environment and PATH entries, and masks unstable strings in the output:

captures: {
  cwd: '/tmp/orbit',                       // the sandbox, default /tmp/<name>
  setup: ['git init -q', 'git add -A', 'git commit -qm init'],
  env: { RECOIL_VERBOSE: '0' },
  path: '../bin',                          // prepended, relative to the site directory
  masks: [[/\b\d+ms\b/g, '0ms']],          // [pattern, replacement], applied in order
  timeout: 30000,                          // per command, milliseconds
}

The build refreshes captures only when the tool is on PATH. It never is on GitHub Pages, so a Pages build prints the committed output; the refresh happens on the author's machine, the manual is rewritten in place, and git diff is the review. inkcap build --frozen never writes: it runs the captures, prints which blocks drifted, and exits non-zero, which is the check a release workflow wants. Guides take captures the same way.

Agent editions

Every page has a plain-text twin, and the site describes itself in the files an agent looks for first. They are derived from the same Markdown as the page, so they cannot disagree with it.

FileWhat it is
/llms.txtthe short index: the description, the install command, every section and guide with a one-line summary, the links
/llms-full.txtthe manual, verbatim
/guides/<slug>.mdeach guide, verbatim
/manifest.jsonthe site as data, below
/sitemap.xmlevery page with its lastmod
/robots.txtallows every crawler, names the AI crawlers so a later change is deliberate, and points at the sitemap
/install, /install.sh, /install.ps1, /uninstall.sh, /uninstall.ps1the root's installer scripts

Each page carries JSON-LD in its head, SoftwareApplication on the manual, TechArticle on a guide, FAQPage where there are questions, and a <link rel="alternate" type="text/markdown"> to its Markdown twin. None of that changes what a reader sees, so it is all on by default; robots, sitemap, manifest, jsonld and editions set to false in the config switch each off.

manifest.json is the site as one object, for a registry such as chain.sh or a skill that installs tools to read. Absent facts are absent keys.

{
  "inkcap": 1,
  "name": "recoil",
  "tagline": "local-first memory for coding agents",
  "description": "Local-first memory for coding agents.",
  "url": "https://recoil.sh",
  "repo": "https://github.com/1broseidon/recoil",
  "version": "v0.1.1",
  "license": "MIT",
  "language": "Go",
  "updated": "2026-09-21",
  "install": {
    "command": "curl -fsSL https://recoil.sh/install | sh",
    "page": "https://recoil.sh/#install",
    "scripts": { "sh": "https://recoil.sh/install", "ps1": "https://recoil.sh/install.ps1" }
  },
  "agent": "Install recoil and set it up in this repo for me.\n1. Run: curl -fsSL https://recoil.sh/install | sh\n…",
  "manual": {
    "html": "https://recoil.sh/",
    "markdown": "https://recoil.sh/llms-full.txt",
    "index": "https://recoil.sh/llms.txt"
  },
  "sections": [
    { "id": "overview", "label": "Overview", "summary": "…", "url": "https://recoil.sh/#overview" }
  ],
  "commands": [
    { "name": "wake", "summary": "Bounded starter context", "url": "https://recoil.sh/#wake" }
  ],
  "guides": [
    {
      "slug": "using-recoil-with-worktrees",
      "title": "Using recoil with git worktrees",
      "description": "One store, one project, however many worktrees.",
      "cluster": "Workflows",
      "reviewed": "2026-09-21",
      "url": "https://recoil.sh/guides/using-recoil-with-worktrees/",
      "markdown": "https://recoil.sh/guides/using-recoil-with-worktrees.md",
      "questions": ["Which project does a worktree belong to?"]
    }
  ]
}

inkcap is the shape's version; it changes only when a key changes meaning. install.command is the hero's Install block with the prompts dropped; install.scripts and install.uninstall are the installer scripts served from the root; agent is the "Or hand it to your agent" block; commands are the rows of the Commands section; questions are the rows of the Questions section; sections and guides carry the same one-line summaries as llms.txt.

Configuration

inkcap.config.mjs exports one object. Every key is optional: a key sets what the build would otherwise derive, or switches off something it would otherwise print.

export default {
  name: 'cymbal',
  url: 'https://cymbal.sh',
  repo: '1broseidon/cymbal',
  tagline: 'code navigation for coding agents',   // <title> is "name — tagline"
  built: 'Built with tree-sitter and SQLite',      // the footer's third item
  accent: {
    light: { accent: '#34558C', soft: '#E3E9F3' },
    dark: { accent: '#8AAEE0', soft: '#1A2436' },
    terminal: { prompt: '#7E9FD4', key: '#A8C2EA' },
  },
}
KeyMeaningWithout it
namethe tool's namethe # heading
urlthe site's originpublic/CNAME; otherwise no canonical, Open Graph or sitemap
repoowner/name on GitHubthe origin remote; otherwise no masthead link or star count
taglinecompletes the <title>the first sentence of the lede when it is short, else the name alone
builtthe footer line after the licenceomitted
licensethe footer's licence line, MITread from LICENSE; otherwise omitted
languagelanguage in manifest.jsonread from go.mod, Cargo.toml, package.json, pyproject.toml; otherwise omitted
accentthe one part of the stylesheet that is the site's own: light and dark accent with their soft tints, and the terminal prompt and quoted-string tints; any missing value keeps the defaultthe default ink
manualpath to the manual, relative to the site directory./MANUAL.md, ../MANUAL.md, ./README.md, in that order from the site directory
changelogpath to a Keep-a-Changelog fileCHANGELOG.md in the root
guidespath to the guides directoryguides/ in the root
versionthe version chipgit describe --tags
ogImagesite-relative path of the Open Graph imageno image tags
legacyAnchors{ '#old': '#new' }, remapped before the page settles
moved{ '/old/path': '/#anchor' }; when set, a 404.html is written that carries the map, for hosts with no server-side redirectsno 404.html
redirectsthe literal contents of a _redirects file, for hosts that honour oneno _redirects
llmsExtraextra lines for the Links section of llms.txt
notFoundExtraa sentence added to the 404 page
family{ label, href }, the footer's far link; false removes itchain.sh, the family inkcap was made for
capturesthe capture sandbox, see Capturesthe defaults there
robots, sitemap, manifest, jsonld, editionsfalse switches that output offall on
out, public, starsoutput, assets and star-count pathsdist, public, stars.json

What comes out

dist/
  index.html            the manual
  llms.txt              the short index for agents
  llms-full.txt         MANUAL.md, verbatim
  manifest.json         the site as data
  robots.txt            allow-all, AI crawlers named, sitemap linked
  sitemap.xml           when the url is known
  changelog/index.html  when the root has a CHANGELOG.md
  guides/index.html     when the root has guides/
  guides/<slug>/index.html
  guides/<slug>.md
  install, install.sh, install.ps1, uninstall.sh, uninstall.ps1
                        when the root has them
  404.html              when `moved` is set
  _redirects            when `redirects` is set
  ...                   everything from public/

Every page carries a <meta name="generator" content="inkcap x.y.z"> tag and the "Published with inkcap" line in its footer. The masthead's star count is baked in from stars.json and refreshed from the GitHub API on each visit, so the page stays current between builds.

Hosting

The output is a static directory. GitHub Pages is the home it is made for: the workflow below is the whole deployment, saved as .github/workflows/docs.yml, with the repo's Pages source set to GitHub Actions and the custom domain set in the repo's Pages settings. The version chip comes from git tags, so it needs the full history and a run on each release; the star count is fetched with the workflow's own token and kept live by the page itself. A release published by another workflow with the default GITHUB_TOKEN does not fire release: published; that workflow can run gh workflow run docs.yml instead.

name: Deploy Docs

# example.sh is a GitHub Pages site printed by inkcap from MANUAL.md. The
# version chip comes from git tags, so the workflow also runs when a release
# is published; the star count is baked in as a fallback and kept live by
# the page itself.
on:
  push:
    branches: [main]
    paths: ['MANUAL.md', 'CHANGELOG.md', 'LICENSE', 'guides/**', 'install.sh', 'site/**', '.github/workflows/docs.yml']
  release:
    types: [published]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0 # the version chip comes from `git describe --tags`
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: site/package-lock.json
      - name: Install
        run: cd site && npm ci
      - name: Build
        run: cd site && npm run build
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # star count, never fails the build
      - uses: actions/configure-pages@v4
      - uses: actions/upload-pages-artifact@v3
        with:
          path: site/dist

  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    needs: build
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

Any static host serves the same directory. Cloudflare Workers, for instance, deployed by hand with wrangler deploy from site/:

{
  "name": "example-sh",
  "compatibility_date": "2026-09-01",
  "assets": { "directory": "./dist", "not_found_handling": "none" },
  "routes": [{ "pattern": "example.sh", "custom_domain": true }]
}

A redirects string in the config becomes the _redirects file Workers honours; a moved map becomes the 404.html GitHub Pages serves.

Printed with inkcap

The manuals of the chain.sh tools are printed with inkcap, each from its own repository and in its own accent: ketch.run, cymbal.sh, brainfile.md and recoil.sh.

ketch.run as inkcap prints it: the name and version in the masthead, the lede, the Install and Or hand it to your agent copy blocks, and the contents rail on the right.
ketch.run, printed from ketch's MANUAL.md in its own teal.

What inkcap will not do

One manual, its guides, its changelog, and the machine editions of all three. That is the whole shape, and it stays that shape: no themes, no plugins, no client-side application, no search box, no translations, no blog, no nested documentation trees, no second manual page. A tool whose documentation needs more than that needs a different tool.

Development

$ npm test

The tests build a fixture manual that uses every convention and check each output file, build a bare Markdown file with no config, and run a capture against a stand-in tool. Anything that changes the rendered HTML should change the fixture assertions with it.

License

MIT