Color mode

Plugin System

Riebeckite Plugins extend content interpretation, transformation, rendering, diagnostics, build-time processing, browser behavior, endpoints, and SEO. This document describes the shared plugin contract rather than individual plugins.

Start with the smallest contract

Choose a remark/rehype or pipeline extension for semantic source transforms; use a content hook only when a named content phase is required. Use assets for CSS, client entries only for necessary browser code, endpoints for reusable HTTP behavior, and renderers for a specific target. Do not use a plugin to add application routes or to hide framework-specific routing. Use pageTypes for a reusable, framework-independent page; the integration owns the one generic route that renders it.

Minimal plugin

ts
import { definePlugin } from "@riebeckite/core";
 
export function examplePlugin() {
  return definePlugin({ name: "example" });
}

A plugin factory may expose typed options and retain the resolved options on the plugin object:

ts
type ExampleOptions = {
  enabled?: boolean;
};
 
export function examplePlugin(options: ExampleOptions = {}) {
  return definePlugin({
    name: "example",
    options,
  });
}

Contract overview

Area API


Identity name, enabled, order, options Dependencies provides, requires, optional Validation validateOptions Cache cacheVersion, context.cache Lifecycle setup, buildStart, buildEnd, dispose Content hooks config/content/post/manifest hooks Public location resolveContentLocations Pipeline remark/rehype declarations and extension hooks Graph extendContentGraph Diagnostics addDiagnostics Rendering renderers Pages pageTypes Browser integration assets, clientEntries HTTP integration endpoints SEO seo

Use only the extension points a plugin actually needs.

Ordering and capabilities

Disabled/false/null inputs are removed, and enabled: false is never executed. order provides a basic ordering before dependency resolution, while capability dependencies express real requirements:

ts
plugins: [
  condition && myPlugin(),
]
ts
definePlugin({
  name: "consumer",
  provides: ["example.output"],
  requires: ["content.graph"],
  optional: ["example.optional"],
});
  • provides: capabilities this plugin provides.
  • requires: capabilities that must exist.
  • optional: capabilities used when present.

The resolver places providers before consumers and detects missing requirements, duplicate providers, and cycles while preserving unrelated input order where possible. Capability resolution failures throw PluginDependencyError (importable from @riebeckite/core); kind and pluginName identify the cause.

Option validation

Runtime option validation complements TypeScript factory types. Validators should be pure and must not scan content, build the site, or mutate cache/state.

Plugin Context

The base context contains resolved config when available, contentIndex, diagnostics, plugin-scoped cache, generated-output sink, Logger, Tracer, and the content source when Core owns one. Specialized hooks add post, manifest, graph, location, or render data.

ts
type PluginContext = {
  config?: ResolvedRiebeckiteConfig;
  contentIndex: Map<string, string>;
  diagnostics: Diagnostic[];
  cache: PluginCache;
  output: GeneratedOutputSink;
  logger: Logger;
  tracer: Tracer;
  contentSource?: ContentSource;
};

Prefer injected context services over plugin-owned global singletons.

Lifecycle

Framework lifecycle hooks run once per ContentManager in this order: setup, buildStart, onConfigResolved, content processing, and buildEnd. dispose runs in reverse resolved order when the manager is disposed. buildEnd receives the completed manifest after diagnostics have been collected and is the only terminal build hook.

Named lifecycle and content hooks run in resolved plugin order. A hook failure is reported as PluginHookError (importable from @riebeckite/core); its message names the plugin and hook, cause holds the original error, and for content hooks path identifies the offending file (for example note.md).

Markdown and HTML pipelines

Plugins can declare remark/rehype plugins directly:

ts
definePlugin({
  name: "example",
  remarkPlugins: [remarkExample],
  rehypePlugins: [rehypeExample],
});

Or compose the framework pipelines themselves:

ts
definePlugin({
  name: "example",
  extendMarkdownPipeline(pipeline, context) {
    pipeline.use(remarkExample);
  },
  extendHtmlPipeline(pipeline) {
    pipeline.use(rehypeExample);
  },
});

Semantic Markdown/HTML transformation belongs here, not in application components.

Processed-content build dependencies

Core owns incremental invalidation. A plugin declares its cache contract with processedContentCache; it must not implement its own affected-content logic.

ts
definePlugin({
  name: "citations",
  processedContentCache: {
    version: "citations-v1",
    dependencyMode: "tracked",
  },
  extendMarkdownPipeline(pipeline, context) {
    pipeline.use(remarkCitations, { contentSource: context.contentSource });
  },
});
  • none means processing depends only on the content source, frontmatter, options, and the declared version.
  • tracked means the pipeline reads other content or files. Read them through context.contentSource so Core records the dependency and selectively rebuilds its consumers. For example, a citations plugin reads its BibTeX file with readContentSourceEntry(context.contentSource, path).
  • unsafe opts out of persistent processed-content reuse. A content-affecting plugin without a contract receives the same safe full-content fallback.

Tracked dependencies are captured while processing content. Core persists their content/file identities, builds a reverse index, and computes affected content on the next incremental build. Do not scan the vault independently or persist a plugin-specific incremental state for this purpose. If a dependency cannot be observed through the framework API, use unsafe; a broad rebuild is correct, where a stale result is not.

This is separate from output dependencies. pageTypes[].outputDependencies and context.output.emit(..., { dependencies }) declare which rendered pages or generated files require regeneration. Use content, tag, folder, global, or unknown there; unknown safely requests full output regeneration.

Generated output paths are physical output paths. A generated output must not collide with a content, redirect, or plugin page route; Core fails the build instead of overwriting the route.

Content Hooks

Content hooks join named phases of content processing:

text
setup → buildStart → config resolved → public locations resolved
→ content loaded → Markdown/HTML pipeline → post parsed → post processed
→ content graph → manifest created → diagnostics → build end

Use only the hooks a phase genuinely requires, and do not rebuild later-phase information in an earlier hook.

Content Graph

Use the existing Manifest/Content Graph contracts instead of rescanning the filesystem inside graph-oriented plugins.

Public location

resolveContentLocations lets a plugin replace the public location of content entries. Core first applies its default resolver (resolveDefaultContentLocation: index -> /, otherwise /{slug}), then runs each plugin's hook in resolved plugin order and stores the results as ContentPublicLocation values on the manifest, graph, and Markdown pipeline.

