Color mode

Writing Your First Plugin

Plugins add functionality to Riebeckite: Markdown or HTML transformation, client-side behavior, standalone pages, SEO, diagnostics, and more. Use a Theme when you only want to change appearance.

Plugins are trusted application code. When a Plugin emits HTML, page bodies, head tags, client entries, endpoints, or generated files, it is responsible for escaping untrusted text and validating URLs for the exact context. Riebeckite preserves raw Markdown HTML and does not sanitize Plugin-generated HTML. See Security model.

When a Plugin generates UI, follow the accessibility contract: prefer semantic HTML, use real links and buttons, keep keyboard operation and focus management working, and synchronize ARIA state only when ARIA is needed.

A Plugin can live directly inside a site; it does not have to be published as a package.

The path

This page follows one continuous path, from an empty folder to a distributable Plugin:

text
Create a minimal Plugin (1-2)
   ↓
Transform Markdown or HTML (3, Hands-on)
   ↓
Test the output (Testing a plugin)
   ↓
Package it (5)
   ↓
Verify it as an external package (tests/plugin-dx / test:plugin-dx:external)

If the Plugin only runs inside one site, you can stop before packaging. Only distributed Plugin packages need the packaging and external-verification steps. For the exact contract of each extension point, see the Plugin API.

1. Create a minimal Plugin

Create Plugins with definePlugin from @riebeckite/core. A Plugin with only a name is the smallest valid form. Plugin factories can accept typed options when configuration is needed.

2. Add CSS

Declare Plugin-specific stylesheets through assets. Do not copy CSS into the site manually or reference /node_modules directly from the browser.

Use a stable root hook such as rr-<feature> on rendered output. See Plugin API for the CSS contract.

3. Transform Markdown or HTML

Semantic Markdown transformation belongs to Plugins. Simple remark Plugins can be declared as an array. Use extendMarkdownPipeline / extendHtmlPipeline when you need finer control of the processing pipeline.

remarkPlugins, rehypePlugins, and extendMarkdownPipeline all join the same pipeline. Riebeckite runs remark-parse, remark-directive, remark-gfm, and the other base plugins before your Plugin, so directive syntax such as :::tip and GFM already arrive as AST nodes. You do not install or register those yourself.

For dependencies, lifecycle hooks, renderers, endpoints, and other extension points, see Plugin API.

Plugins that participate in content transformation should also declare processedContentCache. Without it a plugin still works, but it disables the site's processed-content cache so Markdown is reprocessed on every build. Use { version: "1", dependencyMode: "none" } for a standalone transform and tracked when the output depends on other plugins (reserve unsafe for what cannot be tracked accurately).

Hands-on: Build a directive plugin from start to finish

In this section, you will build a small plugin that turns the following Markdown into a custom Tip block:

md
:::tip[Heads up]
Save often.
:::

The generated HTML will look like this:

html
<aside class="rr-tip">
  <p class="rr-tip__title">Heads up</p>
  <p>Save often.</p>
</aside>

We will build it in four steps:

  1. Create a plugin that transforms the Markdown
  2. Add CSS for the Tip block
  3. Register the plugin in riebeckite.config.ts
  4. Test the generated HTML

Step 1: Create the plugin

Create extensions/tip-plugin.ts.

First, let's look at the entry point of the plugin:

ts
import { definePlugin } from "@riebeckite/core";
 
export function tipPlugin() {
  return definePlugin({
    name: "tip",
 
    extendMarkdownPipeline: (pipeline) => {
      pipeline.use(remarkTip);
    },
 
    assets: [
      {
        pluginName: "tip",
        kind: "style",
        moduleSpecifier: "/extensions/plugin.css",
      },
    ],
  });
}

This plugin does two things:

  • adds a Markdown transformation called remarkTip
  • loads /extensions/plugin.css as a stylesheet

remarkTip is the part that actually turns :::tip into an <aside> element.

Find :::tip

Add the imports and a small type for the directive node:

ts
import { definePlugin } from "@riebeckite/core";
import type { Parent, Root } from "mdast";
import { visit } from "unist-util-visit";
 
type ContainerDirective = {
  type: "containerDirective";
  name: string;
  children: Parent["children"];
  data?: Record<string, unknown>;
};

This example uses the mdast types for AST nodes and unist-util-visit to walk the tree. Add them to your own package's dependencies (they are not imported from @riebeckite/core).

In Riebeckite's Markdown pipeline, syntax such as :::tip has already been parsed by remark-directive before your plugin runs.

That means your plugin does not need to parse the Markdown text itself. It receives a containerDirective node instead.

Use unist-util-visit to find those nodes:

ts
function remarkTip() {
  return (tree: Root) => {
    visit(tree, "containerDirective", (node) => {
      const directive = node as unknown as ContainerDirective;
 
      if (directive.name !== "tip") return;
 
      // Transform :::tip here.
    });
  };
}

A containerDirective can represent directives other than tip, so:

ts
if (directive.name !== "tip") return;

makes sure that this plugin only handles :::tip.

Turn it into an <aside>

Next, tell the Markdown renderer which HTML element to generate:

ts
directive.data = {
  ...directive.data,
  hName: "aside",
  hProperties: {
    className: ["rr-tip"],
  },
};

Here:

ts
hName: "aside"

selects the HTML element, while:

ts
className: ["rr-tip"]

adds its CSS class.

As a result:

md
:::tip
Save often.
:::

will produce HTML similar to:

html
<aside class="rr-tip">
  <p>Save often.</p>
</aside>

Turn [Heads up] into the title

Now let's handle the label in:

