Site Documentation
A Notion-style documentation workspace inside WordPress. Folders, tags, search, and markdown docs that humans edit in a WYSIWYG and agents read and write as plain .md.
by SnippetNest · github.com/zackpyle/site-documentation · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/zackpyle/site-documentation/archive/refs/heads/main.zipA documentation workspace inside WordPress. Folders, tags, and full-text search over markdown docs that people write in a WYSIWYG editor and agents read and write as plain .md.
It is built for the case where both audiences work on the same documentation: someone on the team writes a runbook in a rich editor without ever seeing markdown syntax, and an agent reads that same runbook as a markdown file over the REST API, WP-CLI, or the Abilities API.
Features
- Folders, nested as deep as you like. A doc lives in exactly one folder, so it always has an unambiguous path such as
engineering/deploys/checklist. Collapse them in the sidebar, and drag to rearrange — or move the focused row with Alt and the arrow keys, since rearranging documentation should not require a mouse. Docs and folders share one order, so an overview doc can sit above the folders it introduces. - Tags, flat and cross-cutting, separate from folders.
- Full-text search with relevance ranking — title matches outrank passing mentions — backed by a MySQL
FULLTEXTindex rather thanLIKE. - WYSIWYG or raw markdown, toggled per doc. Humans get formatting buttons; the file underneath is always markdown.
- Wiki-links. Write
[[deploy-checklist]]or[[deploy-checklist|the checklist]]. Every doc lists what links to it and which of its own links point at docs nobody has written yet, and clicking an unwritten one offers to create it. In the editor canvas the link stays as plain text — see Wiki-links are not rendered in the canvas below for why. - Revision history with one-click restore.
- Images, pasted or dropped straight into a doc, stored in the media library.
- Import and export a
.zipof markdown files, folder structure and front matter preserved. The two are exact inverses. - Light or dark, per person. A theme picker in the workspace sidebar — System, Light, or Dark — stored against the user, not the site. System follows the operating system setting and switches live when it changes.
- Dashboard widget. A mini version of the workspace on the WordPress dashboard: search, the docs touched most recently, and folder shortcuts. Every result opens the real workspace at that doc.
- Admin-only. Nothing is exposed on the front end.
Requirements
- WordPress 6.4+
- PHP 8.1+
ZipArchivefor import and export (present on essentially every host; the buttons hide themselves if it is missing)- WordPress 6.9+ for the Abilities API surface — everything else works without it
Installing
Download the latest release, or clone into wp-content/plugins/. Updates come from this repository: once the plugin is active, new releases appear in the WordPress updates screen like any other plugin.
On activation the plugin creates its search index table and grants the manage_site_docs capability to administrators and editors. The workspace appears as Documentation in the admin menu.
For agents
Three surfaces, all enforcing the same manage_site_docs capability. All of them accept a doc path (engineering/deploys/checklist), a bare slug (checklist), or a numeric ID interchangeably.
REST API
Namespace site-docs/v1. Authenticate with an application password.
| Route | Method | Purpose |
|---|---|---|
/docs |
GET |
List or search — see Listing docs below |
/docs |
POST |
Create |
/docs/batch |
POST |
Write up to 100 docs in one request |
/docs/{path} |
GET |
Read. ?format=markdown returns the raw .md file |
/docs/{path} |
POST |
Upsert — updates the doc there, or creates it if there is none |
/docs/{path} |
DELETE |
Trash, or ?force=true to delete |
/docs/{path}/links |
GET |
Outgoing wiki-links and backlinks |
/docs/{path}/revisions |
GET |
History |
/docs/{path}/revisions/{id} |
POST |
Restore a revision |
/search?q= |
GET |
Ranked search |
/folders |
GET |
Every folder — see Folders below |
/folders |
POST |
Create one, by name + parent or by path |
/folders/{id} |
POST |
Rename (name) or move (parent) |
/folders/{id} |
DELETE |
Delete. ?docs=move&move_to={id} or ?docs=trash |
/tags |
GET |
Tags in use, with counts |
/reorder |
POST |
Set manual order — see Ordering below |
/preferences |
GET / POST |
The current user's own workspace preferences |
/import |
POST |
Upload a .md or .zip |
Listing docs
GET /docs paginates like core: per_page (default 50, maximum 200) and page, with the total in the X-WP-Total header as well as the body. Filter with search, folder_path or folder, include_subfolders, and tags.
?content=true returns every doc's full markdown in the list response. For an agent about to read a folder, that is one request instead of one per doc — worth reaching for before fetching each doc individually.
curl -u user:app-password \
"https://example.com/wp-json/site-docs/v1/docs?folder_path=engineering&content=true&per_page=100"
Folders
GET /folders returns a flat array, not a nested tree. Each folder carries its own parent, so nesting is reconstructed by grouping on it — and path is already resolved, so most callers never need to:
[
{ "id": 4, "name": "Engineering", "slug": "engineering", "parent": 0,
"path": "engineering", "count": 2, "order": 0 },
{ "id": 9, "name": "Deploys", "slug": "deploys", "parent": 4,
"path": "engineering/deploys", "count": 5, "order": 1 }
]
count is the number of docs directly in that folder, not including its subfolders — which is the number that matters when deciding whether deleting it is safe.
Ordering
Manual order is not a sidebar-only feature — an agent writing a set of docs usually knows what order they should read in, and can say so directly. Name the contents in the order you want; anything left out keeps its place after the ones listed.
curl -u user:app-password -X POST \
-H 'Content-Type: application/json' \
-d '{"type":"doc","parent_path":"engineering",
"siblings":["overview","setup","deploys"]}' \
"https://example.com/wp-json/site-docs/v1/reorder"
siblings accepts IDs, slugs, or paths, and parent_path can be given instead of a numeric parent. Pass type: "folder" to order subfolders instead, naming them by path or by the name they carry inside their parent.
Naming only one kind says nothing about how the two interleave, so the listed items are dealt back into the positions that kind already occupies — reordering a folder's docs leaves its subfolders exactly where they were on screen.
To interleave docs and folders, send the whole contents as items. Docs and folders share one sequence, so this is how you put an overview doc above the folders it introduces:
curl -u user:app-password -X POST \
-H 'Content-Type: application/json' \
-d '{"parent":0,"items":[
{"type":"doc","id":"start-here"},
{"type":"folder","id":"editors"},
{"type":"folder","id":"developers"}
]}' \
"https://example.com/wp-json/site-docs/v1/reorder"
id is optional in both shapes. Include it to move something into the destination as part of the same call — which is what the sidebar does on a drop — or leave it out to only set the order of what is already there.
Writing several docs at once
curl -u user:app-password -X POST \
-H 'Content-Type: application/json' \
-d '{"docs":[
{"path":"editors/start-here","content":"# Start here\n"},
{"path":"developers/start-here","content":"# Start here\n","tags":["dev"]}
]}' \
"https://example.com/wp-json/site-docs/v1/docs/batch"
Each entry is written independently, so one bad doc does not take the rest with it. The response reports written, failed, and a per-entry results array carrying each doc's resulting path and whether it was created.
?format=markdown returns a text/markdown body — the file itself, not a JSON envelope:
curl -u user:app-password \
"https://example.com/wp-json/site-docs/v1/docs/engineering/deploys/checklist?format=markdown"
---
title: Deploy checklist
slug: checklist
folder: engineering/deploys
tags: [ops, release]
status: publish
---
## Before you start
...
Writing is the same shape in reverse. Front matter at the top of content is parsed and applied, so an agent can round-trip a file it just read:
curl -u user:app-password -X POST \
-H 'Content-Type: application/json' \
-d '{"content":"---\ntitle: Deploy checklist\nfolder: engineering/deploys\ntags: [ops]\n---\n\n## Before you start\n"}' \
"https://example.com/wp-json/site-docs/v1/docs/checklist"
Folders named in folder_path or in front matter are created if they do not exist, and writing the same path twice resolves to the same folder both times.
WP-CLI
Reading and writing go through stdout and stdin, so the commands compose with ordinary shell tooling.
wp site-docs list --folder=engineering
wp site-docs get engineering/deploys/checklist > checklist.md
cat checklist.md | wp site-docs put engineering/deploys/checklist
wp site-docs search "deploy staging"
wp site-docs import docs.zip --folder=imported --overwrite
wp site-docs export ./backup.zip
wp site-docs reorder engineering --docs=overview,setup,deploys
wp site-docs reindex
wp site-docs reset-order
wp site-docs repair-markdown --dry-run
wp site-docs put creates the doc if it does not exist and replaces it if it does. wp help site-docs <command> documents every flag.
Abilities API
On WordPress 6.9+, six abilities are registered under the site-documentation category, with input and output schemas so an MCP-capable client discovers them without being configured for this plugin:
site-documentation/search-docssite-documentation/read-docsite-documentation/list-docssite-documentation/write-docsite-documentation/reordersite-documentation/delete-doc
Notes on how it stores things
Docs are posts. A doc is a sitedoc post whose post_content is raw markdown — never HTML, never blocks. Nothing renders it through the_content, so autop and block parsing never touch it.
Markdown storage follows WordPress's own privilege boundary. post_content is markdown, not HTML, so kses can mangle ordinary documentation prose — a <Type> placeholder, an example of a script tag, an HTML snippet someone is documenting. The plugin nonetheless leaves kses alone, because unfiltered_html is a real capability and not this plugin's to grant. WordPress already suspends the filter for users who hold it, so single-site administrators and editors keep full fidelity; users who do not hold it — which on multisite, and under DISALLOW_UNFILTERED_HTML, includes editors — get their markup filtered exactly as core filters it anywhere else.
A site that wants raw storage regardless can opt in with the sitedoc_allow_unfiltered_markdown filter, accepting that a doc author can then store script that runs for whoever reads the doc.
Wiki-links are not rendered in the canvas. They were, briefly, using Toast UI's widgetRules. That feature turns matched text into a real node in the document, and it took the document with it two ways — both silent, both confirmed against the vendored build:
getMarkdown()serialises a widget as the editor's own internal marker, so every save rewrote[[target]]as$$widget0 [[target]]$$. The editor hid it, because it strips the marker again on the way in. The stored file did not — and that file is what agents read, what export writes, and what the search index holds.- A table cell is schema-bound to contain paragraphs. A widget landing in one produces a document ProseMirror cannot build, so a wiki-link in a table blanked the entire editor with nothing in the console.
Rendering them safely needs a ProseMirror decoration, which draws without touching the document. This build of the editor exports neither Decoration nor a wysiwygPlugins option, so there is no way to add one from outside it. Links therefore stay plain text in the canvas and resolve in the panel beneath it.
Documents already carrying the marker are repaired once, automatically, on the first admin page view after upgrading, with a notice saying how many changed; the previous version of each is in its revision history. wp site-docs repair-markdown --dry-run lists them first if you would rather look before anything moves.
Rendering is sanitized by DOMPurify. Toast UI ships its own HTML sanitizer, but its last release was 3.2.2 in early 2023 and that sanitizer has a history of bypasses. Docs are written by one person and read by another, so a bypass there is stored XSS between them. A vendored DOMPurify is passed as the editor's customHTMLSanitizer, replacing the built-in one.
The search index is a cache. Each doc is mirrored into {prefix}sitedoc_index with a FULLTEXT key across title, body, and tags. Dropping that table and running wp site-docs reindex always reproduces it exactly, so it never needs backing up separately.
Deleting a folder never decides for you. The dialog says how many docs are affected and makes you choose between moving them somewhere and trashing them; over the API, docs defaults to move, so nothing is destroyed unless it was asked for. Subfolders are never deleted — they move up a level, keeping their own docs, so removing one level of a tree cannot take a branch nobody was looking at with it.
Folder paths are matched by name, not by slug. Term slugs must be unique across the whole taxonomy, so WordPress renames one that collides — a second folder called content-types gets stored as content-types-developers. Paths are therefore built from folder names, which only have to be unique among their own siblings. developers/content-types means the folder called "content-types" inside the one called "developers", regardless of what slug it ended up with or what else exists at the root.
Doc slugs, by contrast, are unique across the whole workspace. Asking for a slug that is already taken elsewhere gets you a suffixed one — start-here becomes start-here-2 — so editors/start-here and developers/start-here cannot both exist. This is a deliberate trade for wiki-links: it means [[start-here]] resolves to exactly one doc from anywhere, with no relative-path rules to learn. When it happens, the write response says so explicitly:
{ "slug": "start-here-2", "path": "developers/start-here-2",
"requested_slug": "start-here", "slug_adjusted": true }
Docs and folders share one order sequence. They are not ordered separately, so a doc can sit above a folder — an overview doc above the folders it introduces is a normal thing to want, and keeping the two kinds in separate sequences would make it impossible to express.
Positions are one-based, so nought means "never placed" and sorts last, which is where a doc created after its folder was arranged belongs. When nothing has been placed everything ties and the fallback applies: folders first, then docs, each alphabetically — what an untouched workspace has always looked like.
Manual order is opt-in. Nothing has a position until something is dragged, or an agent sets one through the API. Docs store theirs in menu_order, folders in term meta. Dragging never touches a doc's modified date, so rearranging the sidebar does not rewrite "recently updated". wp site-docs reset-order clears every stored position and returns everything to alphabetical.
The theme is a per-user preference, not a site setting. How a document should look is a property of the person reading it, so it lives in user meta and nobody can change it for anyone else. The panel's colours are CSS custom properties keyed on a data-theme attribute that is printed server-side, so a dark workspace is never briefly light while the scripts load; JavaScript is only needed to follow the system setting as it changes and to hand Toast UI its own dark class.
Imports are bounded by what they expand to, not by upload size. A zip's compressed size guarantees nothing — 32 MB of highly compressible files can expand to gigabytes. Entries are read one at a time rather than collected first, with ceilings on both the number of documents (SITEDOC_MAX_IMPORT_ENTRIES) and the total decompressed bytes (SITEDOC_MAX_IMPORT_EXPANDED). An import that hits either ceiling says so rather than looking complete.
Search falls back to LIKE when the index cannot answer. InnoDB ignores tokens shorter than innodb_ft_min_token_size (three characters by default) and drops common words from its stopword list, so a search for ACF or who returns nothing from an index working exactly as designed. The fallback catches those.
Customizing
sitedoc_capability
Change who may use the documentation. Everything — the admin screen, every REST route, every CLI command, every ability — checks this one capability.
add_filter( 'sitedoc_capability', function () {
return 'edit_pages';
} );
sitedoc_allow_unfiltered_markdown
Whether to store a doc's markdown without running it through kses. Defaults to whether the author may post unfiltered HTML. Returning true for a user without that capability lets them store markup that executes for other readers of the doc — only do this where every author is trusted.
add_filter( 'sitedoc_allow_unfiltered_markdown', function ( $unfiltered ) {
return current_user_can( 'manage_site_docs' );
} );
sitedoc_doc_created / sitedoc_doc_updated
Fire after a doc is written through any surface, so a hook here catches an agent's write as well as a person's.
add_action( 'sitedoc_doc_updated', function ( $doc, $previous ) {
if ( $doc->post_title !== $previous->post_title ) {
// ...
}
}, 10, 2 );
Vendored libraries
Two third-party files are committed under assets/vendor/ rather than pulled at build time, because a documentation tool that stops working when a CDN is unreachable is not much of a documentation tool:
- Toast UI Editor 3.2.2 (MIT) — the markdown WYSIWYG
- DOMPurify 3.2.7 (Apache-2.0 / MPL-2.0) — HTML sanitizing at render time
The Toast UI build carries one local patch, for a null dereference that throws when a table cell contains a line break. assets/vendor/PATCHES.md documents what and why. Re-downloading that file drops the patch and brings the bug back.
Uninstalling
Deleting the plugin removes what the plugin owns: the index table, its option, the capability it added to roles, and the analytics options. It does not delete your documentation.
That asymmetry is deliberate — the index costs nothing to rebuild, but the docs are the writing, and a plugin deletion is far too easy to trigger by accident to be the thing that destroys it. If you really do want the docs gone, say so in wp-config.php before deleting the plugin:
define( 'SITEDOC_DELETE_DATA', true );