Plugins own their URL strategy: identity fields, hash or frontmatter IDs, path shapes, redirect rules, and their own validation. Core does not know any of that; it knows only ContentLocationInput, ContentPublicLocation, resolveDefaultContentLocation, and the resolveContentLocations hook. Consumers read the resolved entry.permalink, never branch on a specific plugin, and never rebuild a URL from a slug. An entry without a resolved location is an explicit error, not a slug fallback.

Renderers

Renderers receive a target (kind, path, raw, label, url, embed) plus normal plugin context. Return null when the renderer does not handle a target so another renderer can participate.

Body slots

A plugin can contribute an HTML fragment to a named position in the Site-owned article layout without adding a route or touching the document shell. Core defines the slot names as ContentBodySlot; the Site decides which slots to render and where.

The standard article layout recognizes:

Slot Position


article.header directly after the article header article.metadata after the title and meta block article.aside in the article aside article.before-content before the note body article.after-content after the note body article.footer in the article footer

ContentBodySlot also accepts any other string, so a custom Site can define additional slot names.

Publish a fragment with appendContentBodySlot from @riebeckite/core, typically from a manifest hook such as onManifestCreated:

ts
import { appendContentBodySlot } from "@riebeckite/core";
 
appendContentBodySlot(entry, "article.after-content", "<section>...</section>");

Empty fragments are ignored, and fragments accumulate in resolved plugin order: contributions from earlier plugins are preserved and the new fragment is appended.

Use article.footer for article-end sections such as related content, history, navigation, and backlinks. Set each plugin's order to establish a stable sequence; do not reorder these sections in routes or with CSS.

The Site reads entry.bodySlots and chooses whether and where to render each value. It can delegate the rendering mechanics to the public ContentSlot primitive from @riebeckite/honox/ui:

tsx
// app/components/article/article.tsx
import { ContentSlot } from "@riebeckite/honox/ui";
 
<ContentSlot slots={props.bodySlots} name="article.after-content" />

ContentSlot is public API. It owns the slot lookup, missing and empty handling, HTML fragment rendering, and the data-slot attribute; site classes are added with class/className. A slot is rendered only because the Site's own renderer chooses to render it, and a custom slot name does nothing until the Site renders it. The escape hatches remain: read slots directly, wrap a slot in any element, and render the same slot more than once. A plugin can alternatively export a Hono JSX component for the Site to place; see Providing UI or output.

The reference app and the scaffolded starter consume the standard slots. A plugin publishes; the Site renders. A plugin never changes a route, the shell, or the render order. See Body slot handoff for the route-level contract.

Manifest collections and publication safety

Hooks that receive the manifest (onManifestCreated, page resolvers, renderers) choose between three entry collections:

  • manifest.entries — every entry, including draft and scheduled. Never render these into a public page or a discovery UI.
  • manifest.publicEntries — routable entries: public and unlisted. Use for output that must cover every reachable URL, such as a sitemap. It still includes unlisted content.
  • manifest.discoverableEntries — entries allowed in discovery surfaces: public only. Use this for related posts, recent lists, tag pages, search indexes, and any list a reader browses.

Do not re-derive visibility from frontmatter or reimplement publishAt. When a hook genuinely needs to branch, read the resolved entry.publishing (visibility, routable, discoverable); otherwise pick the collection that already encodes the decision. The publish strategy is configured in Configuration.

Pages

pageTypes supplies standalone pages without adding application routes. A page type declares its stable ID, SSG paths, optional priority, and a resolver. The resolver receives the resolved manifest and a normalized request path, then returns HTML for the page body or null. The site still owns its document frame and theme. A page may also return title, description, headTags, and the language it resolved; the document frame decides how to render that metadata.

ts
definePlugin({
  name: "example-pages",
  pageTypes: [{
    id: "example.report",
    paths: ["/report"],
    resolve: ({ pathname, manifest }) => pathname === "/report"
      ? { type: "example.report", pathname, body: `<p>${manifest.discoverableEntries.length}</p>` }
      : null,
  }],
});

Use resolveRiebeckiteRoute(content, c.req.path) and pluginPageSsgParams(content) from @riebeckite/honox/server in a catch-all route. Duplicate IDs fail at plugin resolution. When multiple types match, the highest priority wins; ties fail explicitly.

Build dependencies

processedContentCache declares whether Core may reuse a plugin's processed content between builds. This is separate from cacheVersion and context.cache.

ts
processedContentCache: {
  version: "example-v1",
  dependencyMode: "tracked",
}
  • none is for transforms that depend only on the source content, frontmatter, options, and the declared version.
  • tracked is for transforms that read other content or files through Core. Use context.contentSource, readContentSourceEntry, renderContent, or renderNoteEmbed; Core owns ContentDependencyTracker and records those content and file reads automatically. Do not read the filesystem directly.
  • unsafe is for inputs Core cannot track, such as Git, network, time, or process state. It safely bypasses persistent processed-content reuse.

Content dependencies decide which source content must be processed again. Output dependencies decide which emitted files must be written again. They are separate contracts. Page types declare outputDependencies; a plugin that updates existing manifest entry HTML in onManifestCreated declares root outputDependencies, which are added to those content outputs.

ts
outputDependencies: [{ type: "global" }]
 
pageTypes: [{
  id: "example.report",
  paths: ["/report"],
  outputDependencies: [{ type: "tag", tag: "release" }],
  resolve: () => null,
}]

Use content, tag, or folder when the exact scope is known; use global for a manifest-wide collection transform. Use unknown only when the input cannot be represented: it regenerates safely and requests full output regeneration. Generated outputs without declared dependencies are unknown.

Assets and client entries

Plugin CSS remains in the plugin package and is declared through assets. Browser initialization is declared through clientEntries only when browser JavaScript is genuinely required. Do not copy plugin CSS into apps/web or expose /node_modules directly.

For a package named @riebeckite/plugin-example, use the Core helpers. They declare the package's conventional ./style.css and ./client exports while keeping the integration-facing values consistent:

ts
import {
  createClientEntry,
  createStyleAsset,
  definePlugin,
} from "@riebeckite/core";
 
