Custom directives
Markbook's :::story, :::stories, and :::props directives are useful, but they're only the start. You can register your own :::name (or ::name) directives from markbook.config.ts and have them produce any HTML you want.
This is the same extension point Markbook's own site uses for the callout boxes in these guides:
Register a directive#
In markbook.config.ts:
import { defineConfig } from '@doidor/markbook-core';
export default defineConfig({
directives: {
youtube: ({ attributes }) =>
`<iframe src="https://youtube.com/embed/${attributes.id}" allowfullscreen></iframe>`,
callout: ({ attributes, innerHtml }) =>
`<aside class="callout callout-${attributes.type ?? 'info'}">${innerHtml ?? ''}</aside>`,
},
});Use them in any markdown page:
::youtube{id=dQw4w9WgXcQ}
:::callout{type=warning}
This is **markdown** inside a directive. The handler receives the
inner content already-parsed as HTML.
:::Handlers in external files#
Inline handlers are fine for one-liners. Once you have more than a couple, or once a handler needs its own helper functions / constants / fixtures, extract it into its own file:
my-site/
├── markbook.config.ts
└── directives/
├── callout.ts
├── youtube.ts
└── csv-table.tsEach file just exports a DirectiveHandler:
// directives/callout.ts
import { escapeAttribute, type DirectiveHandler } from '@doidor/markbook-core';
const VALID_TYPES = new Set(['info', 'tip', 'warning', 'danger']);
export const callout: DirectiveHandler = ({ attributes, innerHtml }) => {
const raw = attributes.type ?? 'info';
const type = VALID_TYPES.has(raw) ? raw : 'info';
return `<aside class="callout callout-${escapeAttribute(type)}" role="note">${innerHtml ?? ''}</aside>`;
};Then import and register:
// markbook.config.ts
import { defineConfig } from '@doidor/markbook-core';
import { callout } from './directives/callout.js';
import { youtube } from './directives/youtube.js';
import { csvTable } from './directives/csv-table.js';
export default defineConfig({
directives: { callout, youtube, 'csv-table': csvTable },
});The Markbook CLI loads markbook.config.ts through jiti, which transparently handles TypeScript imports through the whole config tree — your directive files don't need a separate build step. Use .js-style extensions in import paths even when the source is .ts (TypeScript's NodeNext convention).
This file pattern also makes directives testable with regular Vitest / Jest — they're just functions:
// directives/callout.test.ts
import { describe, it, expect } from 'vitest';
import { callout } from './callout.js';
describe('callout', () => {
it('falls back to info on unknown type', async () => {
const result = await callout({
name: 'callout',
attributes: { type: 'unknown' },
type: 'container',
innerHtml: '<p>x</p>',
innerMarkdown: 'x',
pageFile: '/x.md',
root: '/',
frontmatter: {},
});
expect(result).toContain('callout-info');
});
});The official Markbook site uses this pattern — see examples/markbook-site/directives/callout.ts.
Templates in HTML files#
Hand-written HTML inside JS template literals gets ugly fast. Markbook ships an htmlTemplate(source) helper so the markup can live in a real .html file next to the handler:
my-site/
└── directives/
├── callout.ts
└── callout.html<!-- directives/callout.html -->
<aside class="callout callout-{{ type }}" role="note">
{{ content }}
</aside>// directives/callout.ts
import { escapeAttribute, htmlTemplate, type DirectiveHandler } from '@doidor/markbook-core';
const VALID_TYPES = new Set(['info', 'tip', 'warning', 'danger']);
const render = htmlTemplate(new URL('./callout.html', import.meta.url));
export const callout: DirectiveHandler = ({ attributes, innerHtml }) => {
const raw = attributes.type ?? 'info';
const type = VALID_TYPES.has(raw) ? raw : 'info';
return render({
type: escapeAttribute(type),
content: innerHtml ?? '',
});
};The helper:
- Reads the file once and caches it. The first
render()call loads from disk synchronously; subsequent calls are pure string substitution. Same path → same cached body, even across multiplehtmlTemplate()instances. {{ key }}and{{ key.dot.path }}substitution. Missing keys render as an empty string (no throw — keeps optional placeholders ergonomic).- All values insert raw — no auto-escaping. Call
escapeAttribute/escapeHtmlyourself on untrusted strings before passing them in. This matches Markbook's layout-placeholder contract: what you pass is what lands. It's also what you want forinnerHtml, which IS already HTML. - HTML comments are preserved verbatim.
{{ }}mentions inside<!-- ... -->are left alone, so you can document expected variables in the template itself. new URL('./file.html', import.meta.url)is the recommended source form — it resolves relative to the calling module rather thanprocess.cwd(). Absolute string paths also work.
If the file is missing, the helper throws a clear Markbook: htmlTemplate could not read '<path>' error at first render.
Two directive forms#
| Form | Syntax | Body? | Typical use |
|---|---|---|---|
| Leaf | ::name{attr=value} |
no | "embed this thing" — videos, badges, files |
| Container | :::name{attr=value}\n...\n::: |
yes | "wrap this content" — callouts, tabs, conditional blocks |
For container directives, the handler receives the inner content TWO ways:
innerHtml— children already parsed to HTML through Markbook's pipeline. Use this 90% of the time.innerMarkdown— the original markdown source as a string. Use this for directives that want to do their own parsing (e.g. a Mermaid renderer that needs the raw text).
Function handlers accept both forms by default. Pin to one with the descriptor form:
directives: {
youtube: {
type: 'leaf', // only allow `::youtube{...}`; throw on `:::youtube\n...\n:::`
handler: ({ attributes }) => `<iframe ...></iframe>`,
},
callout: {
type: 'container', // only allow `:::callout\n...\n:::`; throw on leaf use
handler: ({ innerHtml }) => `<aside>${innerHtml}</aside>`,
},
},Nesting directives#
A container's body is parsed for directives too, so directives compose. Put leaf directives inside a container and each one's handler runs — the container sees the combined output as innerHtml:
:::section{label=Currently}
::about-item{label="Role:" text="Principal Engineer"}
::about-item{label="Team:" text="Core"}
:::directives: {
section: ({ attributes, innerHtml }) =>
`<section data-label="${escapeAttribute(attributes.label ?? '')}">${innerHtml ?? ''}</section>`,
'about-item': ({ attributes }) =>
`<div class="item"><b>${escapeHtml(attributes.label ?? '')}</b> ${escapeHtml(attributes.text ?? '')}</div>`,
},To nest a container inside a container, add more colons to the outer fence — the same rule as nested code fences:
::::group
:::inner
::leaf{}
:::
::::Directives are not parsed inside raw HTML blocks. A
::linkwritten between<ul>…</ul>stays literal text — CommonMark treats the HTML block as opaque, so remark-directive never sees it. Build the structure with a container directive instead: alink-listwhose handler wrapsinnerHtmlin<ul>, withlinkleaf children that render<li>. The built-instory/stories/propsdirectives only run at the top level, never nested.
Handler context#
Every handler receives a single ctx object:
interface DirectiveContext {
name: string; // 'callout', 'youtube', etc.
attributes: Record<string, string | undefined>; // {key=value} attrs
type: 'leaf' | 'container'; // how the directive was written
innerHtml: string | null; // parsed children (container only)
innerMarkdown: string | null; // raw source (container only)
pageFile: string; // absolute path to the .md file
root: string; // project root (config.root)
frontmatter: Record<string, unknown>; // page's frontmatter
}Handler return values#
Every Markbook page is emitted twice: as the HTML a browser renders, and as a plain-markdown mirror at /llms/<page>.txt (the llms.txt feature — what AI assistants fetch, what the per-page "Copy as Markdown" button copies, and a clean text view of the page). A handler can describe itself differently for each output.
// Shorthand: just HTML. The llms.txt mirror keeps the raw `::name{...}` source.
({ attributes }) => `<x>${attributes.foo}</x>`
// Object form: HTML for the page + a clean markdown fallback for llms.txt
// (+ optional file dependencies for dev-mode re-rendering).
({ attributes }) => ({
html: '<x></x>',
markdown: '(embedded x)',
dependencies: ['/data/x.json'],
})
// null or undefined: drop the directive entirely (no replacement in either output).
() => nullThe markdown fallback — why every real directive needs it#
When a handler returns a bare HTML string, Markbook has no text version to put in the /llms/<page>.txt mirror, so it falls back to your raw directive source. A page containing ::youtube{id=dQw4w9WgXcQ} ends up with this literal line in its .txt:
::youtube{id=dQw4w9WgXcQ}That's noise to everything that reads the markdown mirror — AI assistants ingesting llms.txt, the "Copy as Markdown" button, anyone viewing the plain-text page. The fix is the object form: return the HTML and a markdown description.
import { escapeAttribute, defineConfig } from '@doidor/markbook-core';
export default defineConfig({
directives: {
youtube: ({ attributes }) => ({
html: `<iframe src="https://youtube.com/embed/${escapeAttribute(attributes.id ?? '')}" allowfullscreen></iframe>`,
markdown: `[▶ Watch on YouTube](https://youtu.be/${attributes.id})`,
}),
},
});Now the HTML page gets the iframe, and the .txt mirror gets a real, followable link instead of directive syntax:
[▶ Watch on YouTube](https://youtu.be/dQw4w9WgXcQ)How each field is consumed:
| Field | Consumed by | When omitted |
|---|---|---|
html |
The rendered HTML page and the Pagefind search index | Required — the object form must include html |
markdown |
/llms/<page>.txt, the top-level llms.txt, and the "Copy as Markdown" button |
The raw ::name{...} directive source is kept verbatim |
dependencies |
markbook dev — re-renders the page whenever a listed file changes |
No file-watching for this directive |
markdown has three modes:
- Omitted — keep the original directive source in the mirror. Fine only when the
::name{...}source already reads naturally on its own. - A string — replace the directive with that markdown (a link,
(embedded tweet), a real| table |, …). This is what most content directives want. ''(empty string) — drop the directive from the mirror entirely. Good for purely-decorative HTML that carries no textual meaning.
Async + file I/O#
Handlers can be async. For handlers that read files, report them as dependencies so markbook dev re-renders the page when they change:
import fs from 'node:fs/promises';
import path from 'node:path';
import type { DirectiveHandler } from '@doidor/markbook-core';
export const csvTable: DirectiveHandler = async ({ attributes, pageFile }) => {
const abs = path.resolve(path.dirname(pageFile), attributes.src ?? '');
const text = await fs.readFile(abs, 'utf8');
const rows = text.trim().split('\n').map((line) => line.split(','));
const head = rows[0]!.map((c) => `<th>${c}</th>`).join('');
const body = rows.slice(1).map((row) =>
`<tr>${row.map((c) => `<td>${c}</td>`).join('')}</tr>`
).join('');
return {
html: `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`,
dependencies: [abs],
};
};::csv-table{src=./data/users.csv}Now edit data/users.csv and markbook dev re-renders the page within ~80ms.
Built-in conflict#
Names that collide with story, stories, or props throw at config load. The built-ins have side effects (story tracking, props-table generation) that a user handler can't replicate, and silently overriding them would be surprising.
Safety: escaping helpers#
Markbook re-exports two tiny helpers for safe interpolation:
import { escapeHtml, escapeAttribute, defineConfig } from '@doidor/markbook-core';
export default defineConfig({
directives: {
badge: ({ attributes }) => {
const label = attributes.label ?? '';
const variant = attributes.variant ?? 'info';
return `<span class="badge badge-${escapeAttribute(variant)}">${escapeHtml(label)}</span>`;
},
},
});Frontmatter values that flow into your handler are NOT escaped automatically. If a directive interpolates a value into HTML or attributes, escape it.
Errors get file context#
If a handler throws, Markbook re-throws with the source position prepended and the original error preserved as the cause:
Markbook: directive 'callout' in /tmp/site/pages/intro.md:42:1 threw: <original message>What you can build with this#
::youtube{id=...}— video embeds::badge{label=stable variant=success}— labelled badges:::callout{type=warning}— admonitions with markdown content (used by this site)::mermaid{src=./flow.mmd}— diagram renderers::api{spec=openapi.yaml path=/users}— API doc cards::github-file{repo=foo/bar path=src/Button.tsx}— embed a file from GitHub:::tabs+:::tab{label=...}— tabbed content (two cooperating directives)::collection{tag=blog limit=5}— render a list of pages matching a tag
The shared property: each one is a tiny piece of authoring vocabulary your team gets to use without leaving markdown.
Reference#
- Directives reference → — the directive vocabulary (built-in + user).
- Config reference → — full
MarkbookConfig.directivestyping. remark-directive— the underlying syntax parser.