md
:::tip[Heads up]
Save often.
:::

remark-directive provides this label as the first child of the directive.

First, check whether the first child is a directive label:

ts
const [first, ...rest] = directive.children;
 
const hasLabel =
  first?.type === "paragraph" &&
  (first as { data?: { directiveLabel?: boolean } }).data
    ?.directiveLabel === true;

If a label exists, split the directive into:

  • the first child → title
  • the remaining children → body
ts
const titleChildren = hasLabel
  ? (first as Parent).children
  : [];
 
const bodyChildren = hasLabel
  ? rest
  : directive.children;

Then add the title as:

html
<p class="rr-tip__title">

by replacing the directive's children:

ts
directive.children = [
  {
    type: "paragraph",
    data: {
      hName: "p",
      hProperties: {
        className: ["rr-tip__title"],
      },
    },
    children: titleChildren,
  },
  ...bodyChildren,
];

Now:

md
:::tip[Heads up]
Save often.
:::

produces:

html
<aside class="rr-tip">
  <p class="rr-tip__title">Heads up</p>
  <p>Save often.</p>
</aside>

Complete plugin

Putting everything together, extensions/tip-plugin.ts looks like this:

ts
import { definePlugin } from "@riebeckite/core";
import type { Parent, Root } from "mdast";
import { visit } from "unist-util-visit";
 
type ContainerDirective = {
  type: "containerDirective";
  name: string;
  children: Parent["children"];
  data?: Record<string, unknown>;
};
 
function remarkTip() {
  return (tree: Root) => {
    visit(tree, "containerDirective", (node) => {
      const directive = node as unknown as ContainerDirective;
 
      if (directive.name !== "tip") return;
 
      const [first, ...rest] = directive.children;
 
      const hasLabel =
        first?.type === "paragraph" &&
        (first as { data?: { directiveLabel?: boolean } }).data
          ?.directiveLabel === true;
 
      const titleChildren = hasLabel
        ? (first as Parent).children
        : [];
 
      const bodyChildren = hasLabel
        ? rest
        : directive.children;
 
      directive.data = {
        ...directive.data,
        hName: "aside",
        hProperties: {
          className: ["rr-tip"],
        },
      };
 
      directive.children = [
        {
          type: "paragraph",
          data: {
            hName: "p",
            hProperties: {
              className: ["rr-tip__title"],
            },
          },
          children: titleChildren,
        },
        ...bodyChildren,
      ];
    });
  };
}
 
export function tipPlugin() {
  return definePlugin({
    name: "tip",
 
    extendMarkdownPipeline: (pipeline) => {
      pipeline.use(remarkTip);
    },
 
    assets: [
      {
        pluginName: "tip",
        kind: "style",
        moduleSpecifier: "/extensions/plugin.css",
      },
    ],
 
    processedContentCache: {
      version: "1",
      dependencyMode: "none",
    },
  });
}

extendMarkdownPipeline is a low-level extension point that gives you direct access to the Markdown AST. We use it here because the plugin needs to change the HTML structure generated for the directive.

Step 2: Add the stylesheet

Create extensions/plugin.css:

css
.rr-tip {
  border-left: 2px solid var(--rb-color-accent);
  padding: 0.75rem 1rem;
}
 
.rr-tip__title {
  margin-block: 0 0.25rem;
  font-weight: 600;
}

These styles target the elements we generated earlier:

html
<aside class="rr-tip">

and:

html
<p class="rr-tip__title">

The border color uses the Riebeckite theme token:

css
var(--rb-color-accent)

instead of a hard-coded color.

This allows the Tip block to follow the active theme's accent color automatically.

This is the Theme Extension Contract in practice. Put a stable root hook (rr-<feature>; here rr-tip) on the outermost element, and use --rb-* semantic tokens for color and spacing. --rb-* tokens resolve in light, explicit dark, and system dark, so you normally do not branch on prefers-color-scheme or [data-theme] yourself, and you must not depend on a .dark class. See the Theme API for details, including how to scope a dark-only branch when one is genuinely necessary.

Step 3: Register the plugin

Register the plugin in riebeckite.config.ts:

ts
import { defineConfig } from "@riebeckite/core";
import { tipPlugin } from "./extensions/tip-plugin";
 
export default defineConfig({
  plugins: [
    tipPlugin(),
  ],
});

You can now use:

md
:::tip[Heads up]
Save often.
:::

in your Markdown content.

Step 4: Test the output

Finally, let's verify that the plugin generates the expected HTML.

You do not need to build the entire site for this test. You can run the Markdown Pipeline directly and inspect its output.

Create extensions/tip-plugin.test.ts:

ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { Pipeline } from "@riebeckite/core";
import { tipPlugin } from "./tip-plugin.ts";
 
test("renders :::tip as an aside with a title", async () => {
  const pipeline = new Pipeline(
    new Map(),
    new Map(),
    undefined,
    {
      plugins: [tipPlugin()],
    },
  );
 
  const { html } = await pipeline.execute(
    ":::tip[Heads up]\nSave often.\n:::",
  );
 
  assert.match(
    html,
    /<aside class="rr-tip">/,
  );
 
  assert.match(
    html,
    /<p class="rr-tip__title">Heads up<\/p>/,
  );
 
  assert.match(
    html,
    /Save often\./,
  );
});

This test checks three things:

text
:::tip
   ↓
<aside class="rr-tip">
 
[Heads up]
   ↓
<p class="rr-tip__title">Heads up</p>
 
Save often.
   ↓
Rendered as the body

