@wilsoon/docs
v1.4.0
Published
Markdoc schema, renderer and publishing CLI for docs.wilsoon.dev
Maintainers
Readme
@wilsoon/docs
The Markdoc schema, the renderer, and the docs CLI behind docs.wilsoon.dev.
It exists so a repository can keep its documentation as files next to the code it describes, and publish it with one command. It is not a general-purpose tool - it publishes to one site, and pushing to that site needs a token only I hold. If you found this on the registry, the renderer and the Markdoc schema may still be of interest; the CLI will not be.
Writing docs with an agent? Run docs guide, or read
AGENTS_PACKAGE_GUIDE.md,
which ships inside the package. It states every export, every component, every
fatal rule and every refusal in one place, so nothing has to be inferred from the
source.
Install
npm install @wilsoon/docsRequires Node 22.12 or newer - the CLI uses process.loadEnvFile, and hashing
goes through Web Crypto rather than node:crypto so the same file runs in a
Worker, in Node and in a browser.
react and react-dom (>= 19) are peer dependencies. npm 7+ installs them
automatically. pnpm does not - add them explicitly, or docs preview fails on
import, since it renders with the same React components the site uses.
Quick start
npx docs init --project my-app # scaffold docs/ and a config
npx docs login # sign in with WilsoonID, once per machine
npx docs check # is what I wrote valid?
npx docs preview # what will it look like?
npx docs push # publish itdocs init writes docs/docs.config.mjs, one chapter, one page, and a
.gitignore for the preview cache.
Commands
| | | Effect |
| -------------- | ---------------------------------------- | ------------------------------------- |
| docs schema | Print the tags you can write | Nothing |
| docs check | Validate every page | Nothing |
| docs preview | Serve the docs locally | Nothing, beyond a cached stylesheet |
| docs status | Show what a push would do | Nothing |
| docs login | Sign this machine in with WilsoonID | Stores a token on this machine |
| docs whoami | Show which account a push would use | Nothing |
| docs logout | Forget the token on this machine | Removes a local file |
| docs init | Scaffold docs/ | Creates files |
| docs pull | Bring the site's version into your files | Overwrites files |
| docs push | Publish what changed in code | Writes to the site. Never deletes |
| docs prune | Delete live pages with no file | PERMANENTLY deletes |
docs <command> help gives the full options and an EFFECT block for each.
docs check exits non-zero, so docs check && docs push refuses to publish
anything invalid.
Signing in
docs login opens a browser, authenticates against the same identity provider
the dashboard uses, and stores a token for this machine. There is no shared
secret to distribute, and a push is attributed to the account that made it.
The token is scoped to one endpoint, expires after 90 days, and is stored in your
user config directory (%APPDATA%\wilsoon-docs on Windows, ~/.config/wilsoon-docs
elsewhere) with owner-only permissions. Revoke it — or any other machine's — from
CLI tokens in the dashboard. docs logout only removes the local copy; it
does not revoke anything.
What you may push is decided per project by its editor list, never by holding a token. Signing in proves who you are; it grants nothing on its own.
CI
A pipeline has no browser, but it does not need one — you have one. Create a
deploy token from CLI tokens in the dashboard, and paste it into your
pipeline's secret store as DOCS_TOKEN. It is shown once and stored as a hash.
A deploy token is scoped to one project, and the project comes from the token, not from the request. A push naming any other project is refused before anything is read — so a compromised runner reaches exactly one project, whatever it sends. A pipeline that publishes two projects needs two tokens.
It differs from a docs login token in two further ways, both because it gets
pasted somewhere and forgotten:
- It can be set to never expire. A build that breaks on a timer is its own kind of outage. 90 days, 1 year and never are offered; 1 year is the default.
- It is always role
user, whatever your own role is, so it can never create or claim a project.
It also stops working the moment the account that created it stops being an editor of that project — no revocation needed. Revoke it from the same page when the pipeline is retired.
Worked example
Say the FederatedIdentityProvider repo publishes the wilsoon-id project.
- Open https://docs.wilsoon.dev/admin/tokens and choose New deploy token.
- Project wilsoon-id, label it after the pipeline that will hold it —
github actions - FederatedIdentityProvider— and pick an expiry. - Copy the token. It is shown once; it is stored as a hash and cannot be recovered.
- In that repo: Settings → Secrets and variables → Actions → New repository
secret, named
DOCS_TOKEN.
Then the workflow needs nothing else:
name: Publish docs
on:
push:
branches: [main]
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npx @wilsoon/docs check
- run: npx @wilsoon/docs push
env:
DOCS_TOKEN: ${{ secrets.DOCS_TOKEN }}docs check first, so an invalid page fails the build instead of publishing.
The token names wilsoon-id, and docs.config.mjs in that repo names it too. If
they ever disagree the push is refused, naming both — the token wins, because it
is the half the pipeline cannot edit.
Working on that repo yourself needs none of this. Run docs login once on
your machine and push; a deploy token is only for the unattended case. And never
commit one — not to docs.config.mjs, not to a .env that is tracked.
The credential is resolved in this order:
DOCS_TOKENfrom the environment- the stored login for this endpoint, from
docs login
init, schema, check, preview and logout need no credential at all.
Config
docs.config.mjs describes the project itself. Only project and endpoint are
required.
export default {
// Permanent. It is the project's URL on the site.
project: "node-oidc-kit",
title: "Wilsoon Node OIDC Kit",
description: "An OIDC client for Node, with sessions that survive a restart.",
repository: "https://github.com/Wilsoon7721/node-oidc-kit",
endpoint: "https://docs.wilsoon.dev",
dir: ".",
};| Field | Effect |
| ------------- | ------------------------------------------------------- |
| title | The project's name on the site |
| description | One sentence, shown under the title on the home page |
| repository | Linked from the home page, with a GitHub or GitLab icon |
repository must be an https:// URL on github.com or gitlab.com, without
the .git suffix - the site only links hosts it vouches for, so anything else is
refused at push time rather than stored and silently never rendered. docs init
fills it in from your git remote, so it is usually already correct.
These three are pushed once per run, not per section, and only when the config
mentions them. A config with no description key leaves the column alone; a
config with description: '' clears it. That distinction is what stops a push
from erasing something written in the dashboard.
Changing only
titleordescriptionstill publishes. Before 1.1.0 the title travelled inside a section payload, so editing it without touching a page meant every hash still matched, nothing was pushed, and the new title never arrived - whiledocs pushreported that everything was already published.
On disk
Folder is a chapter, file is a section. Two levels only - the database has exactly two, and a third is refused rather than silently flattened.
docs/
docs.config.mjs
01-getting-started/
_chapter.md # chapter title and order
01-introduction.md
02-installation.md
assets/ # images and video, beside the pages using themNN-prefixes set the order and are stripped from the identifier.assets/inside a chapter is expected and passed over in silence. A nested folder holding.mdfiles warns, because those pages are ignored.- Two folders reducing to one chapter identifier -
guide/and01-guide/- is a fatal error, not a merge.
Frontmatter is a contract
---
title: Installation
identifier: installation
order: 2
---Never change identifier: on a page that has been published. It is the URL and
it is permanent. Changing it does not rename the page - it publishes a second one
and orphans the first. Renaming the file is safe as long as the identifier stays
put; that is what identifier: is for.
title and order can change freely.
Media
Write the path you can see on disk:
{% image src="./assets/diagram.png" alt="How a page reaches the database" /%}
{% video src="./assets/walkthrough.mp4" caption="Publishing a page" /%}docs push uploads the file and rewrites src to a /media/... path on the way
out. The file on disk keeps its relative path - do not "fix" it to a /media/
one by hand. A page that came down through docs pull already carries absolute
paths, because that is what the site stores; leave those alone.
Alt text is required on images. Pass alt="" for a decorative one - that is the
correct value, and it is explicit.
Keys are content-addressed, so re-pushing an unchanged image is a no-op and two
pages referencing one picture share one object. Images are capped at 10MB, video
at 95MB, mp4 and webm only.
Markdoc syntax inside a code fence stays literal, so a page can document the
syntax without the example being interpreted - and push skips fenced regions
when uploading and rewriting media for the same reason.
The lockfile
docs.lock.json is committed. It records what was last published, and it is
what lets the CLI say who changed a page rather than only that it differs.
Each entry holds two hashes, and they are not interchangeable:
hash- the body on disk when it was pushed, compared against localpublished- what the site was given, compared against remote
They differ whenever a media path was rewritten. published: null is a real
observation - the site reported no hash - and is not the same as the key being
absent, which means a lockfile written before the two were split.
A push refuses any page edited in the dashboard since your last push, and names
it. --force <key> overrides one deliberately; there is no global force.
Programmatic use
The renderer is the same one the site and the dashboard use, which is the point of it living here - a page pushed from a repo and the same page edited in the dashboard produce byte-identical rows, including the search text.
import { renderSource, blocking } from "@wilsoon/docs/render";
import { toReact, asProseRoot } from "@wilsoon/docs/render/react";
import { tags, config } from "@wilsoon/docs/markdoc";
import { sha256Hex } from "@wilsoon/docs/hash";
const { tree, headings, diagnostics } = await renderSource(source);
if (blocking(diagnostics).length) throw new Error("invalid");| Entry | What it is |
| --------------------------------- | ---------------------------------------------------------------------------- |
| @wilsoon/docs/markdoc | The tag schema. No React, so a CLI can import it alone |
| @wilsoon/docs/render | parse → validate → transform → headings → tables → highlight |
| @wilsoon/docs/render/react | toReact(tree, React) - React is passed in, so there is never a second copy |
| @wilsoon/docs/render/components | Callout, CodeBlock, Figure, Aside, Video |
| @wilsoon/docs/render/text | Plain text for search, so every writer ranks identically |
| @wilsoon/docs/hash | One sha256, shared with the Worker and with Postgres |
Plain ESM, no build step: the same files are imported by the site through Vite, by
the dashboard island, and by the CLI through bare Node. The render components use
createElement rather than JSX for that reason - Node cannot load .jsx, and a
build step would mean the site importing from dist/, where a stale build is a bug
that looks exactly like a code bug.
There are no type declarations. The package is plain JavaScript with JSDoc.
Known limitations
- A
docs logintoken carries the role you had when you created it. Roles are snapshotted at login, because a token outlives the browser session it came from. The role only decides who may create or claim a project, and the snapshot is bounded by the 90-day expiry — but a demoted admin keeps that ability until their token expires or is revoked. CI tokens are unaffected: they are pinned touserat creation and can never create or claim. - A CI token with no expiry ends only when revoked. That is the point of the option, but it means a forgotten one stays live indefinitely. The tokens page shows when each was last used, so an unused one is visible.
- A section longer than one page has no repeated header when printed. Browsers
do not implement
@pagemargin boxes; the site's print route measures and splits in JavaScript instead. docs previewborrows another project's stylesheet before you publish. The site splits CSS per route, so a project with nothing live yet has no page of its own to read - the preview finds any published section page and uses that. It is exact, because every docs page shares one layout, but it does mean the first preview against a site with nothing at all published falls back to the home page and says the chrome is approximated.docs newis described inDESIGN.mdand was never built. Creating a file is onecataway and the frontmatter is three lines.
Licence
MIT. Copyright (c) 2026 Wilson Oon.