export function examplePlugin() {
  return definePlugin({
    name: "example",
    assets: [createStyleAsset("example")],
    clientEntries: [
      createClientEntry("example", "initExample", { selector: ".example" }),
    ],
  });
}

The helpers build the specifier from the package name: createStyleAsset("example") produces @riebeckite/plugin-example/style.css and createClientEntry("example", ...) produces @riebeckite/plugin-example/client. They therefore fit only a package literally named @riebeckite/plugin-<name>. A package published under any other name — including a site-local plugin — must declare assets and clientEntries explicitly with specifiers its own exports map exposes. See Distributing a Plugin outside this repository.

Omit assets or clientEntries when the plugin does not need them. The client entry's export name is optional and defaults to the module default export. Its third argument is an optional JSON value passed to that initializer. It is the only plugin configuration exposed to browser code (and recorded in the manifest for static hosts); options are never copied to the client. Register only deliberately public values—never tokens, credentials, or private service URLs. An initializer without public config continues to receive no arguments.

CSS hooks

Plugin CSS stays in the plugin package and reaches the browser through assets. When a plugin renders a distinct, reusable feature, put a stable root hook on its outermost element:

  • Name plugin/feature hooks rr-<feature> (rr-search, rr-callout, rr-query, rr-code, ...). Use BEM structure under the root: rr-<feature>, rr-<feature>__element, rr-<feature>--modifier.
  • Keep the historical class on the same element when one already exists. The rr- hook is additive, so existing selectors and site overrides keep working; new plugin CSS should target the rr- hook.
  • Do not put plugin output in the rb- namespace. rb-* classes and --rb-* tokens belong to framework structural hooks and semantic design tokens. Plugin-local tokens use --rr-* and may fall back to --rb-*.
  • rr-<feature>__* and rr-<feature>--* are internal implementation details. Document any descendant a theme is expected to target.

Themes target these root hooks. Plugin default CSS loads before theme CSS, so a theme restyles a feature without editing the plugin. See Theme System.

For dark mode, consume the semantic --rb-* tokens: they resolve correctly in light, explicit dark, and system dark. Only when a plugin must branch on the mode itself (for example to invert a build-time asset) should it scope the override to the theme root so it works both at the document root and inside an embedded .rb-theme-root: :is(:root, .rb-theme-root)[data-theme="dark"] <hook> and, for system dark, @media (prefers-color-scheme: dark) { :is(:root, .rb-theme-root):not([data-theme]) <hook> { ... } }. Do not key a dark override on html[data-theme="dark"] or :root:not([data-theme]) alone; those miss embedded theme roots. The framework never adds a .dark class, so do not depend on one.

Endpoints and SEO

endpoints lets an Integration connect reusable plugin HTTP behavior to the host router without embedding HonoX-specific routing in Core. seo lets plugins participate in metadata/feed-related behavior through the framework contract.

Use defineEndpoint to declare an endpoint. Pass cacheControl only when the response is safe to cache; the helper applies the header without duplicating response plumbing.

ts
import { defineEndpoint } from "@riebeckite/core";
 
const searchEndpoint = defineEndpoint(
  "/search-data.json",
  ({ config, manifest }) => ({ json: buildSearchItems({ config, manifest }) }),
  { cacheControl: "public, max-age=300" },
);

Diagnostics

Return structured diagnostics rather than printing ad-hoc CLI messages. Use the injected Logger for operational logging.

Plugin Cache

context.cache is a plugin-scoped, regenerable build-time cache.

  • Store only regenerable, JSON-serializable values.
  • Reference only your own plugin namespace.
  • Use cacheVersion when compatibility changes.
  • Treat a corrupt cache as a safe miss.
  • Writes are atomic.

It is not a database or Cloudflare Workers runtime storage.

Observability (Logger / Tracer)

Use context.logger and context.tracer:

ts
context.logger.info("...");
await context.tracer.span("plugin.example.work", { plugin: "example" }, async () => {
  // work
});

The Profiler consumes structured tracing, so plugins do not need their own timing/reporting system.

Suggested package layout

text
packages/plugins/example/
├─ index.ts
├─ components/        # only when you export components
├─ client.ts          # only when needed
├─ style.css          # only when needed
├─ package.json
└─ src/
   ├─ remark.ts
   ├─ rehype.ts
   ├─ renderer.ts
   └─ types.ts

Distributing a Plugin outside this repository

