# 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. :::callout{type=info} **Built on [`remark-directive`](https://github.com/remarkjs/remark-directive).** Markbook layers a registry + dispatcher on top: name-keyed handler lookup, leaf/container form auto-detection, async-with-Promise.all execution, dev-mode dependency tracking, llms.txt markdown fallback, and clear file:line error wrapping. The directive syntax itself (`:::name{attr=value}\nbody\n:::`) comes from remark-directive — you can use other remark-directive consumers in the same project and Markbook will leave their directive names alone. ::: This is the same extension point [Markbook's own site uses](/index.html) for the callout boxes in these guides: :::callout{type=info} This callout — and every other one in this site's guides — is rendered by a custom `:::callout` directive defined in [`markbook-site/directives/callout.ts`](https://github.com/doidor/markbook/blob/main/examples/markbook-site/directives/callout.ts). The full handler is 5 lines. ::: ## Register a directive In `markbook.config.ts`: ```ts import { defineConfig } from '@doidor/markbook-core'; export default defineConfig({ directives: { youtube: ({ attributes }) => ``, callout: ({ attributes, innerHtml }) => ``, }, }); ``` Use them in any markdown page: ```markdown ::youtube{id=dQw4w9WgXcQ} :::callout{type=warning} This is **markdown** inside a directive. The handler receives the inner content already-parsed as HTML. ::: ``` :::callout{type=warning} **Both `youtube` handlers above return a bare HTML string** — so the literal `::youtube{id=…}` line is what lands in the page's `llms.txt` markdown mirror. Any directive that produces _meaningful content_ should return the `{ html, markdown }` object form instead, so AI assistants and the "Copy as Markdown" button get clean text. This is essential, not advanced — see [Handler return values](#handler-return-values) below. ::: ## 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.ts ``` Each file just exports a `DirectiveHandler`: ```ts // 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 ``; }; ``` Then import and register: ```ts // 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](https://github.com/unjs/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: ```ts // 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: '

x

', 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`](https://github.com/doidor/markbook/blob/main/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 ``` ```html ``` ```ts // 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 multiple `htmlTemplate()` 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` / `escapeHtml` yourself 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 for `innerHtml`, 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 than `process.cwd()`. Absolute string paths also work. If the file is missing, the helper throws a clear `Markbook: htmlTemplate could not read ''` 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: ```ts directives: { youtube: { type: 'leaf', // only allow `::youtube{...}`; throw on `:::youtube\n...\n:::` handler: ({ attributes }) => ``, }, callout: { type: 'container', // only allow `:::callout\n...\n:::`; throw on leaf use handler: ({ innerHtml }) => ``, }, }, ``` ## 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`: ```md :::section{label=Currently} ::about-item{label="Role:" text="Principal Engineer"} ::about-item{label="Team:" text="Core"} ::: ``` ```ts directives: { section: ({ attributes, innerHtml }) => `
${innerHtml ?? ''}
`, 'about-item': ({ attributes }) => `
${escapeHtml(attributes.label ?? '')} ${escapeHtml(attributes.text ?? '')}
`, }, ``` To nest a **container inside a container**, add more colons to the outer fence — the same rule as nested code fences: ```md ::::group :::inner ::leaf{} ::: :::: ``` > **Directives are not parsed inside raw HTML blocks.** A `::link` written between `` stays literal text — CommonMark treats the HTML block as opaque, so remark-directive never sees it. Build the structure with a container directive instead: a `link-list` whose handler wraps `innerHtml` in `