@doidor/markbook-core
v0.5.0
Published
Markbook core — markdown parsing, story extraction, static site building
Maintainers
Readme
@doidor/markbook-core
The framework-agnostic engine behind Markbook: markdown
parsing, directive expansion, multi-page Vite orchestration, Pagefind
indexing, llms.txt emission, and the embed/package bundler.
Most users only touch @doidor/markbook-core through their markbook.config.ts.
This README documents the public API surface — directives, frontmatter,
config, adapter contract, story-file conventions, and theme tokens.
Public API
import {
defineConfig,
build,
dev,
bundleEmbed,
type MarkbookConfig,
type MarkbookAdapter,
type BundleEmbedOptions,
type BundleMode,
type BundleIsolation,
} from '@doidor/markbook-core';For advanced use (custom CLIs, tooling around Markbook), the internal
surface is reachable via @doidor/markbook-core/internal — but signatures there
may change between minor releases.
Directives
Markdown directive blocks (:::name{attr=value}\nbody\n::: or ::name{attr=value} for leaf form) expand at build time. The syntax itself is provided by remark-directive; Markbook layers a registry + dispatcher on top.
Three built-in directives ship with Markbook (story / stories / props) — see below. They cannot be overridden by config.directives.
Arbitrary user directives can be registered from markbook.config.ts:
import { defineConfig, escapeAttribute } from '@doidor/markbook-core';
export default defineConfig({
directives: {
youtube: ({ attributes }) =>
`<iframe src="https://youtube.com/embed/${escapeAttribute(attributes.id ?? '')}" allowfullscreen></iframe>`,
callout: ({ attributes, innerHtml }) =>
`<aside class="callout callout-${attributes.type ?? 'info'}">${innerHtml ?? ''}</aside>`,
},
});Handlers can be inline (as above), or imported from external modules (jiti loads the whole config tree transparently — TypeScript imports work through to directives/callout.ts etc.). For directive output that's more than a one-liner, the htmlTemplate(new URL('./template.html', import.meta.url)) helper lets the markup live in a real .html file with {{ key }} substitution — handler imports the template, calls render({ vars }). Both leaf + container forms are supported. Handlers can be async; results can include markdown (for the llms.txt mirror) and dependencies (for dev-mode re-rendering on external-file changes). Errors are wrapped with file:line:col and preserve the original via Error.cause.
See the custom directives guide for the full surface (descriptor form, type pinning, llms.txt fallback, dev-mode dependency tracking, etc.) and ADR-0025 for the design rationale.
Nesting directives
A container directive's body is itself parsed for directives, so leaf (and container) directives compose:
:::section{label=Currently}
::about-item{label="Role:" text="Principal Engineer"}
::about-item{label="Team:" text="Core"}
:::The section handler receives the rendered children — including each about-item's handler output — as innerHtml. To nest a container inside a container, add more colons to the outer fence (same rule as nested code fences):
::::group
:::inner
::leaf{}
:::
::::Directives are not parsed inside raw HTML blocks. A
::linkwritten between<ul>…</ul>is left as literal text — that's a CommonMark rule (the block is opaque raw HTML), not a Markbook limitation. Build the structure with a container directive instead (e.g. alink-listwhose handler wrapsinnerHtmlin<ul>, withlinkleaf children rendering<li>). Built-in directives (story/stories/props) only run at the top level, never nested.
:::story — single story per file (built-in)
:::story{src=./Button.stories.tsx [export=Default] [id=stable-slug]}
:::src(required) — path to the story file, relative to the markdown pageexport— named export to mount (default:default)id— explicit embed slug formarkbook bundle. Survives file renames
The story file's default export is the renderer (a function returning JSX, or a React element).
:::stories — multi-export story file (CSF v3) (built-in)
:::stories{src=./Button.stories.tsx [only=A,B] [exclude=C] [id=base-slug]}
:::Fans out to one rendered story per named export of the file. Each export may be a render function:
export const Primary = () => <Button variant="primary" />;…or a Storybook CSF v3 object (render plus at least one metadata field):
export const Secondary = {
name: 'Secondary action',
args: { variant: 'secondary' },
parameters: { layout: 'centered' },
render: (args) => <Button {...args} />,
};Filtering:
only=Primary,Secondary— whitelist exports (CSV)exclude=Internal— blacklist exports (CSV)
Each export becomes its own H3 heading (humanized from the export name —
PrimaryButton → Primary Button) so the page TOC picks them up. The
"Show code" disclosure renders once under the group with the full
multi-export source.
Excluded names: default, args, argTypes, parameters, anything
starting with _, and any TypeScript type-only export.
Embed slugs for :::stories always promote with the export name
(${baseSlug}-${kebab(exportName)}) — adding/removing exports later never
silently renames an existing embed.
:::props — props table (built-in)
---
component: ./Button.tsx
componentExport: Button # optional, defaults to first export
---
:::props
:::Renders a table of every prop in the named component, generated from its
TypeScript types via react-docgen-typescript (React-only).
Frontmatter
| Field | Type | Purpose |
| --- | --- | --- |
| title | string | Page title; falls back to first H1, then the file ID |
| description | string | Used as muted lede after the H1; appears in nav descriptions |
| order | number | Sidebar position within the page's nav group (lower = earlier). Ordered pages appear before unordered ones; same-order pages preserve file-discovery order. The index page is always first. |
| template | string | Wrap content in <templatesDir>/<name>.md |
| layout | string \| false | Pick an HTML layout <layoutsDir>/<name>.html; false to opt out when config.layout sets a default |
| component | string | Path to the component for :::props (relative to page) |
| componentExport | string | Named export within component |
Configuration (MarkbookConfig)
export default defineConfig({
// Project layout
root: process.cwd(), // default: cwd
contentDir: 'pages', // markdown source root (default: 'docs')
// `docsDir` is the legacy alias
outDir: 'dist', // build output
templatesDir: ['_layouts'], // markdown wrappers (string or string[])
layoutsDir: 'layouts', // HTML shells (string or string[])
layout: 'default', // default HTML layout for every page
publicDir: 'public', // static assets copied to dist root
// (default; set to false to disable)
title: 'My Components',
description: 'A short blurb',
// SEO + Open Graph (all optional, all auto-injected into <head>)
siteUrl: 'https://example.com', // origin (no trailing slash); enables
// canonical, og:url, sitemap.xml, robots.txt
themeColor: '#0a1228', // <meta name="theme-color"> (mobile chrome)
ogImage: 'https://example.com/og.png', // default OG/Twitter image URL
// Adapter (optional — defaults to staticAdapter() for markdown-only sites)
adapter: reactAdapter({ decorators: ['./preview.tsx'] }),
// Dev server
dev: { port: 5173, host: '0.0.0.0' },
// Feature toggles (default ON)
search: true, // build the Pagefind search index
// (set false to skip Pagefind entirely)
llmsButtons: true, // "View / Copy as Markdown" page buttons
// Opt-in features (default OFF)
viewTransitions: true, // SPA-style cross-document page swaps
// (instant-cut navigation; off by default)
// Bundle (`markbook bundle`) options
bundle: {
packageScope: '@my-org', // for --mode package outputs
packageVersion: '0.1.0',
},
// Customization (four layers, escalating)
css: ['./markbook.css'], // 1. inlined AFTER built-in chrome CSS
disableBaseCss: false, // 2. opt out of built-in chrome entirely
layoutsDir: 'layouts', // 3. replace the entire HTML shell with
// your own files (see "HTML layouts")
transformHtml: async (html, page) => html, // 4. post-process per page (escape hatch)
});Static assets (public/)
Files placed in <root>/public/ (or whatever you set publicDir to) are
copied verbatim to the build output's root and served at / during
markbook dev. Backed by Vite's own publicDir so behaviour matches
what users expect from Astro / Next / Vite itself.
Typical uses:
- Favicons — drop
favicon.svg(or.ico) and reference as<link rel="icon" type="image/svg+xml" href="/favicon.svg">from your HTML layout or viatransformHtml. - Open Graph images — the OG image URL has to be absolute, so pair
a static
public/og.pngwithconfig.ogImage: 'https://your.site/og.png'. .well-known/,humans.txt, ads.txt, security.txt — any web-standard sibling file goes here.- Download bundles, PDFs, datasets — anything Markdown shouldn't rewrite.
Set publicDir: false to disable entirely. Default: 'public'.
Full-text search (Pagefind)
By default Markbook builds a Pagefind full-text
index over the static output (build) — and over the dev server's working
tree (dev) — and wires the search box into the chrome.
Set search: false to turn it off:
export default defineConfig({
search: false, // skip Pagefind entirely
});With search: false:
runPagefind()is never called inbuildordev— nopagefind/directory is emitted.- The
{{ search }}placeholder (and the built-in shell's search box) renders empty. {{ bodyEnd }}omits the Pagefind UI init script — but still emits the story entry module script when an adapter is configured.
Use this for sites that don't need search (single-page portfolios,
marketing/landing pages), or on platforms where Pagefind's native binary
can't run — notably ARM64 Linux with a 16K memory page size (e.g.
Raspberry Pi 5), where the bundled binary aborts with Unsupported system
page size. Disabling search lets markbook build / markbook dev run on
those machines.
Markbook always injects a complete SEO meta block into every page's <head>
(both via the built-in shell AND via the {{ head }} placeholder in HTML
layouts). The block contains:
<meta name="description">— per-page frontmatterdescriptionwins, thenconfig.description, then omitted entirely (Lighthouse prefers absence over an empty value). Skipped when your HTML layout already hand-writes its own<meta name="description">(no duplicate tag).<meta name="theme-color">— fromconfig.themeColor(default'#0a1228').<meta name="color-scheme" content="light dark">— always.<link rel="canonical">— emitted whenconfig.siteUrlis set.index.htmlcollapses to its directory URL (the homepage canonical ishttps://site.com/, a section index ishttps://site.com/guides/), matchingsitemap.xml.<meta property="og:type|title|description|site_name|url|image">— always emitted;og:urlandog:site_nameare conditional onsiteUrl/title.og:imagecascades per-page frontmatterogImage→config.ogImage→ omitted.<meta name="twitter:card|title|description|image">— always emitted; the card type bumps tosummary_large_imagewhen an image is set.
The <title> / og:title / twitter:title string is
<page title> — <site title>, collapsed to a single value when the page
title already equals config.title (so the homepage isn't
My Site — My Site).
When siteUrl is set, both markbook build and markbook dev generate
sitemap.xml (listing every page with <lastmod>) and robots.txt
(referencing the sitemap) — in dist/ for build, served from / in
dev. index.html collapses to its directory URL in the sitemap
for a cleaner canonical form.
Per-page frontmatter:
---
title: My page
description: Page-specific description
ogImage: https://example.com/custom-og.png
---HTML layouts
When you outgrow the built-in shell, drop .html files into your
layoutsDir and Markbook will use them instead. Layouts own the entire
<html>...</html> structure; Markbook injects content + required bits
via {{ placeholders }}:
| Placeholder | Substitutes |
| ----------------- | ----------- |
| {{ content }} | Rendered page body (REQUIRED; exactly one per layout). Wrap it in <article data-pagefind-body> to enable search indexing. |
| {{ head }} | Markbook-required <head> injections (theme boot script, Pagefind CSS, base CSS, user CSS, etc.). Keep this. |
| {{ bodyEnd }} | Markbook-required body-end scripts (Pagefind UI init, story entry). Keep this. |
| {{ search }} | The Pagefind search input slot (empty when search is disabled). |
| {{ themeToggle }} | Dark/light toggle button (works with the theme boot script in {{ head }}). |
| {{ pageActions }} | "View / Copy as Markdown" buttons (empty when llmsButtons: false). |
| {{ title }} | Page title (HTML-escaped). |
| {{ description }} | Page description from frontmatter (HTML-escaped). |
| {{ siteTitle }} | config.title (HTML-escaped; empty if unset). |
| {{ browserTitle }} | What Markbook would put in <title> — handles the site-title fallback rules (HTML-escaped). |
| {{ frontmatter.x }} | Arbitrary frontmatter access via dot path (HTML-escaped — safe to interpolate into attributes). |
Validation:
- Unknown placeholders throw (typo guard).
- Missing
{{ content }}throws. - More than one
{{ content }}throws. - A named layout that doesn't exist throws (no silent fallback).
- Placeholders inside HTML comments are preserved verbatim and never substituted.
Pick a layout per-page via frontmatter (layout: landing) or for every
page via config.layout. Set layout: false in frontmatter to opt back
into the built-in shell when config.layout provides a default.
See examples/marketing-demo/ for a worked example.
Adapter contract (MarkbookAdapter)
Build a new adapter for any component runtime:
import type { MarkbookAdapter } from '@doidor/markbook-core';
export function myAdapter(): MarkbookAdapter {
return {
packageName: '@my-org/markbook-adapter-svelte',
vitePlugins: () => [/* … */],
decoratorModules: [], // array of paths to wrapper modules
packagePeerDeps: ['svelte'], // externals for --mode package
hasControls: false, // expose setupControls()?
};
}The adapter's default browser entry must export mount(el, story, opts)
(and, if hasControls, setupControls(controlsEl, args, argTypes, onChange)).
The config entry (@my-org/markbook-adapter-svelte/config) exports the
factory above. See ADR-0005 for why this split exists.
Story-file conventions
Stories live in .tsx / .ts / .jsx / .js files alongside the
markdown that references them. A story file may export:
| Export | Purpose |
| --- | --- |
| default | The renderer (single-story file) |
| Named exports (PascalCase) | Stories for :::stories fan-out |
| args | Initial prop values |
| argTypes | Control metadata ({ control: 'text' \| 'number' \| 'boolean' \| 'select', options? }) |
| parameters | Display options ({ layout?, background? }) |
args/argTypes/parameters are read from the named export itself if it's
a CSF v3 object; otherwise they fall back to module-level exports.
Theme tokens (--mb-*)
The built-in chrome styles everything through CSS custom properties. Override
them in your css file to rebrand without touching templates.
| Token | Purpose |
| --- | --- |
| --mb-bg | Page background |
| --mb-fg | Foreground text |
| --mb-fg-muted | Muted text |
| --mb-border | Borders |
| --mb-bg-elev | Elevated surfaces (form widgets) |
| --mb-bg-soft | Soft surfaces (story preview, code blocks) |
| --mb-accent | Brand accent (active nav, highlight) |
| --mb-accent-fg | Text color on top of accent |
| --mb-link | Link color |
| --mb-code-bg | Inline code background |
| --mb-radius | Card radius |
| --mb-font-sans / --mb-font-mono | Type families |
| --mb-content-width | Main column max width |
| --mb-sidebar-width | Left nav width |
| --mb-toc-width | Right TOC width |
| --mb-header-height | Top header height |
A [data-theme="dark"] block re-declares the colour tokens for dark mode;
the <html data-theme> toggle is wired by an inline boot script.
DOM contract for disableBaseCss
These classes / data-attributes stay stable even when you drop BASE_CSS:
.markbook-shell,.markbook-header,.markbook-brand,.markbook-sidebar,.markbook-content,.markbook-toc,.markbook-nav-group.markbook-story-block,.markbook-story,.markbook-controls,.markbook-code,.markbook-code-tabs,.markbook-code-tablist,.markbook-code-file,.markbook-code-file-label.markbook-code-pre-wrap,.markbook-fenced-code,.markbook-code-copy,.markbook-copy-label(fenced markdown code blocks get the wrap + Shiki + copy button — see "Code blocks" below).markbook-props,.markbook-required[data-markbook-story="<id>"],[data-markbook-controls="<id>"],[data-markbook-group="<id>"],[data-markbook-embed="<slug>"],[data-markbook-theme-toggle],[data-markbook-tabs],[data-markbook-copy],[data-pagefind-body]
Code blocks
Every fenced markdown code block (triple-backtick) is automatically:
- Wrapped in
<div class="markbook-code-pre-wrap markbook-fenced-code"> - Syntax-highlighted via Shiki using the
github-light+github-darkthemes (defaultColor: false) so the<html data-theme>swap re-paints colors with zero rebuild. Blocks without a language tag, or with a language Shiki doesn't recognize, render as plain<pre><code>inside the same wrapper. - Decorated with a hover-revealed
<button data-markbook-copy>. The copy boot script inBASE_CSSfinds the closest wrap, reads the<pre>'stextContent, and writes it to the clipboard.
Story code disclosures (:::story expansions) use the same Shiki + copy
machinery but live inside a <details class="markbook-code"> outer
container so they can be collapsed/expanded.
The fenced-code background pulls from --mb-code-bg so a single token
override re-skins every code block in light + dark mode without touching
Shiki's syntax colors.
Navigation feel (prefetch + opt-in View Transitions)
A built site is a classic multi-page app — every link is a full document navigation. Two progressive-enhancement layers smooth that over: hover prefetch (on by default) and opt-in cross-document View Transitions. Both degrade to plain navigation where unsupported; neither needs a client-side router (ADR-0032).
- Cross-document View Transitions (opt-in). Set
viewTransitions: truein your config to emit@view-transition { navigation: auto; }, so same-origin navigations go through the View Transitions API (Chromium 126+, Safari 18.2+). The page content is cut over instantly (::view-transition-old/new(root) { animation: none }) rather than cross-faded: opacity-fading two different pages superimposes their text into a muddy double-exposure that itself reads as a flash. With the instant cut the browser simply holds the old frame until the new page has painted, then swaps without the blank/white repaint of a normal navigation — the chrome (identical between pages) appears to stay put while the content changes, a crisp SPA-style route change. No animation also means nothing to undo forprefers-reduced-motion. It is off by default, and never emitted whendisableBaseCssis set — custom-chrome sites add@view-transitionto their owncss. - Hover prefetch. A
<script type="speculationrules">block (eagerness: "moderate") prefetches same-origin pages on hover/pointerdown via the Speculation Rules API, so the next page is already cached when clicked (which keeps the held-frame window short). Injected through{{ head }}, so custom HTML layouts get it too (independent ofdisableBaseCss). Chromium-only today; a no-op onfile:pages and where unsupported.
See also
- Repo README — overview, install, hello-world
DECISIONS.md— ADRs explaining the designPROGRESS.md— the running development journal