An external Plugin package depends only on @riebeckite/core and declares the subpaths it owns (./client, ./components, ./style.css) in its own exports map. Do not import @riebeckite/core/src/** or reference monorepo paths. See Public packages and import paths for the supported package surface and current constraints.

Package shape

Publish built ESM plus type declarations and point exports at the built files. A complete, copyable manifest:

json
{
  "name": "my-riebeckite-plugin",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "default": "./dist/index.js"
    },
    "./client": {
      "types": "./dist/client.d.ts",
      "import": "./dist/client.js",
      "default": "./dist/client.js"
    },
    "./style.css": "./style.css"
  },
  "files": ["dist", "style.css"],
  "scripts": {
    "build": "node build.mjs && tsc -p tsconfig.json",
    "prepack": "npm run build"
  },
  "dependencies": {
    "@riebeckite/core": "^0.0.19",
    "unist-util-visit": "^5.0.0"
  },
  "devDependencies": {
    "@types/mdast": "^4.0.0",
    "esbuild": "^0.28.0",
    "typescript": "^5.0.0"
  }
}

A plugin without a client.ts or style.css drops those subpaths and files entries.

Bundle the JavaScript entry points with esbuild and emit declarations with tsc. Both are ordinary ecosystem tools; no Riebeckite-specific build script is required.

build.mjs:

js
import { build } from "esbuild";
 
await build({
  entryPoints: ["index.ts", "client.ts"],
  outdir: "dist",
  bundle: true,
  format: "esm",
  platform: "neutral",
  packages: "external",
  external: ["@riebeckite/*"],
  logLevel: "warning",
});

List only the entry points your package actually has (drop client.ts when there is no client).

tsconfig.json:

json
{
  "compilerOptions": {
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "dist",
    "rootDir": ".",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "target": "ESNext",
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["index.ts", "client.ts"]
}

Building in prepack keeps npm pack / npm publish shipping fresh output. Putting @riebeckite/core in dependencies is the simplest choice; use peerDependencies instead when the site should provide the module instance. Declare each transform dependency you import (unist-util-visit, unified, remark/rehype packages) in dependencies, and never point a published exports entry at TypeScript source (./index.ts).

For testing a distributed package, see Testing; for CSS and client packaging, see Assets and Client entries.

Site-local plugins

A plugin does not have to be published. Define it inside the site with definePlugin and pass it to plugins in riebeckite.config.ts; resolution, dependency handling, pipeline hooks, and diagnostics are the same contract as a packaged plugin.

ts
// site/extensions/local-plugin.ts
import { definePlugin } from "@riebeckite/core";
 
export function localPlugin() {
  return definePlugin({
    name: "site-local",
    // Hooks (remarkPlugins, extendHtmlPipeline, endpoints, ...) are the same
    // contract as a packaged plugin.
    assets: [
      {
        pluginName: "site-local",
        kind: "style",
        moduleSpecifier: "/extensions/plugin.css",
      },
    ],
  });
}

createStyleAsset() and createClientEntry() only build @riebeckite/plugin-<name>/... specifiers, so any package not named that way — a site-local plugin, or a third-party package under a different name — must declare moduleSpecifier explicitly: a package subpath or a path relative to the Vite root that the host bundler can resolve. The External Site Build E2E (tests/external-site) exercises a site-local plugin and theme alongside the published packages.

Responsibility boundary

Put in a plugin:

  • Markdown/HTML interpretation and reusable content transformation.
  • Plugin-specific renderers, reusable browser behavior, and diagnostics.
  • Plugin-specific endpoint/SEO extensions.

Do not put in a plugin:

  • Framework-wide content model — belongs in Core.
  • HonoX/Vite connections — belong in the Integration.
  • Application-specific routes/layouts — belong in the App.
  • Appearance-only changes — belong in Themes.

ESM

For NodeNext/ESM packages, ensure built JavaScript uses import paths Node can actually resolve; do not depend on a TypeScript loader repairing runtime resolution.

History

1 changesCollapseExpand
1 + # Plugin System
2 +
3 + Riebeckite Plugins extend content interpretation, transformation,
4 + rendering, diagnostics, build-time processing, browser behavior,
5 + endpoints, and SEO. This document describes the shared plugin contract
6 + rather than individual plugins.
7 +
8 + ## Start with the smallest contract
9 +
10 + Choose a remark/rehype or pipeline extension for semantic source transforms;
11 + use a content hook only when a named content phase is required. Use assets for
12 + CSS, client entries only for necessary browser code, endpoints for reusable
13 + HTTP behavior, and renderers for a specific target. Do not use a plugin to add
14 + application routes or to hide framework-specific routing. Use `pageTypes` for
15 + a reusable, framework-independent page; the integration owns the one generic
16 + route that renders it.
17 +
18 + ## Minimal plugin
19 +
20 + ``` ts
21 + import { definePlugin } from "@riebeckite/core";
22 +
23 + export function examplePlugin() {
24 + return definePlugin({ name: "example" });
25 + }
26 + ```
27 +
28 + A plugin factory may expose typed options and retain the resolved
29 + options on the plugin object:
30 +
31 + ``` ts
32 + type ExampleOptions = {
33 + enabled?: boolean;
34 + };
35 +
36 + export function examplePlugin(options: ExampleOptions = {}) {
37 + return definePlugin({
38 + name: "example",
39 + options,
40 + });
41 + }
42 + ```
43 +
44 + ## Contract overview
45 +
46 + Area API
47 + --------------------- ------------------------------------------------
48 + Identity `name`, `enabled`, `order`, `options`
49 + Dependencies `provides`, `requires`, `optional`
50 + Validation `validateOptions`
51 + Cache `cacheVersion`, `context.cache`
52 + Lifecycle `setup`, `buildStart`, `buildEnd`, `dispose`
53 + Content hooks config/content/post/manifest hooks
54 + Public location `resolveContentLocations`
55 + Pipeline remark/rehype declarations and extension hooks
56 + Graph `extendContentGraph`
57 + Diagnostics `addDiagnostics`
58 + Rendering `renderers`
59 + Pages `pageTypes`
60 + Browser integration `assets`, `clientEntries`
61 + HTTP integration `endpoints`
62 + SEO `seo`
63 +
64 + Use only the extension points a plugin actually needs.
65 +
66 + ## Ordering and capabilities
67 +
68 + Disabled/false/null inputs are removed, and `enabled: false` is never
69 + executed. `order` provides a basic ordering before dependency resolution,
70 + while capability dependencies express real requirements:
71 +
72 + ``` ts
73 + plugins: [
74 + condition && myPlugin(),
75 + ]
76 + ```
77 +
78 + ``` ts
79 + definePlugin({
80 + name: "consumer",
81 + provides: ["example.output"],
82 + requires: ["content.graph"],
83 + optional: ["example.optional"],
84 + });
85 + ```
86 +
87 + - `provides`: capabilities this plugin provides.
88 + - `requires`: capabilities that must exist.
89 + - `optional`: capabilities used when present.
90 +
91 + The resolver places providers before consumers and detects missing
92 + requirements, duplicate providers, and cycles while preserving unrelated
93 + input order where possible. Capability resolution failures throw
94 + `PluginDependencyError` (importable from `@riebeckite/core`); `kind` and
95 + `pluginName` identify the cause.
96 +
97 + ## Option validation
98 +
99 + Runtime option validation complements TypeScript factory types.
100 + Validators should be pure and must not scan content, build the site, or
101 + mutate cache/state.
102 +
103 + ## Plugin Context
104 +
105 + The base context contains resolved config when available, `contentIndex`,
106 + diagnostics, plugin-scoped cache, generated-output sink, Logger, Tracer, and
107 + the content source when Core owns one.
108 + Specialized hooks add post, manifest, graph, location, or render data.
109 +
110 + ``` ts
111 + type PluginContext = {
112 + config?: ResolvedRiebeckiteConfig;
113 + contentIndex: Map<string, string>;
114 + diagnostics: Diagnostic[];
115 + cache: PluginCache;
116 + output: GeneratedOutputSink;
117 + logger: Logger;
118 + tracer: Tracer;
119 + contentSource?: ContentSource;
120 + };
121 + ```
122 +
123 + Prefer injected context services over plugin-owned global singletons.
124 +
125 + ## Lifecycle
126 +
127 + Framework lifecycle hooks run once per `ContentManager` in this order:
128 + `setup`, `buildStart`, `onConfigResolved`, content processing, and `buildEnd`.
129 + `dispose` runs in reverse resolved order when the manager is disposed.
130 + `buildEnd` receives the completed manifest after diagnostics have been
131 + collected and is the only terminal build hook.
132 +
133 + Named lifecycle and content hooks run in resolved plugin order. A hook failure
134 + is reported as `PluginHookError` (importable from `@riebeckite/core`); its
135 + `message` names the plugin and hook, `cause` holds the original error, and for
136 + content hooks `path` identifies the offending file (for example `note.md`).
137 +
138 + ## Markdown and HTML pipelines
139 +
140 + Plugins can declare remark/rehype plugins directly:
141 +
142 + ``` ts
143 + definePlugin({
144 + name: "example",
145 + remarkPlugins: [remarkExample],
146 + rehypePlugins: [rehypeExample],
147 + });
148 + ```
149 +
150 + Or compose the framework pipelines themselves:
151 +
152 + ``` ts
153 + definePlugin({
154 + name: "example",
155 + extendMarkdownPipeline(pipeline, context) {
156 + pipeline.use(remarkExample);
157 + },
158 + extendHtmlPipeline(pipeline) {
159 + pipeline.use(rehypeExample);
160 + },
161 + });
162 + ```
163 +
164 + Semantic Markdown/HTML transformation belongs here, not in application
165 + components.
166 +
167 + ## Processed-content build dependencies
168 +
169 + Core owns incremental invalidation. A plugin declares its cache contract with
170 + `processedContentCache`; it must not implement its own affected-content logic.
171 +
172 + ```ts
173 + definePlugin({
174 + name: "citations",
175 + processedContentCache: {
176 + version: "citations-v1",
177 + dependencyMode: "tracked",
178 + },
179 + extendMarkdownPipeline(pipeline, context) {
180 + pipeline.use(remarkCitations, { contentSource: context.contentSource });
181 + },
182 + });
183 + ```
184 +
185 + - `none` means processing depends only on the content source, frontmatter,
186 + options, and the declared version.
187 + - `tracked` means the pipeline reads other content or files. Read them through
188 + `context.contentSource` so Core records the dependency and selectively
189 + rebuilds its consumers. For example, a citations plugin reads its BibTeX file
190 + with `readContentSourceEntry(context.contentSource, path)`.
191 + - `unsafe` opts out of persistent processed-content reuse. A content-affecting
192 + plugin without a contract receives the same safe full-content fallback.
193 +
194 + Tracked dependencies are captured while processing content. Core persists their
195 + content/file identities, builds a reverse index, and computes affected content
196 + on the next incremental build. Do not scan the vault independently or persist a
197 + plugin-specific incremental state for this purpose. If a dependency cannot be
198 + observed through the framework API, use `unsafe`; a broad rebuild is correct,
199 + where a stale result is not.
200 +
201 + This is separate from output dependencies. `pageTypes[].outputDependencies`
202 + and `context.output.emit(..., { dependencies })` declare which rendered pages
203 + or generated files require regeneration. Use `content`, `tag`, `folder`,
204 + `global`, or `unknown` there; `unknown` safely requests full output
205 + regeneration.
206 +
207 + Generated output paths are physical output paths. A generated output must not
208 + collide with a content, redirect, or plugin page route; Core fails the build
209 + instead of overwriting the route.
210 +
211 + ## Content Hooks
212 +
213 + Content hooks join named phases of content processing:
214 +
215 + ``` text
216 + setup → buildStart → config resolved → public locations resolved
217 + → content loaded → Markdown/HTML pipeline → post parsed → post processed
218 + → content graph → manifest created → diagnostics → build end
219 + ```
220 +
221 + Use only the hooks a phase genuinely requires, and do not rebuild later-phase
222 + information in an earlier hook.
223 +
224 + ## Content Graph
225 +
226 + Use the existing Manifest/Content Graph contracts instead of rescanning
227 + the filesystem inside graph-oriented plugins.
228 +
229 + ## Public location
230 +
231 + `resolveContentLocations` lets a plugin replace the public location of content
232 + entries. Core first applies its default resolver
233 + (`resolveDefaultContentLocation`: `index` -> `/`, otherwise `/{slug}`), then runs
234 + each plugin's hook in resolved plugin order and stores the results as
235 + `ContentPublicLocation` values on the manifest, graph, and Markdown pipeline.
236 +
237 + Plugins own their URL strategy: identity fields, hash or frontmatter IDs, path
238 + shapes, redirect rules, and their own validation. Core does not know any of
239 + that; it knows only `ContentLocationInput`, `ContentPublicLocation`,
240 + `resolveDefaultContentLocation`, and the `resolveContentLocations` hook.
241 + Consumers read the resolved `entry.permalink`, never branch on a specific plugin,
242 + and never rebuild a URL from a slug. An entry without a resolved location is an
243 + explicit error, not a slug fallback.
244 +
245 + ## Renderers
246 +
247 + Renderers receive a target (`kind`, `path`, `raw`, `label`, `url`,
248 + `embed`) plus normal plugin context. Return `null` when the renderer
249 + does not handle a target so another renderer can participate.
250 +
251 + ## Body slots
252 +
253 + A plugin can contribute an HTML fragment to a named position in the
254 + Site-owned article layout without adding a route or touching the document
255 + shell. Core defines the slot names as `ContentBodySlot`; the Site decides
256 + which slots to render and where.
257 +
258 + The standard article layout recognizes:
259 +
260 + Slot Position
261 + ------------------------ -----------------------------------------------
262 + article.header directly after the article header
263 + article.metadata after the title and meta block
264 + article.aside in the article aside
265 + article.before-content before the note body
266 + article.after-content after the note body
267 + article.footer in the article footer
268 +
269 + `ContentBodySlot` also accepts any other string, so a custom Site can define
270 + additional slot names.
271 +
272 + Publish a fragment with `appendContentBodySlot` from `@riebeckite/core`,
273 + typically from a manifest hook such as `onManifestCreated`:
274 +
275 + ```ts
276 + import { appendContentBodySlot } from "@riebeckite/core";
277 +
278 + appendContentBodySlot(entry, "article.after-content", "<section>...</section>");
279 + ```
280 +
281 + Empty fragments are ignored, and fragments accumulate in resolved plugin
282 + order: contributions from earlier plugins are preserved and the new fragment
283 + is appended.
284 +
285 + Use `article.footer` for article-end sections such as related content,
286 + history, navigation, and backlinks. Set each plugin's `order` to establish a
287 + stable sequence; do not reorder these sections in routes or with CSS.
288 +
289 + The Site reads `entry.bodySlots` and chooses whether and where to render each
290 + value. It can delegate the rendering mechanics to the public `ContentSlot`
291 + primitive from `@riebeckite/honox/ui`:
292 +
293 + ```tsx
294 + // app/components/article/article.tsx
295 + import { ContentSlot } from "@riebeckite/honox/ui";
296 +
297 + <ContentSlot slots={props.bodySlots} name="article.after-content" />
298 + ```
299 +
300 + `ContentSlot` is public API. It owns the slot lookup, missing and empty
301 + handling, HTML fragment rendering, and the `data-slot` attribute; site classes
302 + are added with `class`/`className`. A slot is rendered only because the Site's
303 + own renderer chooses to render it, and a custom slot name does nothing until the
304 + Site renders it. The escape hatches remain: read `slots` directly, wrap a slot
305 + in any element, and render the same slot more than once. A plugin can
306 + alternatively export a Hono JSX component for the Site to place; see
307 + [Providing UI or output](../plugins/writing-a-plugin.en.md#providing-ui-or-output).
308 +
309 + The reference app and the scaffolded starter consume the standard slots. A
310 + plugin publishes; the Site renders. A plugin never changes a route, the shell,
311 + or the render order. See
312 + [Body slot handoff](../framework/honox-integration.en.md#body-slot-handoff) for
313 + the route-level contract.
314 +
315 + ## Manifest collections and publication safety
316 +
317 + Hooks that receive the manifest (`onManifestCreated`, page resolvers, renderers)
318 + choose between three entry collections:
319 +
320 + - `manifest.entries` — every entry, including `draft` and `scheduled`. Never
321 + render these into a public page or a discovery UI.
322 + - `manifest.publicEntries` — routable entries: `public` and `unlisted`. Use for
323 + output that must cover every reachable URL, such as a sitemap. It still
324 + includes `unlisted` content.
325 + - `manifest.discoverableEntries` — entries allowed in discovery surfaces:
326 + `public` only. Use this for related posts, recent lists, tag pages, search
327 + indexes, and any list a reader browses.
328 +
329 + Do not re-derive visibility from `frontmatter` or reimplement `publishAt`. When a
330 + hook genuinely needs to branch, read the resolved `entry.publishing`
331 + (`visibility`, `routable`, `discoverable`); otherwise pick the collection that
332 + already encodes the decision. The publish strategy is configured in
333 + [Configuration](./configuration.en.md).
334 +
335 + ## Pages
336 +
337 + `pageTypes` supplies standalone pages without adding application routes. A page
338 + type declares its stable ID, SSG paths, optional priority, and a resolver. The
339 + resolver receives the resolved manifest and a normalized request path, then
340 + returns HTML for the page body or `null`. The site still owns its document frame
341 + and theme. A page may also return `title`, `description`, `headTags`, and the
342 + `language` it resolved; the document frame decides how to render that metadata.
343 +
344 + ```ts
345 + definePlugin({
346 + name: "example-pages",
347 + pageTypes: [{
348 + id: "example.report",
349 + paths: ["/report"],
350 + resolve: ({ pathname, manifest }) => pathname === "/report"
351 + ? { type: "example.report", pathname, body: `<p>${manifest.discoverableEntries.length}</p>` }
352 + : null,
353 + }],
354 + });
355 + ```
356 +
357 + Use `resolveRiebeckiteRoute(content, c.req.path)` and
358 + `pluginPageSsgParams(content)` from `@riebeckite/honox/server` in a catch-all
359 + route. Duplicate IDs fail at plugin resolution. When multiple types match, the
360 + highest `priority` wins; ties fail explicitly.
361 +
362 + ## Build dependencies
363 +
364 + `processedContentCache` declares whether Core may reuse a plugin's processed
365 + content between builds. This is separate from `cacheVersion` and
366 + `context.cache`.
367 +
368 + ```ts
369 + processedContentCache: {
370 + version: "example-v1",
371 + dependencyMode: "tracked",
372 + }
373 + ```
374 +
375 + - `none` is for transforms that depend only on the source content, frontmatter,
376 + options, and the declared version.
377 + - `tracked` is for transforms that read other content or files through Core.
378 + Use `context.contentSource`, `readContentSourceEntry`, `renderContent`, or
379 + `renderNoteEmbed`; Core owns `ContentDependencyTracker` and records those
380 + content and file reads automatically. Do not read the filesystem directly.
381 + - `unsafe` is for inputs Core cannot track, such as Git, network, time, or
382 + process state. It safely bypasses persistent processed-content reuse.
383 +
384 + Content dependencies decide which source content must be processed again.
385 + Output dependencies decide which emitted files must be written again. They are
386 + separate contracts. Page types declare `outputDependencies`; a plugin that
387 + updates existing manifest entry HTML in `onManifestCreated` declares root
388 + `outputDependencies`, which are added to those content outputs.
389 +
390 + ```ts
391 + outputDependencies: [{ type: "global" }]
392 +
393 + pageTypes: [{
394 + id: "example.report",
395 + paths: ["/report"],
396 + outputDependencies: [{ type: "tag", tag: "release" }],
397 + resolve: () => null,
398 + }]
399 + ```
400 +
401 + Use `content`, `tag`, or `folder` when the exact scope is known; use `global`
402 + for a manifest-wide collection transform. Use `unknown` only when the input
403 + cannot be represented: it regenerates safely and requests full output
404 + regeneration. Generated outputs without declared dependencies are `unknown`.
405 +
406 + ## Assets and client entries
407 +
408 + Plugin CSS remains in the plugin package and is declared through
409 + `assets`. Browser initialization is declared through `clientEntries`
410 + only when browser JavaScript is genuinely required. Do not copy plugin
411 + CSS into `apps/web` or expose `/node_modules` directly.
412 +
413 + For a package named `@riebeckite/plugin-example`, use the Core helpers. They
414 + declare the package's conventional `./style.css` and `./client` exports while
415 + keeping the integration-facing values consistent:
416 +
417 + ```ts
418 + import {
419 + createClientEntry,
420 + createStyleAsset,
421 + definePlugin,
422 + } from "@riebeckite/core";
423 +
424 + export function examplePlugin() {
425 + return definePlugin({
426 + name: "example",
427 + assets: [createStyleAsset("example")],
428 + clientEntries: [
429 + createClientEntry("example", "initExample", { selector: ".example" }),
430 + ],
431 + });
432 + }
433 + ```
434 +
435 + The helpers build the specifier from the package name: `createStyleAsset("example")`
436 + produces `@riebeckite/plugin-example/style.css` and
437 + `createClientEntry("example", ...)` produces `@riebeckite/plugin-example/client`.
438 + They therefore fit only a package literally named `@riebeckite/plugin-<name>`. A
439 + package published under any other name — including a site-local plugin — must
440 + declare `assets` and `clientEntries` explicitly with specifiers its own `exports`
441 + map exposes. See
442 + [Distributing a Plugin outside this repository](#distributing-a-plugin-outside-this-repository).
443 +
444 + Omit `assets` or `clientEntries` when the plugin does not need them. The client
445 + entry's export name is optional and defaults to the module default export. Its
446 + third argument is an optional JSON value passed to that initializer. It is the
447 + only plugin configuration exposed to browser code (and recorded in the
448 + manifest for static hosts); `options` are never copied to the client. Register
449 + only deliberately public values—never tokens, credentials, or private service
450 + URLs. An initializer without public config continues to receive no arguments.
451 +
452 + ## CSS hooks
453 +
454 + Plugin CSS stays in the plugin package and reaches the browser through
455 + `assets`. When a plugin renders a distinct, reusable feature, put a stable
456 + root hook on its outermost element:
457 +
458 + - Name plugin/feature hooks `rr-<feature>` (`rr-search`, `rr-callout`,
459 + `rr-query`, `rr-code`, ...). Use BEM structure under the root:
460 + `rr-<feature>`, `rr-<feature>__element`, `rr-<feature>--modifier`.
461 + - Keep the historical class on the same element when one already exists. The
462 + `rr-` hook is additive, so existing selectors and site overrides keep
463 + working; new plugin CSS should target the `rr-` hook.
464 + - Do not put plugin output in the `rb-` namespace. `rb-*` classes and
465 + `--rb-*` tokens belong to framework structural hooks and semantic design
466 + tokens. Plugin-local tokens use `--rr-*` and may fall back to `--rb-*`.
467 + - `rr-<feature>__*` and `rr-<feature>--*` are internal implementation
468 + details. Document any descendant a theme is expected to target.
469 +
470 + Themes target these root hooks. Plugin default CSS loads before theme CSS, so
471 + a theme restyles a feature without editing the plugin. See
472 + [Theme System](./theme-api.en.md#stable-css-hooks).
473 +
474 + For dark mode, consume the semantic `--rb-*` tokens: they resolve correctly in
475 + light, explicit dark, and system dark. Only when a plugin must branch on the
476 + mode itself (for example to invert a build-time asset) should it scope the
477 + override to the theme root so it works both at the document root and inside an
478 + embedded `.rb-theme-root`:
479 + `:is(:root, .rb-theme-root)[data-theme="dark"] <hook>` and, for system dark,
480 + `@media (prefers-color-scheme: dark) { :is(:root, .rb-theme-root):not([data-theme]) <hook> { ... } }`.
481 + Do not key a dark override on `html[data-theme="dark"]` or
482 + `:root:not([data-theme])` alone; those miss embedded theme roots. The
483 + framework never adds a `.dark` class, so do not depend on one.
484 +
485 + ## Endpoints and SEO
486 +
487 + `endpoints` lets an Integration connect reusable plugin HTTP behavior to
488 + the host router without embedding HonoX-specific routing in Core. `seo`
489 + lets plugins participate in metadata/feed-related behavior through the
490 + framework contract.
491 +
492 + Use `defineEndpoint` to declare an endpoint. Pass `cacheControl` only when the
493 + response is safe to cache; the helper applies the header without duplicating
494 + response plumbing.
495 +
496 + ```ts
497 + import { defineEndpoint } from "@riebeckite/core";
498 +
499 + const searchEndpoint = defineEndpoint(
500 + "/search-data.json",
501 + ({ config, manifest }) => ({ json: buildSearchItems({ config, manifest }) }),
502 + { cacheControl: "public, max-age=300" },
503 + );
504 + ```
505 +
506 + ## Diagnostics
507 +
508 + Return structured diagnostics rather than printing ad-hoc CLI messages.
509 + Use the injected Logger for operational logging.
510 +
511 + ## Plugin Cache
512 +
513 + `context.cache` is a plugin-scoped, regenerable **build-time** cache.
514 +
515 + - Store only regenerable, JSON-serializable values.
516 + - Reference only your own plugin namespace.
517 + - Use `cacheVersion` when compatibility changes.
518 + - Treat a corrupt cache as a safe miss.
519 + - Writes are atomic.
520 +
521 + It is not a database or Cloudflare Workers runtime storage.
522 +
523 + ## Observability (Logger / Tracer)
524 +
525 + Use `context.logger` and `context.tracer`:
526 +
527 + ``` ts
528 + context.logger.info("...");
529 + await context.tracer.span("plugin.example.work", { plugin: "example" }, async () => {
530 + // work
531 + });
532 + ```
533 +
534 + The Profiler consumes structured tracing, so plugins do not need their own
535 + timing/reporting system.
536 +
537 + ## Suggested package layout
538 +
539 + ``` text
540 + packages/plugins/example/
541 + ├─ index.ts
542 + ├─ components/ # only when you export components
543 + ├─ client.ts # only when needed
544 + ├─ style.css # only when needed
545 + ├─ package.json
546 + └─ src/
547 + ├─ remark.ts
548 + ├─ rehype.ts
549 + ├─ renderer.ts
550 + └─ types.ts
551 + ```
552 +
553 + ## Distributing a Plugin outside this repository
554 +
555 + An external Plugin package depends only on `@riebeckite/core` and declares the
556 + subpaths it owns (`./client`, `./components`, `./style.css`) in its own `exports`
557 + map. Do not import `@riebeckite/core/src/**` or reference monorepo paths. See
558 + [Public packages and import paths](./README.en.md#public-packages-and-import-paths)
559 + for the supported package surface and current constraints.
560 +
561 + ### Package shape
562 +
563 + Publish built ESM plus type declarations and point `exports` at the built files.
564 + A complete, copyable manifest:
565 +
566 + ```json
567 + {
568 + "name": "my-riebeckite-plugin",
569 + "version": "1.0.0",
570 + "type": "module",
571 + "main": "./dist/index.js",
572 + "types": "./dist/index.d.ts",
573 + "exports": {
574 + ".": {
575 + "types": "./dist/index.d.ts",
576 + "import": "./dist/index.js",
577 + "default": "./dist/index.js"
578 + },
579 + "./client": {
580 + "types": "./dist/client.d.ts",
581 + "import": "./dist/client.js",
582 + "default": "./dist/client.js"
583 + },
584 + "./style.css": "./style.css"
585 + },
586 + "files": ["dist", "style.css"],
587 + "scripts": {
588 + "build": "node build.mjs && tsc -p tsconfig.json",
589 + "prepack": "npm run build"
590 + },
591 + "dependencies": {
592 + "@riebeckite/core": "^0.0.19",
593 + "unist-util-visit": "^5.0.0"
594 + },
595 + "devDependencies": {
596 + "@types/mdast": "^4.0.0",
597 + "esbuild": "^0.28.0",
598 + "typescript": "^5.0.0"
599 + }
600 + }
601 + ```
602 +
603 + A plugin without a `client.ts` or `style.css` drops those subpaths and `files`
604 + entries.
605 +
606 + Bundle the JavaScript entry points with `esbuild` and emit declarations with
607 + `tsc`. Both are ordinary ecosystem tools; no Riebeckite-specific build script is
608 + required.
609 +
610 + `build.mjs`:
611 +
612 + ```js
613 + import { build } from "esbuild";
614 +
615 + await build({
616 + entryPoints: ["index.ts", "client.ts"],
617 + outdir: "dist",
618 + bundle: true,
619 + format: "esm",
620 + platform: "neutral",
621 + packages: "external",
622 + external: ["@riebeckite/*"],
623 + logLevel: "warning",
624 + });
625 + ```
626 +
627 + List only the entry points your package actually has (drop `client.ts` when there
628 + is no client).
629 +
630 + `tsconfig.json`:
631 +
632 + ```json
633 + {
634 + "compilerOptions": {
635 + "declaration": true,
636 + "emitDeclarationOnly": true,
637 + "outDir": "dist",
638 + "rootDir": ".",
639 + "module": "ESNext",
640 + "moduleResolution": "Bundler",
641 + "target": "ESNext",
642 + "lib": ["ESNext", "DOM", "DOM.Iterable"],
643 + "strict": true,
644 + "skipLibCheck": true
645 + },
646 + "include": ["index.ts", "client.ts"]
647 + }
648 + ```
649 +
650 + Building in `prepack` keeps `npm pack` / `npm publish` shipping fresh output.
651 + Putting `@riebeckite/core` in `dependencies` is the simplest choice; use
652 + `peerDependencies` instead when the site should provide the module instance.
653 + Declare each transform dependency you import (`unist-util-visit`, `unified`,
654 + remark/rehype packages) in `dependencies`, and never point a published `exports`
655 + entry at TypeScript source (`./index.ts`).
656 +
657 + For testing a distributed package, see [Testing](../framework/testing.en.md#testing-a-plugin);
658 + for CSS and client packaging, see [Assets](#assets) and [Client entries](#client-entries).
659 +
660 + ### Site-local plugins
661 +
662 + A plugin does not have to be published. Define it inside the site with
663 + `definePlugin` and pass it to `plugins` in `riebeckite.config.ts`; resolution,
664 + dependency handling, pipeline hooks, and diagnostics are the same contract as a
665 + packaged plugin.
666 +
667 + ``` ts
668 + // site/extensions/local-plugin.ts
669 + import { definePlugin } from "@riebeckite/core";
670 +
671 + export function localPlugin() {
672 + return definePlugin({
673 + name: "site-local",
674 + // Hooks (remarkPlugins, extendHtmlPipeline, endpoints, ...) are the same
675 + // contract as a packaged plugin.
676 + assets: [
677 + {
678 + pluginName: "site-local",
679 + kind: "style",
680 + moduleSpecifier: "/extensions/plugin.css",
681 + },
682 + ],
683 + });
684 + }
685 + ```
686 +
687 + `createStyleAsset()` and `createClientEntry()` only build
688 + `@riebeckite/plugin-<name>/...` specifiers, so any package not named that way —
689 + a site-local plugin, or a third-party package under a different name — must
690 + declare `moduleSpecifier` explicitly: a package subpath or a path relative to
691 + the Vite root that the host bundler can resolve. The External Site Build E2E
692 + (`tests/external-site`) exercises a site-local plugin and theme alongside the
693 + published packages.
694 +
695 + ## Responsibility boundary
696 +
697 + Put in a plugin:
698 +
699 + - Markdown/HTML interpretation and reusable content transformation.
700 + - Plugin-specific renderers, reusable browser behavior, and diagnostics.
701 + - Plugin-specific endpoint/SEO extensions.
702 +
703 + Do not put in a plugin:
704 +
705 + - Framework-wide content model — belongs in Core.
706 + - HonoX/Vite connections — belong in the Integration.
707 + - Application-specific routes/layouts — belong in the App.
708 + - Appearance-only changes — belong in Themes.
709 +
710 + ## ESM
711 +
712 + For NodeNext/ESM packages, ensure built JavaScript uses import paths
713 + Node can actually resolve; do not depend on a TypeScript loader
714 + repairing runtime resolution.
715 +
716 + ## Related
717 +
718 + - [Architecture](../framework/architecture.en.md)
719 + - [Content System](../framework/content-system.en.md)
720 + - [Testing](../framework/testing.en.md)
721 + - [Observability](../framework/observability.en.md)
722 + - [Theme System](./theme-api.en.md)
723 + - [Framework Reference](./README.en.md)
724 +