Custom Format Handlers
Dune's content system is pluggable. Adding a new content format — RST, AsciiDoc, Djot, or anything else — is a matter of implementing the ContentFormatHandler interface and registering it.
The ContentFormatHandler interface
interface ContentFormatHandler {
/** File extensions this handler supports */
extensions: string[];
/** Extract frontmatter (must be fast — used during indexing) */
extractFrontmatter(raw: string, filePath: string): Promise<PageFrontmatter>;
/** Extract the raw content body (without frontmatter) */
extractBody(raw: string, filePath: string): string | null;
/** Render content to HTML (called at request time) */
renderToHtml(page: Page, ctx: RenderContext): Promise<string>;
}
Three methods, each with a clear responsibility:
extractFrontmatter— Parse metadata. Called during indexing. Must be fast and must not execute code.extractBody— Separate content from metadata. Returnsnullfor self-rendering formats (like TSX).renderToHtml— Convert content to HTML. Called on demand when a page is requested.
Example: AsciiDoc handler
import type { ContentFormatHandler, Page, PageFrontmatter, RenderContext } from "dune/types";
export class AsciiDocHandler implements ContentFormatHandler {
extensions = [".adoc", ".asciidoc"];
async extractFrontmatter(raw: string, _filePath: string): Promise<PageFrontmatter> {
// AsciiDoc uses = Title as first line, then :attribute: value pairs
const lines = raw.split("\n");
const attrs: Record<string, string> = {};
for (const line of lines) {
const match = line.match(/^:(\w+):\s*(.+)$/);
if (match) {
attrs[match[1]] = match[2];
} else if (line.startsWith("=")) {
attrs.title = line.replace(/^=+\s*/, "");
} else if (line.trim() === "") {
break; // blank line ends header
}
}
return {
title: attrs.title || "",
published: attrs.published !== "false",
visible: true,
routable: true,
};
}
extractBody(raw: string, _filePath: string): string | null {
// Everything after the first blank line
const index = raw.indexOf("\n\n");
return index >= 0 ? raw.slice(index + 2) : raw;
}
async renderToHtml(page: Page, _ctx: RenderContext): Promise<string> {
// Use an AsciiDoc library to render
const asciidoctor = await import("npm:asciidoctor");
const processor = asciidoctor.default();
return processor.convert(page.rawContent || "");
}
}
Registration
Register your handler when creating the Dune engine:
import { createDuneEngine, MarkdownHandler, TsxHandler } from "dune";
import { AsciiDocHandler } from "./plugins/asciidoc-handler.ts";
const engine = await createDuneEngine({
formats: [
new MarkdownHandler(),
new TsxHandler(),
new AsciiDocHandler(), // your custom format
],
});
Now .adoc files in your content/ directory are treated as first-class content pages, with the same routing, collections, taxonomy, and templating as Markdown and TSX.
Guidelines
extractFrontmatter must be fast. It runs for every content file during indexing. Don't execute content, don't import heavy libraries, don't do I/O beyond reading the file.
extractBody should be pure. Just string splitting — separate frontmatter from body.
renderToHtml can be expensive. It only runs on demand when a specific page is requested. It's cached. Use heavy libraries here.