Writing Plugins
Extend the build pipeline with custom components, data, views, and hooks.
Not available in this release
Project plugins are not in Folioh 0.3. They return with the sidecar protocol:
a plugin is a separate process, written in any language, that folioh starts
for the build and talks to over NDJSON JSON-RPC on stdio. This page documents
the hooks and primitives that surface ships with; the built-in integrations
already run on them.
How Plugins Work
The plugin system is built on three concepts:
- Hook specifications define the extension points (what plugins can do).
- Hook implementations are the handlers a plugin provides for the hooks it needs.
- The plugin host inside
foliohcalls those implementations at the right time during the build.
During folioh build, the host calls config_keys, configure, register_extensions, collect_docs, emit_assets, and post_build, in that order. During folioh serve it also watches the directories a plugin names in watch_paths and calls on_watched_change for a change under one of them.
Extension lifecycle
- 1
Config keys
config_keysDeclare plugin-owned top-level docs.yaml keys before validation.
- 2
Configure
configureNormalize raw plugin config and store data on config.extra.
- 3
Register UI
register_extensionsRegister components, typed data modules, layouts, and generated views.
- 4
Collect docs
collect_docsAdd Markdown sources to Folioh's normal page pipeline.
- 5
Emit assets
emit_assetsWrite generated files into the prepared site before the frontend build.
- 6
Watch
watch_paths / on_watched_changeName extra directories for folioh serve to watch, and react to a change under them.
- 7
Post-build
post_buildRun after the static output directory is written.
Available Hooks
These are the hooks called by the build pipeline. New plugins should use
register_extensions for UI work and data-backed views.
Every hook that reads configuration receives the resolved config: project (name, version, repo, url), output_dir, project_dir, and the extra map. configure gets it mutably, together with the raw docs.yaml mapping, and stores the plugin’s normalized section in extra under its own key; later hooks read it back from there.
An error from configure, register_extensions, or collect_docs stops the build as Plugin '<name>' failed in hook '<hook>': <message>. An error from emit_assets, on_watched_change, or post_build becomes a warning with the same text plus (skipped), and the build goes on.
Extension Model
Folioh keeps customization simple by using four primitives:
- Component — a named React export that can be exposed to MDX pages or plugin views.
- Data — JSON-serializable values emitted as typed frontend modules.
- Layout — a route shell with named slots. Views must choose a layout.
- View — a route composed from a layout, slot blocks, components, and data.
Plugins should prefer the extension registry over writing arbitrary files. Folioh owns import generation, route placement, and build safety.
config_keys
Build-wired. Declare top-level docs.yaml keys that belong to your plugin. Folioh uses this before validation so plugin-owned configuration does not produce unknown-key warnings.
Return the key names as a list; a plugin that owns no key returns an empty one.
configure
Build-wired. Read plugin-owned configuration from the raw docs.yaml mapping and store normalized data on extra. This keeps plugin data out of the core config model while making it available to later build hooks. project_dir is set before this hook runs, so resolve relative paths from your plugin’s config against it rather than the process working directory.
register_extensions
Build-wired. Register components, layouts, data modules, and views. This is the preferred hook for custom UI, plugin-owned routes, and typed frontend data.
collect_docs
Build-wired. Return Markdown or MDX files that should become ordinary Folioh documentation pages. Each document carries a source path and a clean relative route, for example reports/quality.md under the project directory published at reports/quality.
Folioh parses the source and puts it through the same page generation, sidebar, local-image copying, link validation, search, sitemap, Markdown mirror, llms.txt, and incremental manifest as files under source.docs. A route collision with a project document or another plugin fails before either page is written. Use emit_assets instead for files that should be served verbatim.
An unlisted document keeps every surface except the sidebar: the page still compiles at its route and enters search, the sitemap, the Markdown mirror, and llms.txt, but never appears in the docs navigation — a folder holding only unlisted pages is hidden with it. Use it for plugin output that answers to a URL without belonging to the documentation’s table of contents.
watch_paths and on_watched_change
Serve-wired. watch_paths returns directories folioh serve should watch besides the sources; a path that is not an existing directory is ignored. on_watched_change receives the asset builder, the changed path, and whether it was added, modified, or deleted, and returns true when it handled the change. The plugin registry is built once when folioh serve starts, so this hook can rewrite pages and assets, but data modules written by register_extensions refresh only on the next start.
post_build
Build-wired. Run actions after the build completes; the hook receives the path of the generated output directory. Common uses include copying additional assets, generating sitemaps, or running link checkers.
emit_assets
Build-wired. Write generated files into the prepared site before search, dependency checks, and the static build run. Use this for generated MDX pages or public assets. Prefer register_extensions for typed data modules and layout-backed views.
The hook receives the asset builder, which offers these operations:
write_page(route, content)— write an MDX page into the content directory. The route is recorded as a live page automatically, so a successfulwrite_pageneeds no separateregister_routecall.page_exists(route)— report whether a page for the route is already on disk (for example, persisted from a prior warm build).read_page(route)/remove_page(route)— read or delete a page in the content directory. Together with a marker string embedded in your generated content, these carry the warm-build contract: refresh a page only when it still carries your marker, and never touch a user-authored page at the same route.list_pages(prefix)— routes of the pages currently on disk under a content-directory prefix. Use it when your plugin generates a variable set of pages: on a warm build, list your namespace, and remove the marker-tagged pages whose source of truth no longer exists.register_route(route)— record a route as a live page so the link checker treats internal links to it as valid. Call this for every page your plugin owns even when you skipwrite_pagebecause the page persists from a prior build; otherwise links to your page are flagged as broken on warm builds. Only register a route once the page is guaranteed to exist on disk — write the page first, or confirm withpage_exists— because a registered route with no page passes link checking and then 404s on the deployed site.read_meta(directory)/write_meta(directory, meta_json)— read and write the_meta.tssidebar file for a content directory.read_metareturns the raw file text (an empty string when the file does not exist). Always merge your entry into the existing content instead of rewriting the file:_meta.tsfiles written by the sidebar generator contain nested object entries (folder titles, collapse state) that must be preserved.copy_static_asset(relative, source)— copy one source file into the workspacepublic/directory at a containment-checked relative path so the static site serves it verbatim.remove_static_tree(relative)— remove an owned subtree belowpublic/before republishing it on a warm build. It refuses the public root and escaping paths.emitted_routes()— the set of routes recorded viawrite_page/register_routein this build.write_llm_files(llms_txt, llms_full_txt)— override the generatedllms.txt/llms-full.txtfiles.
Dividing Work Between the Build Hooks
Three hooks split a plugin’s output surface, and a plugin may use more than one:
register_extensionsowns everything typed and registry-managed: components, typed data modules, layouts, and views. Folioh generates the imports, the TypeScript modules, and the view routes for you, and validates names and references at registration time.collect_docsowns Markdown and MDX source files: Folioh turns them into normal documentation pages and includes every standard generated surface.emit_assetsowns raw or programmatically generated files in the prepared site: verbatim public assets and pages that do not exist as Markdown source files.
A typical shape: register_extensions registers a component and writes the typed data it renders; collect_docs contributes authored Markdown that uses it; emit_assets publishes any raw bundle the page links to. The roadmap integration pairs the first with a layout-backed view; the OpenAPI example below pairs it with programmatically generated pages.
Official Example: Roadmap
The built-in roadmap integration shows the intended shape for plugin-owned data and generated routes. It is compiled into folioh, and the roadmap: config key is what activates it — without the key the integration emits nothing:
roadmap:
routes:
docs: true
public: true
phases:
- id: "foundation"
version: "0.1"
title: "Foundation"
status: "shipped"
layer: "Source analysis"
summary: "Parse source files into documentation."
command: "folioh build"
features:
- "Parser"
- "Search"The integration declares roadmap as a config key, stores normalized data under extra["roadmap"], emits lib/roadmap-data.ts and lib/roadmap-projects.ts, and can generate a standalone /roadmap/ route. The docs route (/docs/roadmap/) is for explanation; the public route (/roadmap/) is for displaying the same real data outside the docs shell.
The implementation follows the four-primitives model: it registers the Roadmap component, writes typed roadmap data, and declares /roadmap/ as a folioh.public layout-backed view.
The dedicated-page contract
Plugins that need a standalone page should follow the grammar the roadmap view uses, so every plugin page feels like one product:
- Declare the view on the
folioh.publiclayout. Its band renders the Home link, optional sibling cross-links (alinksprop), the title, and an optionaldescription— and anarrowprop switches the page from full width to a centered document column. - Open the content with the
ViewHeaderRulecomponent (mono micro-label, hairline, metadata or controls on the right) — the rule the roadmap’s “Releases” row uses. - Cross-link a sibling view only after checking its route is actually
enabled in
extra, so a disabled integration never produces a dead link.
Official Example: OpenAPI
The OpenAPI integration is the reference for the register_extensions / emit_assets division of labor. It is compiled into folioh; its openapi: section activates it:
openapi:
sources:
- path: "specs/petstore.yaml"
title: "Petstore"
route: "api-reference/petstore"Walking through its hooks in build order:
config_keysdeclaresopenapias a plugin-owned top-level key, so it does not trigger unknown-key warnings.configureloads and normalizes each spec and stores the result underextra["openapi"]. Relativepathvalues are resolved against the project directory, so builds work from any working directory, and a path that resolves outside the project directory fails the build.register_extensionsregisters theOpenApiReferencecomponent and writes a typed data module (lib/openapi-data.ts) whose hand-authored TypeScript interfaces type the exported constant.emit_assetswrites one MDX page per source, merges a_meta.tssidebar entry for it without disturbing the entries the sidebar generator wrote, and registers each route — even when the page already exists from a prior warm build — so internal links to the page stay valid.
Registering components
For a single component file (or a directory of them), the components: key in docs.yaml is how a site adds its own React components — a plugin is for a component that needs data modules, layouts, views, or other hooks alongside it. A plugin registers a component from register_extensions with:
name— the JSX name pages use. Must be a valid JavaScript identifier.import_path— the module path the generated import uses (for example@/components/__folioh_components/interactive-demo).export_name— the export to import when it differs fromname.expose_mdx— expose the component to MDX pages without imports (on by default).source_path— a component source file copied into the build. Relative paths are resolved against the project directory, not the process working directory.props— a mapping of prop names to TypeScript type strings. This is documentation metadata; it does not by itself validate props or publish the component anywhere.required— mark the component as part of the template contract: custom templates must wire it in theirmdx-components.tsx, and template validation fails when it is missing.category— a free-form taxonomy label. It plays no role in the MDX component contract.contract/source_label— mark the component as part of the published MDX component contract (foliohMdxComponents);source_labelcarries the contractsourcestring. Contract membership is explicit: declaringpropsalone does not add a component to the contract. A flagged plugin or config component joins the builtins in both places the contract is published: thelib/folioh-mdx-contract.tsmodule written for the template, and the/_folioh/contract.jsonfile in the exported site.
After registering, a component is available in any MDX page without import statements:
# Architecture
<ArchitectureDiagram layers={["transport", "aggregation", "model"]} />Name collisions and shadowing builtins
Registering a component whose name matches a Folioh builtin (for example Callout or Timeline) is allowed: the config or plugin component shadows the builtin and the build emits a warning noting the override. Registering the same name twice from config or plugins is an error that names both origins.
The published authoring contract
Every build writes /_folioh/contract.json into the exported site. It answers, for this project, the three questions an agent otherwise has to guess at: which components a page may use, which top-level docs.yaml keys the project accepts, and which docs pages the build emitted.
Source batches refresh this contract during folioh serve. An unchanged payload
keeps its file bytes and modification time; generatedAt records the last
semantic contract change, rather than every save or build invocation.
Abridged — a real build lists every component, every accepted key, and every route:
{
"foliohVersion": "0.3.0-a1",
"mdxContractVersion": "1.1",
"generatedAt": "2026-07-28T09:12:04Z",
"instructions": "Ignore fields you do not recognise; later Folioh releases add them. mdxContractVersion versions the components list only.",
"components": [
{
"name": "ParamTable",
"required": true,
"source": "api-reference",
"props": {
"args": "Array<{ name: string; type: string; default?: string; description?: string | null; href?: string }>"
}
}
],
"configKeys": ["components", "deploy", "output", "project", "roadmap"],
"routes": ["/docs/", "/docs/api-reference/", "/docs/plugins/authoring/"]
}components— the MDX component contract for this build: the Folioh builtins plus every config or plugin component flagged ascontract. The same set Folioh writes intolib/folioh-mdx-contract.tsfor the template.configKeys— the core Folioh keys unioned with the keys the active integrations claim through theirconfig_keyshook. Any other top-level key warns whendocs.yamlis read:plugins,i18nandversionsas ignored in this release, anything else as an unknown key.routes— the docs URLs this build emitted, integration-owned pages included.instructions— one line, addressed to the reader: tolerate fields you do not know. Later Folioh releases add fields, and a reader that rejects unknown ones breaks on the next upgrade.
Read the payload defensively. mdxContractVersion versions the components list, and foliohVersion moves with every release, so neither signals a change to the envelope itself. Treat unknown fields as additions rather than errors.
The file is written during the build, from that build’s configuration and active integrations, and it describes the site published next to it. Read it as a snapshot of that build, not as a view of the repository as it stands now.
Folioh publishes it through the workspace public/ directory, which the Next static export carries through unchanged, so the path is the same on every deployment: https://<site>/_folioh/contract.json. A plugin can publish a file the same way from emit_assets with copy_static_asset.
Publishing a plugin
Add the topic folioh-plugin to the public repository. A plugin catalog is on the roadmap, and that topic is the only signal it will use: no listing request, no review queue, no account. Nothing indexes the topic today, so adding it now costs one click and is the whole publishing step when the catalog arrives.
Before you publish, read Trust & Safety. A plugin runs inside its users’ builds, and this publishing convention carries no vetting by design.