Color mode

はじめてのプラグイン作成

プラグインは機能(Markdown/HTML の変換、クライアント動作、独立ページ、SEO、診断など)を足す仕組みです。見た目を変えたいときはテーマ(はじめてのテーマ作成)を使います。Plugin の独立ページは Site の共通 route で表示します。Site 固有の画面だけを App(app/)の route に置いてください。

プラグインが HTML や外部 URL を生成する場合は、表示する値や URL を安全に扱う必要があります。特に、外部から取得したデータやユーザー入力をそのまま HTML に埋め込まないでください。 Riebeckite がどこまで安全性を保証し、プラグイン側で何を確認する必要があるかは、セキュリティモデル を参照してください。

Plugin がボタンやメニューなどの UI を表示する場合は、キーボードでも操作できるようにしてください。 基本的な考え方や注意点は、アクセシビリティ を参照してください。

全体像

このページは、何もない状態から配布できる Plugin までを一続きで扱います。

text
最小の Plugin を作る(1〜2)
   ↓
Markdown / HTML を変換する(3、実践)
   ↓
出力をテストする(テストの進め方)
   ↓
配布用パッケージにする(5)
   ↓
外部パッケージとして検証する(tests/plugin-dx / test:plugin-dx:external)

site の中だけで使う場合は「配布用パッケージにする」より前で完結します。外部へ配布する場合だけ、package 化と外部検証まで進めます。各拡張ポイントの正確な契約は Plugin System を参照してください。

1. 最小のプラグインを作る

プラグインは definePlugin(@riebeckite/core から import)で作ります。パッケージにする必要はなく、サイトの中に置けます。

ts
// extensions/local-plugin.ts
import { definePlugin } from "@riebeckite/core";
 
export function localPlugin() {
  return definePlugin({
    name: "local",
  });
}

riebeckite.config.ts の plugins 配列に追加します。

ts
// riebeckite.config.ts
import { localPlugin } from "./extensions/local-plugin";
 
export default defineConfig({
  plugins: [localPlugin()],
  // ...
});

name だけのプラグインは「何もしない」最小構成です。オプションを渡したい場合は factory に引数を付けて型を付けます。

ts
type LocalOptions = { enabled?: boolean };
 
export function localPlugin(options: LocalOptions = {}) {
  return definePlugin({ name: "local", options });
}

2. CSS を足す

Plugin 固有の stylesheet は assets で宣言します。サイトに CSS をコピーしたり、ブラウザから /node_modules を直接参照させたりしないでください。

ts
// 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 プラグインは配列で宣言できます。

ts
// 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 表示に変換するプラグインを作ります。

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

最終的には、次のような HTML が生成されます。

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

作業は4段階です。

  1. Markdown を変換するプラグインを作る
  2. 見た目を整える CSS を追加する
  3. riebeckite.config.ts に登録する
  4. 期待した HTML が生成されることを test する

Step 1: プラグインを作る

extensions/tip-plugin.ts を作成します。

まず、このプラグインの入口を見てみましょう。

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 を扱うための型を追加します。

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>;
};

この例では 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 を探します。

ts
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 も含まれます。

そのため、

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

として、:::tip だけを処理しています。

<aside> に変換する

見つけた :::tip に、生成したい HTML 要素を指定します。

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

ここで、

ts
hName: "aside"

が HTML 要素を、

ts
className: ["rr-tip"]

が class を指定しています。

つまり、

md
:::tip
Save often.
:::

は最終的に、

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

のように出力されます。

[Heads up] をタイトルにする

次は、

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

の [Heads up] をタイトルとして扱います。

remark-directive は、この label を directive の最初の子 node として渡します。

そこで最初の子が label かどうかを確認します。

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

label があれば、

  • 最初の子 → タイトル
  • それ以降 → 本文

として分けます。

ts
const titleChildren = hasLabel
  ? (first as Parent).children
  : [];
 
const bodyChildren = hasLabel
  ? rest
  : directive.children;

そしてタイトルを、

html
<p class="rr-tip__title">

として追加します。

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

これで、

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

から、

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

が生成されます。

完成したプラグイン

ここまでをまとめると、extensions/tip-plugin.ts は次のようになります。

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 を作成します。

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;
}

先ほど生成した、

html
<aside class="rr-tip">

と、

html
<p class="rr-tip__title">

に対してスタイルを適用しています。

色には固定値ではなく、

css
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 に登録します。

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

これで Markdown に、

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

と書けば、Tip が生成されるようになります。