At this point, you have a complete site-local plugin that transforms Markdown, loads its own stylesheet, can be registered through Riebeckite's configuration, and has a test for its generated output.

What to remember from this example

You do not need to memorize every AST operation used in this example.

The important part is that a Riebeckite plugin can group Markdown transformations and assets into a single plugin:

ts
definePlugin({
  name: "...",
 
  extendMarkdownPipeline: (pipeline) => {
    pipeline.use(...);
  },
 
  assets: [...],
});

extendMarkdownPipeline is a low-level API for working directly with remark and mdast. Use it when you need custom Markdown syntax or more advanced transformations.

This example also declares processedContentCache. Without it the plugin still works, but it disables the site's processed-content cache so Markdown is reprocessed on every build. Use dependencyMode: "none" for a standalone transform that depends only on the current content, and tracked when the output depends on other plugins (reserve unsafe for what cannot be tracked accurately). Bump version when the transform's meaning changes; the plugin's options and transform function are part of the pipeline fingerprint, so option changes alone do not require a version bump.

Testing a plugin

A plugin can be tested at four levels. You do not need all of them; start from the smallest level that proves the behavior you changed.

text
Level 1  Pure logic               A normal test runner is enough (AST helpers, string transforms).
Level 2  Markdown / HTML          Pass the plugin to a Pipeline and assert the transformed output.
Level 3  Content / lifecycle      Use ContentManager with an in-memory ContentSource to assert manifests and hooks.
Level 4  Public package boundary  Pack the package and install it into an isolated project.

Level 1: Pure logic

Option resolution and AST or string helpers that do not depend on Riebeckite can be tested with any runner such as node:test. No Riebeckite test infrastructure is needed at this level.

Level 2: Markdown / HTML transformation

Pass the plugin to a Pipeline, run representative Markdown through execute(), and assert the semantic output. This is the test from Step 4 above.

ts
const pipeline = new Pipeline(new Map(), new Map(), undefined, {
  plugins: [tipPlugin()],
});
 
const { html } = await pipeline.execute(
  ":::tip[Heads up]\nSave often.\n:::",
);

The first two Pipeline arguments are the content index and permalink maps. Empty Maps are enough to transform a single document. The third argument (getMarkdownBySlug) is only needed when the plugin resolves embeds or other content.

Level 3: Content / lifecycle

To test content loading, hooks, manifests, body slots, or Page Types, use ContentManager with a small in-memory ContentSource instead of a filesystem fixture.

ts
const source = {
  async scan() {
    return [{ path: "notes/index.md" }];
  },
  async read(entry) {
    return `# ${entry.path}`;
  },
};
 
const manager = new ContentManager(source, [], { config });
const manifest = await manager.getManifest();

See Testing for the deeper patterns.

Level 4: Public package boundary

