はじめてのプラグイン作成
プラグインは機能(Markdown/HTML の変換、クライアント動作、独立ページ、SEO、診断など)を足す仕組みです。見た目を変えたいときはテーマ(はじめてのテーマ作成)を使います。Plugin の独立ページは Site の共通 route で表示します。Site 固有の画面だけを App(app/)の route に置いてください。
プラグインが HTML や外部 URL を生成する場合は、表示する値や URL を安全に扱う必要があります。特に、外部から取得したデータやユーザー入力をそのまま HTML に埋め込まないでください。 Riebeckite がどこまで安全性を保証し、プラグイン側で何を確認する必要があるかは、セキュリティモデル を参照してください。
Plugin がボタンやメニューなどの UI を表示する場合は、キーボードでも操作できるようにしてください。 基本的な考え方や注意点は、アクセシビリティ を参照してください。
全体像
このページは、何もない状態から配布できる Plugin までを一続きで扱います。
最小の Plugin を作る(1〜2)
↓
Markdown / HTML を変換する(3、実践)
↓
出力をテストする(テストの進め方)
↓
配布用パッケージにする(5)
↓
外部パッケージとして検証する(tests/plugin-dx / test:plugin-dx:external)
site の中だけで使う場合は「配布用パッケージにする」より前で完結します。外部へ配布する場合だけ、package 化と外部検証まで進めます。各拡張ポイントの正確な契約は Plugin System を参照してください。
1. 最小のプラグインを作る
プラグインは definePlugin(@riebeckite/core から import)で作ります。パッケージにする必要はなく、サイトの中に置けます。
// extensions/local-plugin.ts
import { definePlugin } from "@riebeckite/core";
export function localPlugin() {
return definePlugin({
name: "local",
});
}
riebeckite.config.ts の plugins 配列に追加します。
// riebeckite.config.ts
import { localPlugin } from "./extensions/local-plugin";
export default defineConfig({
plugins: [localPlugin()],
// ...
});
name だけのプラグインは「何もしない」最小構成です。オプションを渡したい場合は factory に引数を付けて型を付けます。
type LocalOptions = { enabled?: boolean };
export function localPlugin(options: LocalOptions = {}) {
return definePlugin({ name: "local", options });
}
2. CSS を足す
Plugin 固有の stylesheet は assets で宣言します。サイトに CSS をコピーしたり、ブラウザから /node_modules を直接参照させたりしないでください。
// extensions/local-plugin.ts
import { definePlugin } from "@riebeckite/core";
export function localPlugin() {
return definePlugin({
name: "local",
assets: [
{
pluginName: "local",
kind: "style",
moduleSpecifier: "/extensions/plugin.css",
},
],
});
}
moduleSpecifierは host bundler が解決できるものを指定します。site 内プラグインでは/extensions/plugin.cssの形です。- 描画する最外要素には安定した root hook(
rr-<feature>)を付けます。CSS の規約は Plugin System を参照してください。
3. Markdown / HTML を変換する
マークダウンの意味変換は Plugin の責務です。簡単な remark プラグインは配列で宣言できます。
// extensions/local-plugin.ts
import { definePlugin } from "@riebeckite/core";
function remarkLocal() {
return (tree: unknown) => {
// tree(Markdown AST)を加工する
return tree;
};
}
export function localPlugin() {
return definePlugin({ name: "local", remarkPlugins: [remarkLocal] });
}
pipeline 自体を細かく構成したい場合は extendMarkdownPipeline / extendHtmlPipeline を使います。その他の拡張ポイント(依存関係・lifecycle・renderer・endpoint など)は Plugin System を参照してください。
remarkPlugins / rehypePlugins も extendMarkdownPipeline も同じ Pipeline に参加します。Riebeckite は Plugin より前に remark-parse や remark-directive、remark-gfm などを適用するため、:::tip のような directive や GFM 記法はすでに AST node として Plugin へ渡されます。これらを著者がインストールしたり登録したりする必要はありません。
Content 変換に参加する Plugin は processedContentCache も宣言してください。宣言がない場合でも動作はしますが、その site の処理済み Content キャッシュが無効化され、build のたびに Markdown を再処理します。他 Plugin の Content に依存しない単独の変換なら { version: "1", dependencyMode: "none" }、他 Plugin の出力に依存するなら tracked を指定します(正確に追跡できない場合のみ unsafe)。
実践: directive プラグインを最後まで作る
ここでは、次のような Markdown を独自の Tip 表示に変換するプラグインを作ります。
:::tip[Heads up]
Save often.
:::
最終的には、次のような HTML が生成されます。
<aside class="rr-tip">
<p class="rr-tip__title">Heads up</p>
<p>Save often.</p>
</aside>
作業は4段階です。
- Markdown を変換するプラグインを作る
- 見た目を整える CSS を追加する
riebeckite.config.tsに登録する- 期待した HTML が生成されることを test する
Step 1: プラグインを作る
extensions/tip-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",
},
],
});
}
ここでやっていることは2つだけです。
remarkTipという Markdown 変換を追加する/extensions/plugin.cssを stylesheet として読み込む
remarkTip が、実際に :::tip を <aside> へ変換する部分です。
:::tip を見つける
必要な import と、directive を扱うための型を追加します。
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>;
};
この例では Markdown AST の型に mdast、node の走査に unist-util-visit を使います。どちらも通常の npm package として自分の package に追加してください(@riebeckite/core からは import しません)。
Riebeckite の Markdown pipeline では、:::tip のような記法はあらかじめ remark-directive によって containerDirective node に変換されています。
そのため、プラグイン側で Markdown の文字列を解析する必要はありません。
unist-util-visit を使って、その node を探します。
function remarkTip() {
return (tree: Root) => {
visit(tree, "containerDirective", (node) => {
const directive = node as unknown as ContainerDirective;
if (directive.name !== "tip") return;
// ここで :::tip を変換する
});
};
}
containerDirective には tip 以外の directive も含まれます。
そのため、
if (directive.name !== "tip") return;
として、:::tip だけを処理しています。
<aside> に変換する
見つけた :::tip に、生成したい HTML 要素を指定します。
directive.data = {
...directive.data,
hName: "aside",
hProperties: {
className: ["rr-tip"],
},
};
ここで、
hName: "aside"
が HTML 要素を、
className: ["rr-tip"]
が class を指定しています。
つまり、
:::tip
Save often.
:::
は最終的に、
<aside class="rr-tip">
<p>Save often.</p>
</aside>
のように出力されます。
[Heads up] をタイトルにする
次は、
:::tip[Heads up]
Save often.
:::
の [Heads up] をタイトルとして扱います。
remark-directive は、この label を directive の最初の子 node として渡します。
そこで最初の子が label かどうかを確認します。
const [first, ...rest] = directive.children;
const hasLabel =
first?.type === "paragraph" &&
(first as { data?: { directiveLabel?: boolean } }).data
?.directiveLabel === true;
label があれば、
- 最初の子 → タイトル
- それ以降 → 本文
として分けます。
const titleChildren = hasLabel
? (first as Parent).children
: [];
const bodyChildren = hasLabel
? rest
: directive.children;
そしてタイトルを、
<p class="rr-tip__title">
として追加します。
directive.children = [
{
type: "paragraph",
data: {
hName: "p",
hProperties: {
className: ["rr-tip__title"],
},
},
children: titleChildren,
},
...bodyChildren,
];
これで、
:::tip[Heads up]
Save often.
:::
から、
<aside class="rr-tip">
<p class="rr-tip__title">Heads up</p>
<p>Save often.</p>
</aside>
が生成されます。
完成したプラグイン
ここまでをまとめると、extensions/tip-plugin.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は Markdown の AST を直接操作できる低レベルな拡張ポイントです。この例では directive の HTML 構造そのものを変更したいため使用しています。
Step 2: stylesheet を追加する
次に extensions/plugin.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;
}
先ほど生成した、
<aside class="rr-tip">
と、
<p class="rr-tip__title">
に対してスタイルを適用しています。
色には固定値ではなく、
var(--rb-color-accent)
という Riebeckite の theme token を使っています。
こうしておくと、利用している theme が変わっても、その theme の accent color に追従できます。
これが Theme Extension Contract の基本です。Plugin は最外要素に stable な root hook(rr-<feature>。ここでは rr-tip)を付け、色や余白には --rb-* semantic token を使います。--rb-* は light / 明示 dark / system dark のいずれでも解決されるため、通常は prefers-color-scheme や [data-theme] を自分で分岐する必要はありません。.dark class には依存しないでください。dark 専用の分岐が本当に必要な場合の書き方を含む詳細は Theme API を参照してください。
Step 3: プラグインを登録する
作ったプラグインを riebeckite.config.ts に登録します。
import { defineConfig } from "@riebeckite/core";
import { tipPlugin } from "./extensions/tip-plugin";
export default defineConfig({
plugins: [
tipPlugin(),
],
});
これで Markdown に、
:::tip[Heads up]
Save often.
:::
と書けば、Tip が生成されるようになります。
Step 4: 出力を test する
最後に、期待した HTML が生成されることを test します。
このテストでは site 全体を build する必要はありません。Pipeline を直接実行して、Markdown の変換結果だけを確認できます。
extensions/tip-plugin.test.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\./,
);
});
この test では3つのことを確認しています。
:::tip
↓
<aside class="rr-tip">
[Heads up]
↓
<p class="rr-tip__title">Heads up</p>
Save often.
↓
本文として出力される
これで、Markdown の変換、stylesheet の追加、Plugin の登録、そして test までを含む小さな site 内プラグインが完成しました。
この例で覚えておくこと
この例のすべての AST 操作を覚える必要はありません。
重要なのは、Riebeckite Plugin では、
definePlugin({
name: "...",
extendMarkdownPipeline: (pipeline) => {
pipeline.use(...);
},
assets: [...],
});
という形で、Markdown の変換や stylesheet などをひとつの Plugin にまとめられることです。
extendMarkdownPipeline は remark / mdast を直接扱うための低レベルな API なので、独自の Markdown 構文や複雑な変換が必要な場合に使います。
この例では、processedContentCache も宣言しています。宣言がない場合でも動作はしますが、site の処理済み Content キャッシュが無効化され、build のたびに Markdown を再処理します。他 Plugin の Content に依存しない単独の変換なら dependencyMode: "none"、他 Plugin の出力に依存するなら tracked を指定します(正確に追跡できない場合のみ unsafe)。version は変換の意味を変えたときに上げてください。Plugin の options と変換関数の内容も pipeline fingerprint に含まれるため、Option の変更だけで version を上げる必要はありません。
テストの進め方
Plugin のテストは、確認したい範囲に合わせて4段階に分けられます。すべてを行う必要はありません。小さい範囲から始めてください。
Level 1 Pure logic 通常の test runner だけでよい(AST ヘルパー、文字列変換など)
Level 2 Markdown / HTML Pipeline に Plugin を渡して変換結果を検証する
Level 3 Content / lifecycle ContentManager と In-memory ContentSource で manifest や hook を検証する
Level 4 外部パッケージ境界 pack した package を隔離プロジェクトへ install して検証する
Level 1: 純粋な処理
Option の解決、文字列や AST のヘルパーなど、Riebeckite に依存しない処理は node:test などの通常の test runner でそのままテストします。この段階では Riebeckite のテスト基盤は不要です。
Level 2: Markdown / HTML の変換
Pipeline に Plugin を渡し、代表的な Markdown を execute() して意味のある出力を検証します。これが「実践」の Step 4 で行っているテストです。
const pipeline = new Pipeline(new Map(), new Map(), undefined, {
plugins: [tipPlugin()],
});
const { html } = await pipeline.execute(
":::tip[Heads up]\nSave often.\n:::",
);
Pipeline の第1・第2引数は content index と permalink の解決に使う Map です。単一ファイルの変換を確認するだけなら空の Map で十分です。第3引数(getMarkdownBySlug)は embed など他の Content を参照する場合だけ必要です。
Level 3: Content / lifecycle
Content の読み込み、hook、manifest、body slot、Page Type などを検証する場合は ContentManager を使います。実際の Filesystem を用意する代わりに、小さな In-memory ContentSource を渡します。
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();
詳しい pattern は テスト を参照してください。
Level 4: 外部パッケージ境界
配布する Package は、pack した tarball を monorepo の外の隔離プロジェクトへ install して検証します。これによって、公開 Export の不足、@riebeckite/core/src/** への誤った依存、dependencies の宣言漏れ、型定義の欠落などを検出できます。Riebeckite repository では tests/plugin-dx がこの検証の実例で、pnpm test:plugin-dx と pnpm test:plugin-dx:external で実行します。
4. 独立ページを追加する(必要な場合)
独立画面には pageTypes を使います。Page Type は HTML body を返し、Site の共通 catch-all route が document frame と Theme を適用します。ページで entry を一覧する場合は manifest.discoverableEntries を使ってください。manifest.publicEntries(unlisted を含む)と manifest.entries(draft・scheduled を含む)は、本当に必要な場合だけに限ります。詳しくは Manifest の collection と公開境界 を参照してください。Plugin 固有の HonoX route は追加しません。Canvas、Bases、Excalidraw のような記事本文への埋め込みは renderers のままです。
pageTypes: [{
id: "local.report",
paths: ["/report"],
resolve: ({ pathname }) => pathname === "/report"
? { type: "local.report", pathname, title: "Report", body: "<p>Ready</p>" }
: null,
}],
scaffold が生成する HonoX route はすでに resolveRiebeckiteRoute と pluginPageSsgParams を使います。ID は全体で一意にし、動的ページの SSG path は public manifest から導き、所有しない path では null を返してください。責務と接続全体は Page System を参照してください。
5. 配布用パッケージにする(任意)
site 内プラグインとして動けば、パッケージにできます。雛形は packages/plugins/backlinks です。
packages/plugins/backlinks/
├─ index.ts ← definePlugin を呼ぶ factory、公開部品の再 export
├─ components/ ← コンポーネント(必要なら)
├─ src/ ← 実装(型・ヘルパーなど)
├─ styles/style.css ← プラグインの CSS
├─ package.json ← "." / "./components" / "./style.css" を exports で公開
├─ README_ja.md
└─ README.md
外部配布のプラグインは @riebeckite/core だけに依存し、自身の subpath を exports で宣言します。@riebeckite/core/src/** を import したり、monorepo 内の path を参照したりしないでください。package 構成と、ESM と型定義を同梱する build については Repository 外で Plugin を配布する を参照してください。createStyleAsset() と createClientEntry() は @riebeckite/plugin-<name>/... の specifier を組み立てるため、別名の package は assets / clientEntries に moduleSpecifier を明示します。
この directive プラグインを配布する場合、依存は @riebeckite/core と unist-util-visit を dependencies に、mdast の型(@types/mdast)を devDependencies に宣言します。package.json の全体像と、esbuild + tsc による build Script の例は Repository 外で Plugin を配布する にあります。
6. 検証する
npm exec riebeckite check # 設定と Plugin の解決を検証
npm exec riebeckite doctor # 健全性診断
npm exec riebeckite inspect plugins# 解決済みの Plugin 一覧を確認
npm exec riebeckite build # 生成物に反映されるか確認
check / doctor / inspect は読み取り専用です。解決されない場合は、まず check のメッセージで capability エラーや import エラーを確認してください。プラグインを作る前に「本当に Plugin が必要か(設定や App 実装で済まないか)」も確認してください。
UI の提供方法
Plugin が UI を追加する方法はいくつかあります。最小のものを選んでください。優劣の順列ではなく、組み合わせてもかまいません。
UI / output を提供したい
│
├─ Markdown / HTML 自体を変換する
│ └─ remark / rehype pipeline
│
├─ 埋め込み content を描画する
│ └─ renderers
│
├─ 独立ページを提供する
│ └─ pageTypes
│
├─ 記事 layout へ自動配置する
│ └─ HTML fragment + body Slot
│
├─ Site 作者に配置を任せる
│ └─ Hono JSX component を export
│
└─ Browser 側で強化する
└─ clientEntries(必要なら Site 所有の Island)
body Slot で自動配置する
出力が標準の位置にあり、Plugin を有効化すればすぐ表示したい場合は body Slot を使います。HTML fragment を提供し、Site が slot を描画するかどうかと位置を決めます。
import { appendContentBodySlot } from "@riebeckite/core";
appendContentBodySlot(entry, "article.footer", "<section>...</section>");
article.footer などの標準 slot を選ぶか、独自名を Site に描画してもらいます。独自 slot は Site が描画を選ぶまで何も表示しません。slot の一覧と順序は Body Slots を参照してください。
Hono JSX component で手動配置する
UI の配置を Site 作者に任せたい場合は、通常の Hono JSX component を export します。component registry や Plugin 固有の component API はありません。ほかの component と同じように import して組み合わせます。
package の exports に ./components subpath を宣言し、component module の default export を保ちます。必要なら同じ component を package root から名前付きでも再 export します。既存 Plugin はこの形です。
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 は root のみの形です。ColorModeScript と ColorModeToggle を package root から公開し、./components subpath を持ちません。名前は各 package README に従ってください。
HTML fragment と component の使い分け
判断基準は 誰が配置するか です。
- HTML fragment + Slot: Plugin が標準の位置へ書き、Site がその slot を描画するか決めます。
- Hono JSX component: Site 作者が component tree の好きな場所へ配置します。
新しいから優れている、という関係ではありません。Plugin がすでに HTML を生成している場合(HAST 変換など)は文字列が自然で、props と配置を Site が制御したい場合は component が自然です。backlinks と local-graph は両方を使い、component を export しつつ onManifestCreated で描画結果を article.footer へ追加します。よくある pattern であり、必須ではありません。
Browser 強化と Island
Plugin は app/islands/ を所有せず、Riebeckite に Plugin 用 Island registry もありません。Browser 側の動作が必要な場合は、server-render 済み DOM を強化する clientEntries initializer を提供するか、component state が必要なら Site が Plugin component を自前の HonoX Island で包みます。garden-explorer は Page Type と client entry を組み合わせた特殊例であり、必須の pattern として一般化しないでください。詳しくは Client Entries を参照してください。
関連資料
- プラグイン作成の詳細 — この入門の詳細編(拡張ポイント・capability・lifecycle・配布)
- Plugin System — すべての拡張ポイントの詳細
- テスト — Plugin のテスト戦略と External 検証
- Theme API — Theme Extension Contract と Semantic Token
- Architecture — Core / Plugin / Integration / Theme / App の責務
- Framework Reference —
definePluginなどの公開 API