This chapter is for people extending Doku in code — building a block, reading the search index from another application, or working on a child plugin. Nothing in it is needed to run a documentation site, and you can skip the whole chapter if that is what you are here for.
Block development
Custom blocks live in /doku-core/blocks/. Each block has its own folder with block.json and index.js (edit and save). Built output goes to /doku-core/build/.
Blocks carry no stylesheet of their own. The build only emits index.js, index.asset.php and block.json, so a "style" reference in block.json would point at a file that is never generated — and WordPress skips it silently, with no error to tell you. Put block CSS here instead:
| Styles | Location |
|---|---|
| Frontend | doku/assets/css/main.css (theme) |
| Editor-only | doku-core/assets/css/editor.css (plugin, enqueued in includes/blocks.php) |
The build setup ships with the plugin: package.json, package-lock.json and webpack.config.js sit in doku-core/. The packages they install do not, so run npm install once before the first build. The lock file pins every package to the version Doku was tested with, which keeps a rebuilt Shiki or Mermaid identical to the shipped one.
# Run from inside /doku-core
npm install # first time only
npm run start # watch mode during development
npm run build # production buildInline formats
Doku registers no inline rich-text formats of its own. Keyboard keys use WordPress’s built-in Keyboard input format, and the theme styles the bare <kbd> element it produces.
If you add one in a child plugin, two things are worth knowing. First, a format has no block.json, so the build cannot auto-discover it — register the entry by hand in webpack.config.js the way rehighlight and render-mermaid are, and enqueue the built script on enqueue_block_editor_assets, reading dependencies and version from the generated index.asset.php.
Second, check the tag is not already taken. registerFormatType() refuses a second format on the same tag and class combination, and it fails at load with only a console message — the format simply never appears. Core already claims <kbd>, <code>, <mark>, <sub> and <sup>, among others. When a format does map onto an HTML tag the Markdown importer also produces, set className: null and let the theme style the bare element; giving it a class makes the editor emit markup that neither the stylesheet nor the importer agrees with, and imported content will look different from hand-authored content.
Search index endpoint
GET /wp-json/doku/v1/search-indexReturns published docs as a JSON array, ready to feed to a search index. Public — no authentication required, since it exposes only already-public content.
Parameters
| Parameter | Type | Description |
|---|---|---|
page | integer | Which chunk to fetch. Defaults to 1. |
product | string | Restrict to docs in this doc_product term (slug). |
version | string | Restrict to docs in this doc_version term (slug). |
Response headers
| Header | Description |
|---|---|
X-WP-Total | Total docs matching the request across all pages. |
X-WP-TotalPages | Number of pages. 1 means the whole index came back in this response. |
Cache-Control | Always no-store, and LiteSpeed Cache is told the same through its own API. No HTTP cache clears this response when a doc is saved, so none may keep it; the server-side cache described below is cleared on every change. |
Each entry contains id, title, content (full text, tags stripped; empty for a password-protected doc), url and section. There is no excerpt field — derive one from content if you need it.
// Fetch the whole index, however many chunks it takes.
async function fetchIndex( baseUrl ) {
const sep = baseUrl.includes( '?' ) ? '&' : '?'; // rest_url() may already carry ?rest_route=
const first = await fetch( baseUrl );
const pages = parseInt( first.headers.get( 'X-WP-TotalPages' ), 10 ) || 1;
let docs = await first.json();
for ( let p = 2; p <= pages; p++ ) {
docs = docs.concat( await ( await fetch( `${ baseUrl }${ sep }page=${ p }` ) ).json() );
}
return docs;
}Two things to know before building on this:
- Do not assume a single request returns everything. It does on small sites, which makes the multi-chunk path easy to miss in testing. Check
X-WP-TotalPages. - Build the URL by testing for an existing
?. With plain permalinks,rest_url()returns…/?rest_route=/doku/v1/search-index, so blindly appending?page=2produces a broken URL.
Responses are cached in a transient for an hour. The cache is invalidated by bumping the doku_search_idx_ver option rather than by deleting rows, so it behaves correctly on hosts with a persistent object cache.
Markdown export endpoint
GET /wp-json/doku/v1/markdown/<id>Returns one entry as Markdown. Responds with text/plain; charset=utf-8 — not text/markdown, which browsers download instead of displaying — and with X-Robots-Tag: noindex, since it is a second address for content that already has one.
Public for published entries of a public post type; anything else requires the capability to read that entry, so drafts and private pages return 404 to anonymous requests and 200 to an editor.
Responses are cached in a transient for an hour, dropped when the entry is saved and keyed by plugin version, so an update that changes the serializer cannot serve output the previous one produced. That server-side copy is the only one: the response is sent with Cache-Control: no-store, and LiteSpeed Cache is told the same through its own API, because no HTTP cache clears it when the entry is edited.
Adding your own block. Anything Doku has no mapping for falls back to its saved HTML, or to a comment when the block builds its output at request time. To emit Markdown for a block of your own, filter it:
add_filter( 'doku_core_markdown_block', function ( $markdown, $block, $post ) {
if ( 'acme/pull-quote' !== ( $block['blockName'] ?? '' ) ) {
return $markdown;
}
return '> ' . wp_strip_all_tags( $block['attrs']['text'] ?? '' );
}, 10, 3 );Return a string to replace the default output, or null to leave the block out of the export entirely.
Serializing in PHP. doku_core_post_to_markdown( $post ) returns the same string the endpoint does. Pass false as the second argument to leave off the front matter.
Updating the translation templates
After changing any string that reaches the screen, regenerate both templates so translators see it:
wp i18n make-pot doku doku/languages/doku.pot --domain=doku \
--package-name="Doku" --exclude=assets/vendor,languages
wp i18n make-pot doku-core doku-core/languages/doku-core.pot --domain=doku-core \
--package-name="Doku Core" --exclude=node_modules,build,assets,languagesNaming conventions
| Scope | Convention |
|---|---|
| Theme PHP functions | doku_ prefix |
| Plugin PHP functions | doku_core_ prefix |
| CSS classes | doku- prefix (e.g. .doku-sidebar-nav) |
| Block names | doku/block-name (e.g. doku/callout) |
| JS | camelCase; window.doku namespace for global state |
| Custom hooks | doku_ prefix |
Architecture rule
If a feature touches content (post types, taxonomies, blocks, REST endpoints), it belongs in doku-core, not the theme. This ensures content survives a theme change.