A package you distribute should be verified by packing its tarball and installing it into an isolated project outside the monorepo. This catches missing public exports, accidental @riebeckite/core/src/** imports, undeclared dependencies, and missing declarations. In this repository, tests/plugin-dx is the working example and runs with pnpm test:plugin-dx and pnpm test:plugin-dx:external.

4. Add a standalone page when needed

Use pageTypes for standalone pages. A Page Type returns the HTML body, while the site's shared catch-all route applies the document frame and Theme. When a page lists entries, read manifest.discoverableEntries; reserve manifest.publicEntries (which includes unlisted) and manifest.entries (which includes draft and scheduled) for the cases that genuinely need them. See Manifest collections and publication safety.

Do not add Plugin-specific HonoX routes. Content embeds such as Canvas, Bases, and Excalidraw remain renderers.

See Page System for ownership, path resolution, and SSG behavior.

5. Package it when needed

Once a site-local Plugin works, it can be turned into a package. External Plugins should depend only on @riebeckite/core, declare their own subpaths through exports, and must not import @riebeckite/core/src/** or monorepo-internal paths. For the package shape and a build that ships ESM plus type declarations, see Distributing a Plugin outside this repository. Because createStyleAsset() and createClientEntry() build @riebeckite/plugin-<name>/... specifiers, a package under any other name declares assets and clientEntries with explicit moduleSpecifier values.

For the directive plugin above, declare @riebeckite/core and unist-util-visit in dependencies and the mdast types (@types/mdast) in devDependencies. The complete package.json and a build script using esbuild plus tsc are in Distributing a Plugin outside this repository.

6. Validate it

Use the repository's checks and tests relevant to the Plugin. check, doctor, and inspect are read-only diagnostics. Before creating a Plugin, also confirm that the requirement cannot be handled more simply by configuration or app-level code.

Providing UI or output

A Plugin can add UI in several ways. Pick the smallest one that fits; these are alternatives, not a progression, and a Plugin may combine them.

text
Want to add UI or output?
│
├─ Change the Markdown or HTML itself
│    └─ remark / rehype pipeline
│
├─ Render an embedded content target
│    └─ renderers
│
├─ Add a standalone page
│    └─ pageTypes
│
├─ Appear in the article layout automatically
│    └─ HTML fragment + body Slot
│
├─ Let the site author place it
│    └─ Hono JSX component export
│
└─ Enhance the page in the browser
     └─ clientEntries (plus a site-owned Island when needed)

Automatic placement with a body Slot

Use a body Slot when the output belongs at a standard position and should appear as soon as the Plugin is enabled. Publish an HTML fragment; the Site decides whether and where to render the slot.

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

A Plugin author picks a standard slot such as article.footer, or asks the Site to render a custom name. A custom slot renders nothing until the Site renders it. See Body slots for the slot list and ordering.

Manual placement with a Hono JSX component

Export an ordinary Hono JSX component when the Site author should choose where the UI goes. There is no component registry and no Plugin-specific component API: the component is imported and composed like any other.

Declare a ./components subpath in the package exports and keep the component module's default export, then optionally re-export it by name from the package root. Existing Plugins follow this shape:

ts
import { Backlinks } from "@riebeckite/plugin-backlinks";
import { TableOfContents } from "@riebeckite/plugin-toc";
import { SearchBar } from "@riebeckite/plugin-search";
import BacklinksDefault from "@riebeckite/plugin-backlinks/components";

color-mode fits the root-only shape: it exports ColorModeScript and ColorModeToggle from the package root and has no ./components subpath. Use the names each package README documents.

HTML fragment or component?

The deciding question is who places it:

  • HTML fragment + Slot — the Plugin writes to a known standard position, and the Site opts into rendering that slot.
  • Hono JSX component — the Site author places it anywhere in the component tree.

Neither is newer or better. A string is natural when the Plugin already produces HTML (for example from a HAST transform); a component is natural when the Site should control props and placement. backlinks and local-graph use both: they export a component and also append their rendered output to article.footer in onManifestCreated. That is a common pattern, not a requirement.

Browser enhancement and Islands

Plugins do not own app/islands/, and Riebeckite has no Plugin Island registry. For browser behavior, either contribute a clientEntries initializer that enhances the server-rendered DOM, or let the Site wrap the Plugin's component in its own HonoX Island when component state is needed. garden-explorer is a specific case that combines a Page Type with a client entry; do not treat it as a required pattern. See Client entries.

History

1 changesCollapseExpand
1 + # Writing Your First Plugin
2 +
3 + Plugins add **functionality** to Riebeckite: Markdown or HTML transformation, client-side behavior, standalone pages, SEO, diagnostics, and more. Use a Theme when you only want to change appearance.
4 +
5 + Plugins are trusted application code. When a Plugin emits HTML, page bodies, head tags, client entries, endpoints, or generated files, it is responsible for escaping untrusted text and validating URLs for the exact context. Riebeckite preserves raw Markdown HTML and does not sanitize Plugin-generated HTML. See [Security model](../security.en.md).
6 +
7 + When a Plugin generates UI, follow the [accessibility contract](../accessibility.en.md): prefer semantic HTML, use real links and buttons, keep keyboard operation and focus management working, and synchronize ARIA state only when ARIA is needed.
8 +
9 + A Plugin can live directly inside a site; it does not have to be published as a package.
10 +
11 + ## The path
12 +
13 + This page follows one continuous path, from an empty folder to a distributable Plugin:
14 +
15 + ```text
16 + Create a minimal Plugin (1-2)
17 + ↓
18 + Transform Markdown or HTML (3, Hands-on)
19 + ↓
20 + Test the output (Testing a plugin)
21 + ↓
22 + Package it (5)
23 + ↓
24 + Verify it as an external package (tests/plugin-dx / test:plugin-dx:external)
25 + ```
26 +
27 + If the Plugin only runs inside one site, you can stop before packaging. Only distributed Plugin packages need the packaging and external-verification steps. For the exact contract of each extension point, see the [Plugin API](../reference/plugin-api.en.md).
28 +
29 + ## 1. Create a minimal Plugin
30 +
31 + Create Plugins with `definePlugin` from `@riebeckite/core`. A Plugin with only a `name` is the smallest valid form. Plugin factories can accept typed options when configuration is needed.
32 +
33 + ## 2. Add CSS
34 +
35 + Declare Plugin-specific stylesheets through `assets`. Do not copy CSS into the site manually or reference `/node_modules` directly from the browser.
36 +
37 + Use a stable root hook such as `rr-<feature>` on rendered output. See [Plugin API](../reference/plugin-api.en.md) for the CSS contract.
38 +
39 + ## 3. Transform Markdown or HTML
40 +
41 + Semantic Markdown transformation belongs to Plugins. Simple remark Plugins can be declared as an array. Use `extendMarkdownPipeline` / `extendHtmlPipeline` when you need finer control of the processing pipeline.
42 +
43 + `remarkPlugins`, `rehypePlugins`, and `extendMarkdownPipeline` all join the same pipeline. Riebeckite runs `remark-parse`, `remark-directive`, `remark-gfm`, and the other base plugins before your Plugin, so directive syntax such as `:::tip` and GFM already arrive as AST nodes. You do not install or register those yourself.
44 +
45 + For dependencies, lifecycle hooks, renderers, endpoints, and other extension points, see [Plugin API](../reference/plugin-api.en.md).
46 +
47 + Plugins that participate in content transformation should also declare `processedContentCache`. Without it a plugin still works, but it disables the site's processed-content cache so Markdown is reprocessed on every build. Use `{ version: "1", dependencyMode: "none" }` for a standalone transform and `tracked` when the output depends on other plugins (reserve `unsafe` for what cannot be tracked accurately).
48 +
49 + ## Hands-on: Build a directive plugin from start to finish
50 +
51 + In this section, you will build a small plugin that turns the following Markdown into a custom Tip block:
52 +
53 + ```md id="bq0v5x"
54 + :::tip[Heads up]
55 + Save often.
56 + :::
57 + ```
58 +
59 + The generated HTML will look like this:
60 +
61 + ```html id="xmx7yi"
62 + <aside class="rr-tip">
63 + <p class="rr-tip__title">Heads up</p>
64 + <p>Save often.</p>
65 + </aside>
66 + ```
67 +
68 + We will build it in four steps:
69 +
70 + 1. Create a plugin that transforms the Markdown
71 + 2. Add CSS for the Tip block
72 + 3. Register the plugin in `riebeckite.config.ts`
73 + 4. Test the generated HTML
74 +
75 + ### Step 1: Create the plugin
76 +
77 + Create `extensions/tip-plugin.ts`.
78 +
79 + First, let's look at the entry point of the plugin:
80 +
81 + ```ts id="szeg4x"
82 + import { definePlugin } from "@riebeckite/core";
83 +
84 + export function tipPlugin() {
85 + return definePlugin({
86 + name: "tip",
87 +
88 + extendMarkdownPipeline: (pipeline) => {
89 + pipeline.use(remarkTip);
90 + },
91 +
92 + assets: [
93 + {
94 + pluginName: "tip",
95 + kind: "style",
96 + moduleSpecifier: "/extensions/plugin.css",
97 + },
98 + ],
99 + });
100 + }
101 + ```
102 +
103 + This plugin does two things:
104 +
105 + - adds a Markdown transformation called `remarkTip`
106 + - loads `/extensions/plugin.css` as a stylesheet
107 +
108 + `remarkTip` is the part that actually turns `:::tip` into an `<aside>` element.
109 +
110 + #### Find `:::tip`
111 +
112 + Add the imports and a small type for the directive node:
113 +
114 + ```ts id="nv29am"
115 + import { definePlugin } from "@riebeckite/core";
116 + import type { Parent, Root } from "mdast";
117 + import { visit } from "unist-util-visit";
118 +
119 + type ContainerDirective = {
120 + type: "containerDirective";
121 + name: string;
122 + children: Parent["children"];
123 + data?: Record<string, unknown>;
124 + };
125 + ```
126 +
127 + This example uses the `mdast` types for AST nodes and `unist-util-visit` to walk the tree. Add them to your own package's dependencies (they are not imported from `@riebeckite/core`).
128 +
129 + In Riebeckite's Markdown pipeline, syntax such as `:::tip` has already been parsed by `remark-directive` before your plugin runs.
130 +
131 + That means your plugin does not need to parse the Markdown text itself. It receives a `containerDirective` node instead.
132 +
133 + Use `unist-util-visit` to find those nodes:
134 +
135 + ```ts id="tvvs30"
136 + function remarkTip() {
137 + return (tree: Root) => {
138 + visit(tree, "containerDirective", (node) => {
139 + const directive = node as unknown as ContainerDirective;
140 +
141 + if (directive.name !== "tip") return;
142 +
143 + // Transform :::tip here.
144 + });
145 + };
146 + }
147 + ```
148 +
149 + A `containerDirective` can represent directives other than `tip`, so:
150 +
151 + ```ts id="4svv7a"
152 + if (directive.name !== "tip") return;
153 + ```
154 +
155 + makes sure that this plugin only handles `:::tip`.
156 +
157 + #### Turn it into an `<aside>`
158 +
159 + Next, tell the Markdown renderer which HTML element to generate:
160 +
161 + ```ts id="ap4hjz"
162 + directive.data = {
163 + ...directive.data,
164 + hName: "aside",
165 + hProperties: {
166 + className: ["rr-tip"],
167 + },
168 + };
169 + ```
170 +
171 + Here:
172 +
173 + ```ts id="l7dbze"
174 + hName: "aside"
175 + ```
176 +
177 + selects the HTML element, while:
178 +
179 + ```ts id="nupj0v"
180 + className: ["rr-tip"]
181 + ```
182 +
183 + adds its CSS class.
184 +
185 + As a result:
186 +
187 + ```md id="nkrh1g"
188 + :::tip
189 + Save often.
190 + :::
191 + ```
192 +
193 + will produce HTML similar to:
194 +
195 + ```html id="zvbjza"
196 + <aside class="rr-tip">
197 + <p>Save often.</p>
198 + </aside>
199 + ```
200 +
201 + #### Turn `[Heads up]` into the title
202 +
203 + Now let's handle the label in:
204 +
205 + ```md id="u0ykxd"
206 + :::tip[Heads up]
207 + Save often.
208 + :::
209 + ```
210 +
211 + `remark-directive` provides this label as the first child of the directive.
212 +
213 + First, check whether the first child is a directive label:
214 +
215 + ```ts id="3q5kbw"
216 + const [first, ...rest] = directive.children;
217 +
218 + const hasLabel =
219 + first?.type === "paragraph" &&
220 + (first as { data?: { directiveLabel?: boolean } }).data
221 + ?.directiveLabel === true;
222 + ```
223 +
224 + If a label exists, split the directive into:
225 +
226 + - the first child → title
227 + - the remaining children → body
228 +
229 + ```ts id="s5d3wu"
230 + const titleChildren = hasLabel
231 + ? (first as Parent).children
232 + : [];
233 +
234 + const bodyChildren = hasLabel
235 + ? rest
236 + : directive.children;
237 + ```
238 +
239 + Then add the title as:
240 +
241 + ```html id="blz7w7"
242 + <p class="rr-tip__title">
243 + ```
244 +
245 + by replacing the directive's children:
246 +
247 + ```ts id="uknv3s"
248 + directive.children = [
249 + {
250 + type: "paragraph",
251 + data: {
252 + hName: "p",
253 + hProperties: {
254 + className: ["rr-tip__title"],
255 + },
256 + },
257 + children: titleChildren,
258 + },
259 + ...bodyChildren,
260 + ];
261 + ```
262 +
263 + Now:
264 +
265 + ```md id="ez1dfb"
266 + :::tip[Heads up]
267 + Save often.
268 + :::
269 + ```
270 +
271 + produces:
272 +
273 + ```html id="4jgvpj"
274 + <aside class="rr-tip">
275 + <p class="rr-tip__title">Heads up</p>
276 + <p>Save often.</p>
277 + </aside>
278 + ```
279 +
280 + #### Complete plugin
281 +
282 + Putting everything together, `extensions/tip-plugin.ts` looks like this:
283 +
284 + ```ts id="7q1nxh"
285 + import { definePlugin } from "@riebeckite/core";
286 + import type { Parent, Root } from "mdast";
287 + import { visit } from "unist-util-visit";
288 +
289 + type ContainerDirective = {
290 + type: "containerDirective";
291 + name: string;
292 + children: Parent["children"];
293 + data?: Record<string, unknown>;
294 + };
295 +
296 + function remarkTip() {
297 + return (tree: Root) => {
298 + visit(tree, "containerDirective", (node) => {
299 + const directive = node as unknown as ContainerDirective;
300 +
301 + if (directive.name !== "tip") return;
302 +
303 + const [first, ...rest] = directive.children;
304 +
305 + const hasLabel =
306 + first?.type === "paragraph" &&
307 + (first as { data?: { directiveLabel?: boolean } }).data
308 + ?.directiveLabel === true;
309 +
310 + const titleChildren = hasLabel
311 + ? (first as Parent).children
312 + : [];
313 +
314 + const bodyChildren = hasLabel
315 + ? rest
316 + : directive.children;
317 +
318 + directive.data = {
319 + ...directive.data,
320 + hName: "aside",
321 + hProperties: {
322 + className: ["rr-tip"],
323 + },
324 + };
325 +
326 + directive.children = [
327 + {
328 + type: "paragraph",
329 + data: {
330 + hName: "p",
331 + hProperties: {
332 + className: ["rr-tip__title"],
333 + },
334 + },
335 + children: titleChildren,
336 + },
337 + ...bodyChildren,
338 + ];
339 + });
340 + };
341 + }
342 +
343 + export function tipPlugin() {
344 + return definePlugin({
345 + name: "tip",
346 +
347 + extendMarkdownPipeline: (pipeline) => {
348 + pipeline.use(remarkTip);
349 + },
350 +
351 + assets: [
352 + {
353 + pluginName: "tip",
354 + kind: "style",
355 + moduleSpecifier: "/extensions/plugin.css",
356 + },
357 + ],
358 +
359 + processedContentCache: {
360 + version: "1",
361 + dependencyMode: "none",
362 + },
363 + });
364 + }
365 + ```
366 +
367 + > `extendMarkdownPipeline` is a low-level extension point that gives you direct access to the Markdown AST. We use it here because the plugin needs to change the HTML structure generated for the directive.
368 +
369 + ### Step 2: Add the stylesheet
370 +
371 + Create `extensions/plugin.css`:
372 +
373 + ```css id="nw1i6n"
374 + .rr-tip {
375 + border-left: 2px solid var(--rb-color-accent);
376 + padding: 0.75rem 1rem;
377 + }
378 +
379 + .rr-tip__title {
380 + margin-block: 0 0.25rem;
381 + font-weight: 600;
382 + }
383 + ```
384 +
385 + These styles target the elements we generated earlier:
386 +
387 + ```html id="0v5gb7"
388 + <aside class="rr-tip">
389 + ```
390 +
391 + and:
392 +
393 + ```html id="3qnpq7"
394 + <p class="rr-tip__title">
395 + ```
396 +
397 + The border color uses the Riebeckite theme token:
398 +
399 + ```css id="r3pgla"
400 + var(--rb-color-accent)
401 + ```
402 +
403 + instead of a hard-coded color.
404 +
405 + This allows the Tip block to follow the active theme's accent color automatically.
406 +
407 + This is the Theme Extension Contract in practice. Put a stable root hook (`rr-<feature>`; here `rr-tip`) on the outermost element, and use `--rb-*` semantic tokens for color and spacing. `--rb-*` tokens resolve in light, explicit dark, and system dark, so you normally do not branch on `prefers-color-scheme` or `[data-theme]` yourself, and you must not depend on a `.dark` class. See the [Theme API](../reference/theme-api.en.md) for details, including how to scope a dark-only branch when one is genuinely necessary.
408 +
409 + ### Step 3: Register the plugin
410 +
411 + Register the plugin in `riebeckite.config.ts`:
412 +
413 + ```ts id="b49mge"
414 + import { defineConfig } from "@riebeckite/core";
415 + import { tipPlugin } from "./extensions/tip-plugin";
416 +
417 + export default defineConfig({
418 + plugins: [
419 + tipPlugin(),
420 + ],
421 + });
422 + ```
423 +
424 + You can now use:
425 +
426 + ```md id="cpz5r3"
427 + :::tip[Heads up]
428 + Save often.
429 + :::
430 + ```
431 +
432 + in your Markdown content.
433 +
434 + ### Step 4: Test the output
435 +
436 + Finally, let's verify that the plugin generates the expected HTML.
437 +
438 + You do not need to build the entire site for this test. You can run the Markdown `Pipeline` directly and inspect its output.
439 +
440 + Create `extensions/tip-plugin.test.ts`:
441 +
442 + ```ts id="5rs1cq"
443 + import assert from "node:assert/strict";
444 + import { test } from "node:test";
445 + import { Pipeline } from "@riebeckite/core";
446 + import { tipPlugin } from "./tip-plugin.ts";
447 +
448 + test("renders :::tip as an aside with a title", async () => {
449 + const pipeline = new Pipeline(
450 + new Map(),
451 + new Map(),
452 + undefined,
453 + {
454 + plugins: [tipPlugin()],
455 + },
456 + );
457 +
458 + const { html } = await pipeline.execute(
459 + ":::tip[Heads up]\nSave often.\n:::",
460 + );
461 +
462 + assert.match(
463 + html,
464 + /<aside class="rr-tip">/,
465 + );
466 +
467 + assert.match(
468 + html,
469 + /<p class="rr-tip__title">Heads up<\/p>/,
470 + );
471 +
472 + assert.match(
473 + html,
474 + /Save often\./,
475 + );
476 + });
477 + ```
478 +
479 + This test checks three things:
480 +
481 + ```text id="6w7im3"
482 + :::tip
483 + ↓
484 + <aside class="rr-tip">
485 +
486 + [Heads up]
487 + ↓
488 + <p class="rr-tip__title">Heads up</p>
489 +
490 + Save often.
491 + ↓
492 + Rendered as the body
493 + ```
494 +
495 + At this point, you have a complete site-local plugin that transforms Markdown, loads its own stylesheet, can be registered through Riebeckite's configuration, and has a test for its generated output.
496 +
497 + ### What to remember from this example
498 +
499 + You do not need to memorize every AST operation used in this example.
500 +
501 + The important part is that a Riebeckite plugin can group Markdown transformations and assets into a single plugin:
502 +
503 + ```ts id="d6q7mf"
504 + definePlugin({
505 + name: "...",
506 +
507 + extendMarkdownPipeline: (pipeline) => {
508 + pipeline.use(...);
509 + },
510 +
511 + assets: [...],
512 + });
513 + ```
514 +
515 + `extendMarkdownPipeline` is a low-level API for working directly with remark and mdast. Use it when you need custom Markdown syntax or more advanced transformations.
516 +
517 + This example also declares `processedContentCache`. Without it the plugin still works, but it disables the site's processed-content cache so Markdown is reprocessed on every build. Use `dependencyMode: "none"` for a standalone transform that depends only on the current content, and `tracked` when the output depends on other plugins (reserve `unsafe` for what cannot be tracked accurately). Bump `version` when the transform's meaning changes; the plugin's `options` and transform function are part of the pipeline fingerprint, so option changes alone do not require a `version` bump.
518 +
519 + ## Testing a plugin
520 +
521 + A plugin can be tested at four levels. You do not need all of them; start from the smallest level that proves the behavior you changed.
522 +
523 + ```text
524 + Level 1 Pure logic A normal test runner is enough (AST helpers, string transforms).
525 + Level 2 Markdown / HTML Pass the plugin to a Pipeline and assert the transformed output.
526 + Level 3 Content / lifecycle Use ContentManager with an in-memory ContentSource to assert manifests and hooks.
527 + Level 4 Public package boundary Pack the package and install it into an isolated project.
528 + ```
529 +
530 + ### Level 1: Pure logic
531 +
532 + Option resolution and AST or string helpers that do not depend on Riebeckite can be tested with any runner such as `node:test`. No Riebeckite test infrastructure is needed at this level.
533 +
534 + ### Level 2: Markdown / HTML transformation
535 +
536 + Pass the plugin to a `Pipeline`, run representative Markdown through `execute()`, and assert the semantic output. This is the test from Step 4 above.
537 +
538 + ```ts
539 + const pipeline = new Pipeline(new Map(), new Map(), undefined, {
540 + plugins: [tipPlugin()],
541 + });
542 +
543 + const { html } = await pipeline.execute(
544 + ":::tip[Heads up]\nSave often.\n:::",
545 + );
546 + ```
547 +
548 + The first two `Pipeline` arguments are the content index and permalink maps. Empty `Map`s are enough to transform a single document. The third argument (`getMarkdownBySlug`) is only needed when the plugin resolves embeds or other content.
549 +
550 + ### Level 3: Content / lifecycle
551 +
552 + To test content loading, hooks, manifests, body slots, or Page Types, use `ContentManager` with a small in-memory `ContentSource` instead of a filesystem fixture.
553 +
554 + ```ts
555 + const source = {
556 + async scan() {
557 + return [{ path: "notes/index.md" }];
558 + },
559 + async read(entry) {
560 + return `# ${entry.path}`;
561 + },
562 + };
563 +
564 + const manager = new ContentManager(source, [], { config });
565 + const manifest = await manager.getManifest();
566 + ```
567 +
568 + See [Testing](../framework/testing.en.md) for the deeper patterns.
569 +
570 + ### Level 4: Public package boundary
571 +
572 + A package you distribute should be verified by packing its tarball and installing it into an isolated project outside the monorepo. This catches missing public exports, accidental `@riebeckite/core/src/**` imports, undeclared dependencies, and missing declarations. In this repository, `tests/plugin-dx` is the working example and runs with `pnpm test:plugin-dx` and `pnpm test:plugin-dx:external`.
573 +
574 + ## 4. Add a standalone page when needed
575 +
576 + Use `pageTypes` for standalone pages. A Page Type returns the HTML body, while the site's shared catch-all route applies the document frame and Theme. When a page lists entries, read `manifest.discoverableEntries`; reserve `manifest.publicEntries` (which includes `unlisted`) and `manifest.entries` (which includes `draft` and `scheduled`) for the cases that genuinely need them. See [Manifest collections and publication safety](../reference/plugin-api.en.md#manifest-collections-and-publication-safety).
577 +
578 + Do not add Plugin-specific HonoX routes. Content embeds such as Canvas, Bases, and Excalidraw remain `renderers`.
579 +
580 + See [Page System](../framework/page-system.en.md) for ownership, path resolution, and SSG behavior.
581 +
582 + ## 5. Package it when needed
583 +
584 + Once a site-local Plugin works, it can be turned into a package. External Plugins should depend only on `@riebeckite/core`, declare their own subpaths through `exports`, and must not import `@riebeckite/core/src/**` or monorepo-internal paths. For the package shape and a build that ships ESM plus type declarations, see [Distributing a Plugin outside this repository](../reference/plugin-api.en.md#distributing-a-plugin-outside-this-repository). Because `createStyleAsset()` and `createClientEntry()` build `@riebeckite/plugin-<name>/...` specifiers, a package under any other name declares `assets` and `clientEntries` with explicit `moduleSpecifier` values.
585 +
586 + For the directive plugin above, declare `@riebeckite/core` and `unist-util-visit` in `dependencies` and the `mdast` types (`@types/mdast`) in `devDependencies`. The complete `package.json` and a build script using esbuild plus `tsc` are in [Distributing a Plugin outside this repository](../reference/plugin-api.en.md#distributing-a-plugin-outside-this-repository).
587 +
588 + ## 6. Validate it
589 +
590 + Use the repository's checks and tests relevant to the Plugin. `check`, `doctor`, and `inspect` are read-only diagnostics. Before creating a Plugin, also confirm that the requirement cannot be handled more simply by configuration or app-level code.
591 +
592 + ## Providing UI or output
593 +
594 + A Plugin can add UI in several ways. Pick the smallest one that fits; these are alternatives, not a progression, and a Plugin may combine them.
595 +
596 + ```text
597 + Want to add UI or output?
598 + │
599 + ├─ Change the Markdown or HTML itself
600 + │ └─ remark / rehype pipeline
601 + │
602 + ├─ Render an embedded content target
603 + │ └─ renderers
604 + │
605 + ├─ Add a standalone page
606 + │ └─ pageTypes
607 + │
608 + ├─ Appear in the article layout automatically
609 + │ └─ HTML fragment + body Slot
610 + │
611 + ├─ Let the site author place it
612 + │ └─ Hono JSX component export
613 + │
614 + └─ Enhance the page in the browser
615 + └─ clientEntries (plus a site-owned Island when needed)
616 + ```
617 +
618 + ### Automatic placement with a body Slot
619 +
620 + Use a body Slot when the output belongs at a standard position and should appear as soon as the Plugin is enabled. Publish an HTML fragment; the Site decides whether and where to render the slot.
621 +
622 + ```ts
623 + import { appendContentBodySlot } from "@riebeckite/core";
624 +
625 + appendContentBodySlot(entry, "article.footer", "<section>...</section>");
626 + ```
627 +
628 + A Plugin author picks a standard slot such as `article.footer`, or asks the Site to render a custom name. A custom slot renders nothing until the Site renders it. See [Body slots](../reference/plugin-api.en.md#body-slots) for the slot list and ordering.
629 +
630 + ### Manual placement with a Hono JSX component
631 +
632 + Export an ordinary Hono JSX component when the Site author should choose where the UI goes. There is no component registry and no Plugin-specific component API: the component is imported and composed like any other.
633 +
634 + Declare a `./components` subpath in the package `exports` and keep the component module's default export, then optionally re-export it by name from the package root. Existing Plugins follow this shape:
635 +
636 + ```ts
637 + import { Backlinks } from "@riebeckite/plugin-backlinks";
638 + import { TableOfContents } from "@riebeckite/plugin-toc";
639 + import { SearchBar } from "@riebeckite/plugin-search";
640 + import BacklinksDefault from "@riebeckite/plugin-backlinks/components";
641 + ```
642 +
643 + `color-mode` fits the root-only shape: it exports `ColorModeScript` and `ColorModeToggle` from the package root and has no `./components` subpath. Use the names each package README documents.
644 +
645 + ### HTML fragment or component?
646 +
647 + The deciding question is **who places it**:
648 +
649 + - **HTML fragment + Slot** — the Plugin writes to a known standard position, and the Site opts into rendering that slot.
650 + - **Hono JSX component** — the Site author places it anywhere in the component tree.
651 +
652 + Neither is newer or better. A string is natural when the Plugin already produces HTML (for example from a HAST transform); a component is natural when the Site should control props and placement. `backlinks` and `local-graph` use both: they export a component and also append their rendered output to `article.footer` in `onManifestCreated`. That is a common pattern, not a requirement.
653 +
654 + ### Browser enhancement and Islands
655 +
656 + Plugins do not own `app/islands/`, and Riebeckite has no Plugin Island registry. For browser behavior, either contribute a `clientEntries` initializer that enhances the server-rendered DOM, or let the Site wrap the Plugin's component in its own HonoX Island when component state is needed. `garden-explorer` is a specific case that combines a Page Type with a client entry; do not treat it as a required pattern. See [Client entries](../reference/plugin-api.en.md#assets-and-client-entries).
657 +
658 + ## Related
659 +
660 + - [Plugin API](../reference/plugin-api.en.md)
661 + - [Plugin System](../framework/plugin-system.en.md)
662 + - [Testing](../framework/testing.en.md)
663 + - [Theme API](../reference/theme-api.en.md)
664 + - [Customizing Your Site](./../guides/customizing-your-site.en.md)
665 + - [Architecture](../framework/architecture.en.md)
666 + - [Writing Your First Theme](../themes/writing-a-theme.en.md)
667 +