Step 4: 出力を test する

最後に、期待した HTML が生成されることを test します。

このテストでは site 全体を build する必要はありません。Pipeline を直接実行して、Markdown の変換結果だけを確認できます。

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\./,
  );
});

この test では3つのことを確認しています。

text
:::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 では、

ts
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段階に分けられます。すべてを行う必要はありません。小さい範囲から始めてください。

text
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 で行っているテストです。

ts
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 を渡します。

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();

詳しい 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 のままです。

ts
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 です。

text
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. 検証する

sh
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 を追加する方法はいくつかあります。最小のものを選んでください。優劣の順列ではなく、組み合わせてもかまいません。

text
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 を描画するかどうかと位置を決めます。

ts
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 はこの形です。

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 は 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 を参照してください。

関連資料

History

1 changesCollapseExpand
1 + # はじめてのプラグイン作成
2 +
3 + プラグインは**機能**(Markdown/HTML の変換、クライアント動作、独立ページ、SEO、診断など)を足す仕組みです。見た目を変えたいときはテーマ([はじめてのテーマ作成](../themes/writing-a-theme.md))を使います。Plugin の独立ページは Site の共通 route で表示します。Site 固有の画面だけを App(`app/`)の route に置いてください。
4 +
5 + プラグインが HTML や外部 URL を生成する場合は、表示する値や URL を安全に扱う必要があります。特に、外部から取得したデータやユーザー入力をそのまま HTML に埋め込まないでください。
6 + Riebeckite がどこまで安全性を保証し、プラグイン側で何を確認する必要があるかは、[セキュリティモデル](../security.md) を参照してください。
7 +
8 + Plugin がボタンやメニューなどの UI を表示する場合は、キーボードでも操作できるようにしてください。
9 + 基本的な考え方や注意点は、[アクセシビリティ](../accessibility.md) を参照してください。
10 +
11 + ## 全体像
12 +
13 + このページは、何もない状態から配布できる Plugin までを一続きで扱います。
14 +
15 + ```text
16 + 最小の Plugin を作る(1〜2)
17 + ↓
18 + Markdown / HTML を変換する(3、実践)
19 + ↓
20 + 出力をテストする(テストの進め方)
21 + ↓
22 + 配布用パッケージにする(5)
23 + ↓
24 + 外部パッケージとして検証する(tests/plugin-dx / test:plugin-dx:external)
25 + ```
26 +
27 + site の中だけで使う場合は「配布用パッケージにする」より前で完結します。外部へ配布する場合だけ、package 化と外部検証まで進めます。各拡張ポイントの正確な契約は [Plugin System](../reference/plugin-api.md) を参照してください。
28 +
29 + ## 1. 最小のプラグインを作る
30 +
31 + プラグインは `definePlugin`(`@riebeckite/core` から import)で作ります。**パッケージにする必要はなく、サイトの中に置けます**。
32 +
33 + ```ts
34 + // extensions/local-plugin.ts
35 + import { definePlugin } from "@riebeckite/core";
36 +
37 + export function localPlugin() {
38 + return definePlugin({
39 + name: "local",
40 + });
41 + }
42 + ```
43 +
44 + `riebeckite.config.ts` の `plugins` 配列に追加します。
45 +
46 + ```ts
47 + // riebeckite.config.ts
48 + import { localPlugin } from "./extensions/local-plugin";
49 +
50 + export default defineConfig({
51 + plugins: [localPlugin()],
52 + // ...
53 + });
54 + ```
55 +
56 + `name` だけのプラグインは「何もしない」最小構成です。オプションを渡したい場合は factory に引数を付けて型を付けます。
57 +
58 + ```ts
59 + type LocalOptions = { enabled?: boolean };
60 +
61 + export function localPlugin(options: LocalOptions = {}) {
62 + return definePlugin({ name: "local", options });
63 + }
64 + ```
65 +
66 + ## 2. CSS を足す
67 +
68 + Plugin 固有の stylesheet は `assets` で宣言します。サイトに CSS をコピーしたり、ブラウザから `/node_modules` を直接参照させたりしないでください。
69 +
70 + ```ts
71 + // extensions/local-plugin.ts
72 + import { definePlugin } from "@riebeckite/core";
73 +
74 + export function localPlugin() {
75 + return definePlugin({
76 + name: "local",
77 + assets: [
78 + {
79 + pluginName: "local",
80 + kind: "style",
81 + moduleSpecifier: "/extensions/plugin.css",
82 + },
83 + ],
84 + });
85 + }
86 + ```
87 +
88 + - `moduleSpecifier` は host bundler が解決できるものを指定します。site 内プラグインでは `/extensions/plugin.css` の形です。
89 + - 描画する最外要素には安定した root hook(`rr-<feature>`)を付けます。CSS の規約は [Plugin System](../reference/plugin-api.md) を参照してください。
90 +
91 + ## 3. Markdown / HTML を変換する
92 +
93 + マークダウンの意味変換は Plugin の責務です。簡単な remark プラグインは配列で宣言できます。
94 +
95 + ```ts
96 + // extensions/local-plugin.ts
97 + import { definePlugin } from "@riebeckite/core";
98 +
99 + function remarkLocal() {
100 + return (tree: unknown) => {
101 + // tree(Markdown AST)を加工する
102 + return tree;
103 + };
104 + }
105 +
106 + export function localPlugin() {
107 + return definePlugin({ name: "local", remarkPlugins: [remarkLocal] });
108 + }
109 + ```
110 +
111 + pipeline 自体を細かく構成したい場合は `extendMarkdownPipeline` / `extendHtmlPipeline` を使います。その他の拡張ポイント(依存関係・lifecycle・renderer・endpoint など)は [Plugin System](../reference/plugin-api.md) を参照してください。
112 +
113 + `remarkPlugins` / `rehypePlugins` も `extendMarkdownPipeline` も同じ Pipeline に参加します。Riebeckite は Plugin より前に `remark-parse` や `remark-directive`、`remark-gfm` などを適用するため、`:::tip` のような directive や GFM 記法はすでに AST node として Plugin へ渡されます。これらを著者がインストールしたり登録したりする必要はありません。
114 +
115 + Content 変換に参加する Plugin は `processedContentCache` も宣言してください。宣言がない場合でも動作はしますが、その site の処理済み Content キャッシュが無効化され、build のたびに Markdown を再処理します。他 Plugin の Content に依存しない単独の変換なら `{ version: "1", dependencyMode: "none" }`、他 Plugin の出力に依存するなら `tracked` を指定します(正確に追跡できない場合のみ `unsafe`)。
116 +
117 + ## 実践: directive プラグインを最後まで作る
118 +
119 + ここでは、次のような Markdown を独自の Tip 表示に変換するプラグインを作ります。
120 +
121 + ```md
122 + :::tip[Heads up]
123 + Save often.
124 + :::
125 + ```
126 +
127 + 最終的には、次のような HTML が生成されます。
128 +
129 + ```html
130 + <aside class="rr-tip">
131 + <p class="rr-tip__title">Heads up</p>
132 + <p>Save often.</p>
133 + </aside>
134 + ```
135 +
136 + 作業は4段階です。
137 +
138 + 1. Markdown を変換するプラグインを作る
139 + 2. 見た目を整える CSS を追加する
140 + 3. `riebeckite.config.ts` に登録する
141 + 4. 期待した HTML が生成されることを test する
142 +
143 + ### Step 1: プラグインを作る
144 +
145 + `extensions/tip-plugin.ts` を作成します。
146 +
147 + まず、このプラグインの入口を見てみましょう。
148 +
149 + ```ts
150 + import { definePlugin } from "@riebeckite/core";
151 +
152 + export function tipPlugin() {
153 + return definePlugin({
154 + name: "tip",
155 +
156 + extendMarkdownPipeline: (pipeline) => {
157 + pipeline.use(remarkTip);
158 + },
159 +
160 + assets: [
161 + {
162 + pluginName: "tip",
163 + kind: "style",
164 + moduleSpecifier: "/extensions/plugin.css",
165 + },
166 + ],
167 + });
168 + }
169 + ```
170 +
171 + ここでやっていることは2つだけです。
172 +
173 + - `remarkTip` という Markdown 変換を追加する
174 + - `/extensions/plugin.css` を stylesheet として読み込む
175 +
176 + `remarkTip` が、実際に `:::tip` を `<aside>` へ変換する部分です。
177 +
178 + #### `:::tip` を見つける
179 +
180 + 必要な import と、directive を扱うための型を追加します。
181 +
182 + ```ts
183 + import { definePlugin } from "@riebeckite/core";
184 + import type { Parent, Root } from "mdast";
185 + import { visit } from "unist-util-visit";
186 +
187 + type ContainerDirective = {
188 + type: "containerDirective";
189 + name: string;
190 + children: Parent["children"];
191 + data?: Record<string, unknown>;
192 + };
193 + ```
194 +
195 + この例では Markdown AST の型に `mdast`、node の走査に `unist-util-visit` を使います。どちらも通常の npm package として自分の package に追加してください(`@riebeckite/core` からは import しません)。
196 +
197 + Riebeckite の Markdown pipeline では、`:::tip` のような記法はあらかじめ `remark-directive` によって `containerDirective` node に変換されています。
198 +
199 + そのため、プラグイン側で Markdown の文字列を解析する必要はありません。
200 +
201 + `unist-util-visit` を使って、その node を探します。
202 +
203 + ```ts
204 + function remarkTip() {
205 + return (tree: Root) => {
206 + visit(tree, "containerDirective", (node) => {
207 + const directive = node as unknown as ContainerDirective;
208 +
209 + if (directive.name !== "tip") return;
210 +
211 + // ここで :::tip を変換する
212 + });
213 + };
214 + }
215 + ```
216 +
217 + `containerDirective` には `tip` 以外の directive も含まれます。
218 +
219 + そのため、
220 +
221 + ```ts
222 + if (directive.name !== "tip") return;
223 + ```
224 +
225 + として、`:::tip` だけを処理しています。
226 +
227 + #### `<aside>` に変換する
228 +
229 + 見つけた `:::tip` に、生成したい HTML 要素を指定します。
230 +
231 + ```ts
232 + directive.data = {
233 + ...directive.data,
234 + hName: "aside",
235 + hProperties: {
236 + className: ["rr-tip"],
237 + },
238 + };
239 + ```
240 +
241 + ここで、
242 +
243 + ```ts
244 + hName: "aside"
245 + ```
246 +
247 + が HTML 要素を、
248 +
249 + ```ts
250 + className: ["rr-tip"]
251 + ```
252 +
253 + が class を指定しています。
254 +
255 + つまり、
256 +
257 + ```md
258 + :::tip
259 + Save often.
260 + :::
261 + ```
262 +
263 + は最終的に、
264 +
265 + ```html
266 + <aside class="rr-tip">
267 + <p>Save often.</p>
268 + </aside>
269 + ```
270 +
271 + のように出力されます。
272 +
273 + #### `[Heads up]` をタイトルにする
274 +
275 + 次は、
276 +
277 + ```md
278 + :::tip[Heads up]
279 + Save often.
280 + :::
281 + ```
282 +
283 + の `[Heads up]` をタイトルとして扱います。
284 +
285 + `remark-directive` は、この label を directive の最初の子 node として渡します。
286 +
287 + そこで最初の子が label かどうかを確認します。
288 +
289 + ```ts
290 + const [first, ...rest] = directive.children;
291 +
292 + const hasLabel =
293 + first?.type === "paragraph" &&
294 + (first as { data?: { directiveLabel?: boolean } }).data
295 + ?.directiveLabel === true;
296 + ```
297 +
298 + label があれば、
299 +
300 + - 最初の子 → タイトル
301 + - それ以降 → 本文
302 +
303 + として分けます。
304 +
305 + ```ts
306 + const titleChildren = hasLabel
307 + ? (first as Parent).children
308 + : [];
309 +
310 + const bodyChildren = hasLabel
311 + ? rest
312 + : directive.children;
313 + ```
314 +
315 + そしてタイトルを、
316 +
317 + ```html
318 + <p class="rr-tip__title">
319 + ```
320 +
321 + として追加します。
322 +
323 + ```ts
324 + directive.children = [
325 + {
326 + type: "paragraph",
327 + data: {
328 + hName: "p",
329 + hProperties: {
330 + className: ["rr-tip__title"],
331 + },
332 + },
333 + children: titleChildren,
334 + },
335 + ...bodyChildren,
336 + ];
337 + ```
338 +
339 + これで、
340 +
341 + ```md
342 + :::tip[Heads up]
343 + Save often.
344 + :::
345 + ```
346 +
347 + から、
348 +
349 + ```html
350 + <aside class="rr-tip">
351 + <p class="rr-tip__title">Heads up</p>
352 + <p>Save often.</p>
353 + </aside>
354 + ```
355 +
356 + が生成されます。
357 +
358 + #### 完成したプラグイン
359 +
360 + ここまでをまとめると、`extensions/tip-plugin.ts` は次のようになります。
361 +
362 + ```ts
363 + import { definePlugin } from "@riebeckite/core";
364 + import type { Parent, Root } from "mdast";
365 + import { visit } from "unist-util-visit";
366 +
367 + type ContainerDirective = {
368 + type: "containerDirective";
369 + name: string;
370 + children: Parent["children"];
371 + data?: Record<string, unknown>;
372 + };
373 +
374 + function remarkTip() {
375 + return (tree: Root) => {
376 + visit(tree, "containerDirective", (node) => {
377 + const directive = node as unknown as ContainerDirective;
378 +
379 + if (directive.name !== "tip") return;
380 +
381 + const [first, ...rest] = directive.children;
382 +
383 + const hasLabel =
384 + first?.type === "paragraph" &&
385 + (first as { data?: { directiveLabel?: boolean } }).data
386 + ?.directiveLabel === true;
387 +
388 + const titleChildren = hasLabel
389 + ? (first as Parent).children
390 + : [];
391 +
392 + const bodyChildren = hasLabel
393 + ? rest
394 + : directive.children;
395 +
396 + directive.data = {
397 + ...directive.data,
398 + hName: "aside",
399 + hProperties: {
400 + className: ["rr-tip"],
401 + },
402 + };
403 +
404 + directive.children = [
405 + {
406 + type: "paragraph",
407 + data: {
408 + hName: "p",
409 + hProperties: {
410 + className: ["rr-tip__title"],
411 + },
412 + },
413 + children: titleChildren,
414 + },
415 + ...bodyChildren,
416 + ];
417 + });
418 + };
419 + }
420 +
421 + export function tipPlugin() {
422 + return definePlugin({
423 + name: "tip",
424 +
425 + extendMarkdownPipeline: (pipeline) => {
426 + pipeline.use(remarkTip);
427 + },
428 +
429 + assets: [
430 + {
431 + pluginName: "tip",
432 + kind: "style",
433 + moduleSpecifier: "/extensions/plugin.css",
434 + },
435 + ],
436 +
437 + processedContentCache: {
438 + version: "1",
439 + dependencyMode: "none",
440 + },
441 + });
442 + }
443 + ```
444 +
445 + > `extendMarkdownPipeline` は Markdown の AST を直接操作できる低レベルな拡張ポイントです。この例では directive の HTML 構造そのものを変更したいため使用しています。
446 +
447 + ### Step 2: stylesheet を追加する
448 +
449 + 次に `extensions/plugin.css` を作成します。
450 +
451 + ```css
452 + .rr-tip {
453 + border-left: 2px solid var(--rb-color-accent);
454 + padding: 0.75rem 1rem;
455 + }
456 +
457 + .rr-tip__title {
458 + margin-block: 0 0.25rem;
459 + font-weight: 600;
460 + }
461 + ```
462 +
463 + 先ほど生成した、
464 +
465 + ```html
466 + <aside class="rr-tip">
467 + ```
468 +
469 + と、
470 +
471 + ```html
472 + <p class="rr-tip__title">
473 + ```
474 +
475 + に対してスタイルを適用しています。
476 +
477 + 色には固定値ではなく、
478 +
479 + ```css
480 + var(--rb-color-accent)
481 + ```
482 +
483 + という Riebeckite の theme token を使っています。
484 +
485 + こうしておくと、利用している theme が変わっても、その theme の accent color に追従できます。
486 +
487 + これが 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](../reference/theme-api.md) を参照してください。
488 +
489 + ### Step 3: プラグインを登録する
490 +
491 + 作ったプラグインを `riebeckite.config.ts` に登録します。
492 +
493 + ```ts
494 + import { defineConfig } from "@riebeckite/core";
495 + import { tipPlugin } from "./extensions/tip-plugin";
496 +
497 + export default defineConfig({
498 + plugins: [
499 + tipPlugin(),
500 + ],
501 + });
502 + ```
503 +
504 + これで Markdown に、
505 +
506 + ```md
507 + :::tip[Heads up]
508 + Save often.
509 + :::
510 + ```
511 +
512 + と書けば、Tip が生成されるようになります。
513 +
514 + ### Step 4: 出力を test する
515 +
516 + 最後に、期待した HTML が生成されることを test します。
517 +
518 + このテストでは site 全体を build する必要はありません。`Pipeline` を直接実行して、Markdown の変換結果だけを確認できます。
519 +
520 + `extensions/tip-plugin.test.ts` を作成します。
521 +
522 + ```ts
523 + import assert from "node:assert/strict";
524 + import { test } from "node:test";
525 + import { Pipeline } from "@riebeckite/core";
526 + import { tipPlugin } from "./tip-plugin.ts";
527 +
528 + test("renders :::tip as an aside with a title", async () => {
529 + const pipeline = new Pipeline(
530 + new Map(),
531 + new Map(),
532 + undefined,
533 + {
534 + plugins: [tipPlugin()],
535 + },
536 + );
537 +
538 + const { html } = await pipeline.execute(
539 + ":::tip[Heads up]\nSave often.\n:::",
540 + );
541 +
542 + assert.match(
543 + html,
544 + /<aside class="rr-tip">/,
545 + );
546 +
547 + assert.match(
548 + html,
549 + /<p class="rr-tip__title">Heads up<\/p>/,
550 + );
551 +
552 + assert.match(
553 + html,
554 + /Save often\./,
555 + );
556 + });
557 + ```
558 +
559 + この test では3つのことを確認しています。
560 +
561 + ```text
562 + :::tip
563 + ↓
564 + <aside class="rr-tip">
565 +
566 + [Heads up]
567 + ↓
568 + <p class="rr-tip__title">Heads up</p>
569 +
570 + Save often.
571 + ↓
572 + 本文として出力される
573 + ```
574 +
575 + これで、Markdown の変換、stylesheet の追加、Plugin の登録、そして test までを含む小さな site 内プラグインが完成しました。
576 +
577 + ### この例で覚えておくこと
578 +
579 + この例のすべての AST 操作を覚える必要はありません。
580 +
581 + 重要なのは、Riebeckite Plugin では、
582 +
583 + ```ts
584 + definePlugin({
585 + name: "...",
586 +
587 + extendMarkdownPipeline: (pipeline) => {
588 + pipeline.use(...);
589 + },
590 +
591 + assets: [...],
592 + });
593 + ```
594 +
595 + という形で、Markdown の変換や stylesheet などをひとつの Plugin にまとめられることです。
596 +
597 + `extendMarkdownPipeline` は remark / mdast を直接扱うための低レベルな API なので、独自の Markdown 構文や複雑な変換が必要な場合に使います。
598 +
599 + この例では、`processedContentCache` も宣言しています。宣言がない場合でも動作はしますが、site の処理済み Content キャッシュが無効化され、build のたびに Markdown を再処理します。他 Plugin の Content に依存しない単独の変換なら `dependencyMode: "none"`、他 Plugin の出力に依存するなら `tracked` を指定します(正確に追跡できない場合のみ `unsafe`)。`version` は変換の意味を変えたときに上げてください。Plugin の `options` と変換関数の内容も pipeline fingerprint に含まれるため、Option の変更だけで `version` を上げる必要はありません。
600 +
601 + ## テストの進め方
602 +
603 + Plugin のテストは、確認したい範囲に合わせて4段階に分けられます。すべてを行う必要はありません。小さい範囲から始めてください。
604 +
605 + ```text
606 + Level 1 Pure logic 通常の test runner だけでよい(AST ヘルパー、文字列変換など)
607 + Level 2 Markdown / HTML Pipeline に Plugin を渡して変換結果を検証する
608 + Level 3 Content / lifecycle ContentManager と In-memory ContentSource で manifest や hook を検証する
609 + Level 4 外部パッケージ境界 pack した package を隔離プロジェクトへ install して検証する
610 + ```
611 +
612 + ### Level 1: 純粋な処理
613 +
614 + Option の解決、文字列や AST のヘルパーなど、Riebeckite に依存しない処理は `node:test` などの通常の test runner でそのままテストします。この段階では Riebeckite のテスト基盤は不要です。
615 +
616 + ### Level 2: Markdown / HTML の変換
617 +
618 + `Pipeline` に Plugin を渡し、代表的な Markdown を `execute()` して意味のある出力を検証します。これが「実践」の Step 4 で行っているテストです。
619 +
620 + ```ts
621 + const pipeline = new Pipeline(new Map(), new Map(), undefined, {
622 + plugins: [tipPlugin()],
623 + });
624 +
625 + const { html } = await pipeline.execute(
626 + ":::tip[Heads up]\nSave often.\n:::",
627 + );
628 + ```
629 +
630 + `Pipeline` の第1・第2引数は content index と permalink の解決に使う `Map` です。単一ファイルの変換を確認するだけなら空の `Map` で十分です。第3引数(`getMarkdownBySlug`)は embed など他の Content を参照する場合だけ必要です。
631 +
632 + ### Level 3: Content / lifecycle
633 +
634 + Content の読み込み、hook、manifest、body slot、Page Type などを検証する場合は `ContentManager` を使います。実際の Filesystem を用意する代わりに、小さな In-memory `ContentSource` を渡します。
635 +
636 + ```ts
637 + const source = {
638 + async scan() {
639 + return [{ path: "notes/index.md" }];
640 + },
641 + async read(entry) {
642 + return `# ${entry.path}`;
643 + },
644 + };
645 +
646 + const manager = new ContentManager(source, [], { config });
647 + const manifest = await manager.getManifest();
648 + ```
649 +
650 + 詳しい pattern は [テスト](../framework/testing.md) を参照してください。
651 +
652 + ### Level 4: 外部パッケージ境界
653 +
654 + 配布する Package は、pack した tarball を monorepo の外の隔離プロジェクトへ install して検証します。これによって、公開 Export の不足、`@riebeckite/core/src/**` への誤った依存、`dependencies` の宣言漏れ、型定義の欠落などを検出できます。Riebeckite repository では `tests/plugin-dx` がこの検証の実例で、`pnpm test:plugin-dx` と `pnpm test:plugin-dx:external` で実行します。
655 +
656 + ## 4. 独立ページを追加する(必要な場合)
657 +
658 + 独立画面には `pageTypes` を使います。Page Type は HTML body を返し、Site の共通 catch-all route が document frame と Theme を適用します。ページで entry を一覧する場合は `manifest.discoverableEntries` を使ってください。`manifest.publicEntries`(`unlisted` を含む)と `manifest.entries`(`draft`・`scheduled` を含む)は、本当に必要な場合だけに限ります。詳しくは [Manifest の collection と公開境界](../reference/plugin-api.md#manifest-の-collection-と公開境界) を参照してください。Plugin 固有の HonoX route は追加しません。Canvas、Bases、Excalidraw のような記事本文への埋め込みは `renderers` のままです。
659 +
660 + ```ts
661 + pageTypes: [{
662 + id: "local.report",
663 + paths: ["/report"],
664 + resolve: ({ pathname }) => pathname === "/report"
665 + ? { type: "local.report", pathname, title: "Report", body: "<p>Ready</p>" }
666 + : null,
667 + }],
668 + ```
669 +
670 + scaffold が生成する HonoX route はすでに `resolveRiebeckiteRoute` と `pluginPageSsgParams` を使います。ID は全体で一意にし、動的ページの SSG path は public manifest から導き、所有しない path では `null` を返してください。責務と接続全体は [Page System](../framework/page-system.md) を参照してください。
671 +
672 + ## 5. 配布用パッケージにする(任意)
673 +
674 + site 内プラグインとして動けば、パッケージにできます。雛形は `packages/plugins/backlinks` です。
675 +
676 + ```text
677 + packages/plugins/backlinks/
678 + ├─ index.ts ← definePlugin を呼ぶ factory、公開部品の再 export
679 + ├─ components/ ← コンポーネント(必要なら)
680 + ├─ src/ ← 実装(型・ヘルパーなど)
681 + ├─ styles/style.css ← プラグインの CSS
682 + ├─ package.json ← "." / "./components" / "./style.css" を exports で公開
683 + ├─ README_ja.md
684 + └─ README.md
685 + ```
686 +
687 + 外部配布のプラグインは `@riebeckite/core` だけに依存し、自身の subpath を `exports` で宣言します。`@riebeckite/core/src/**` を import したり、monorepo 内の path を参照したりしないでください。package 構成と、ESM と型定義を同梱する build については [Repository 外で Plugin を配布する](../reference/plugin-api.md#repository-外で-plugin-を配布する) を参照してください。`createStyleAsset()` と `createClientEntry()` は `@riebeckite/plugin-<name>/...` の specifier を組み立てるため、別名の package は `assets` / `clientEntries` に `moduleSpecifier` を明示します。
688 +
689 + この directive プラグインを配布する場合、依存は `@riebeckite/core` と `unist-util-visit` を `dependencies` に、`mdast` の型(`@types/mdast`)を `devDependencies` に宣言します。`package.json` の全体像と、esbuild + `tsc` による build Script の例は [Repository 外で Plugin を配布する](../reference/plugin-api.md#repository-外で-plugin-を配布する) にあります。
690 +
691 + ## 6. 検証する
692 +
693 + ```sh
694 + npm exec riebeckite check # 設定と Plugin の解決を検証
695 + npm exec riebeckite doctor # 健全性診断
696 + npm exec riebeckite inspect plugins# 解決済みの Plugin 一覧を確認
697 + npm exec riebeckite build # 生成物に反映されるか確認
698 + ```
699 +
700 + `check` / `doctor` / `inspect` は読み取り専用です。解決されない場合は、まず `check` のメッセージで capability エラーや import エラーを確認してください。プラグインを作る前に「本当に Plugin が必要か(設定や App 実装で済まないか)」も確認してください。
701 +
702 + ## UI の提供方法
703 +
704 + Plugin が UI を追加する方法はいくつかあります。最小のものを選んでください。優劣の順列ではなく、組み合わせてもかまいません。
705 +
706 + ```text
707 + UI / output を提供したい
708 + │
709 + ├─ Markdown / HTML 自体を変換する
710 + │ └─ remark / rehype pipeline
711 + │
712 + ├─ 埋め込み content を描画する
713 + │ └─ renderers
714 + │
715 + ├─ 独立ページを提供する
716 + │ └─ pageTypes
717 + │
718 + ├─ 記事 layout へ自動配置する
719 + │ └─ HTML fragment + body Slot
720 + │
721 + ├─ Site 作者に配置を任せる
722 + │ └─ Hono JSX component を export
723 + │
724 + └─ Browser 側で強化する
725 + └─ clientEntries(必要なら Site 所有の Island)
726 + ```
727 +
728 + ### body Slot で自動配置する
729 +
730 + 出力が標準の位置にあり、Plugin を有効化すればすぐ表示したい場合は body Slot を使います。HTML fragment を提供し、Site が slot を描画するかどうかと位置を決めます。
731 +
732 + ```ts
733 + import { appendContentBodySlot } from "@riebeckite/core";
734 +
735 + appendContentBodySlot(entry, "article.footer", "<section>...</section>");
736 + ```
737 +
738 + `article.footer` などの標準 slot を選ぶか、独自名を Site に描画してもらいます。独自 slot は Site が描画を選ぶまで何も表示しません。slot の一覧と順序は [Body Slots](../reference/plugin-api.md#body-slots) を参照してください。
739 +
740 + ### Hono JSX component で手動配置する
741 +
742 + UI の配置を Site 作者に任せたい場合は、通常の Hono JSX component を export します。component registry や Plugin 固有の component API はありません。ほかの component と同じように import して組み合わせます。
743 +
744 + package の `exports` に `./components` subpath を宣言し、component module の default export を保ちます。必要なら同じ component を package root から名前付きでも再 export します。既存 Plugin はこの形です。
745 +
746 + ```ts
747 + import { Backlinks } from "@riebeckite/plugin-backlinks";
748 + import { TableOfContents } from "@riebeckite/plugin-toc";
749 + import { SearchBar } from "@riebeckite/plugin-search";
750 + import BacklinksDefault from "@riebeckite/plugin-backlinks/components";
751 + ```
752 +
753 + `color-mode` は root のみの形です。`ColorModeScript` と `ColorModeToggle` を package root から公開し、`./components` subpath を持ちません。名前は各 package README に従ってください。
754 +
755 + ### HTML fragment と component の使い分け
756 +
757 + 判断基準は **誰が配置するか** です。
758 +
759 + - **HTML fragment + Slot**: Plugin が標準の位置へ書き、Site がその slot を描画するか決めます。
760 + - **Hono JSX component**: Site 作者が component tree の好きな場所へ配置します。
761 +
762 + 新しいから優れている、という関係ではありません。Plugin がすでに HTML を生成している場合(HAST 変換など)は文字列が自然で、props と配置を Site が制御したい場合は component が自然です。`backlinks` と `local-graph` は両方を使い、component を export しつつ `onManifestCreated` で描画結果を `article.footer` へ追加します。よくある pattern であり、必須ではありません。
763 +
764 + ### Browser 強化と Island
765 +
766 + 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](../reference/plugin-api.md#client-entries) を参照してください。
767 +
768 + ## 関連資料
769 +
770 + - [プラグイン作成の詳細](../framework/plugin-system.md) — この入門の詳細編(拡張ポイント・capability・lifecycle・配布)
771 + - [Plugin System](../reference/plugin-api.md) — すべての拡張ポイントの詳細
772 + - [テスト](../framework/testing.md) — Plugin のテスト戦略と External 検証
773 + - [Theme API](../reference/theme-api.md) — Theme Extension Contract と Semantic Token
774 + - [Architecture](../framework/architecture.md) — Core / Plugin / Integration / Theme / App の責務
775 + - [Framework Reference](../reference/README.md) — `definePlugin` などの公開 API
776 +