編集上のナビゲーション
既存ページの URL を変えずに、別の階層でサイドバーにまとめる上級者向けの手順です。
上級者向け
まずはファイルシステムによるナビゲーションから始めてください。望むサイドバーとディレクトリが一致するようにファイルを移動できるなら、そのほうが適切です。この手順ではヘッダーとサイドバーのクロームをホストのコードに置き換え、そのコードを自分で保守することになります。
必要になる場面
既存の URL を動かせない一方で、サイドバーでは別の親子関係が必要な場合に、編集上のツリーを使います。典型的な例は次のとおりです。
ディスク上では、本来のランディングページと兄弟になっている記事。たとえば
reference/basicsの隣にあるreference/installation。別セクションのページを含むまとまり。たとえば Reference の下に並べたいガイド。
実在するランディングページをたどって閲覧させたい、大きなフラットディレクトリ。
次のような場合、この手順は不要です。
| 目的 | 使うもの |
|---|---|
| ディレクトリと同じ階層 | カテゴリごとに index.mdx を書いたファイルシステムナビゲーション |
| いくつかのページの上に、ランディングページのないラベルを置く | category_no_page: true、または合成の type: "category" アイテム(サイドバー) |
| セクションのリンク付き子グループを一段浅く表示する | ファイルシステムのツリーに対する initialPath(サイドバー) |
合成の type: "category" アイテムでは、リンク付きの親行を作れません。このアイテムは href を持たないため、中に置いたランディングページは、リンクのない見出しの下に余分な子として表示されます。SidebarCategory には、見出しをページへリンクさせるプロパティはありません。
6 つの独立した判断
ナビゲーションは 1 つのツリーに見えますが、6 つの判断の組み合わせです。この手順が変えるのは 2 番目だけです。
| 判断 | デフォルトの決まり方 | この例 |
|---|---|---|
| 1. どのページと URL が存在するか | コンテンツファイル(と slug フロントマター) | 変更なし |
| 2. サイドバーでどのページが親と子になるか | ディレクトリ構造 | editorialMembers マップ |
| 3. 遷移先のページをどのセクションが所有するか | ページの URL と headerNav の categoryMatch の照合 | 変更なし(URL の所有者) |
| 4. ツリーのどの部分を最初に表示するか | initialPath と initialPathMode | Reference の行とその子 |
| 5. どの枝を展開するか | sidebar_initial_expansion、collapsed、読者の操作 | 変更なし |
| 6. ツリーを広げるがどこまで広がるか | 渡したフォレストと scopeRoot | ルートが 1 つの Reference フォレスト |
例
コンテンツファイルと URL
例のサイトにはヘッダーセクションが 2 つあります。編集上の行になるページには、すべて実際のページがあります。
src/content/docs/
├── guides/
│ ├── index.mdx → /docs/guides/ "Guides"
│ └── deploying.mdx → /docs/guides/deploying/ "Deploying"
└── reference/
├── index.mdx → /docs/reference/ "Reference"
├── basics.mdx → /docs/reference/basics/ "Basics"
├── installation.mdx → /docs/reference/installation/
└── configuration.mdx → /docs/reference/configuration/ ディスク上では Basics、Installation、Configuration は兄弟です。そのため、デフォルトの Reference サイドバーはフラットになります。
Reference (linked)
├── Basics (linked)
├── Installation (linked)
└── Configuration (linked)目的の Reference サイドバーでは、URL はそのままに親を変えます。
Reference (linked) /docs/reference/
├── Basics (linked) /docs/reference/basics/
│ ├── Installation (linked) /docs/reference/installation/
│ └── Configuration (linked) /docs/reference/configuration/
└── Deploying (linked) /docs/guides/deploying/ ← cross-section leaf メンバーシップのデータとツリーのヘルパー
編集上の階層を表すのは、このメンバーシップマップだけです。ヘルパーはネイティブのツリーを受け取り、スラッグで実在のノードを探し、子がちょうどそのメンバーになるコピーを返します。ラベル、href(ロケールとベースパスを含む)、メタデータはネイティブのノードから引き継ぐため、手作業で組み立て直すものはありません。
import type { DocNavNode } from "@takazudo/zudo-doc/doc-page-props";
import type { SidebarDefinition } from "@takazudo/zudo-doc/sidebar-utils";
/** The header section (`categoryMatch`) that renders the editorial tree. */
export const EDITORIAL_SECTION = "reference";
/** Editorial parent slug → ordered child slugs. Page URLs do not change. */
export const editorialMembers: ReadonlyMap<string, readonly string[]> = new Map([
["reference", ["reference/basics", "guides/deploying"]],
["reference/basics", ["reference/installation", "reference/configuration"]],
]);
/** The forest has one root, Reference; `[0]` in node mode shows that row. */
export const editorialSidebar: SidebarDefinition = {
items: [{ type: "autogenerated" }],
initialPath: [0],
initialPathMode: "node",
};
/**
* Rebuild the native tree as a one-root editorial forest. Every row is a
* shallow copy of a native node (label, href, metadata); its children are
* exactly its editorial members, so a cross-link stays a leaf. The input
* is not mutated.
*/
export function toEditorialTree(tree: DocNavNode[]): DocNavNode[] {
const bySlug = new Map<string, DocNavNode>();
const index = (nodes: DocNavNode[]): void => {
for (const node of nodes) {
bySlug.set(node.slug, node);
index(node.children);
}
};
index(tree);
const build = (slug: string, ancestors: readonly string[]): DocNavNode[] => {
const node = bySlug.get(slug);
// A page missing from this locale or version tree is skipped, not invented.
if (!node) return [];
if (!node.hasPage || !node.href) {
throw new Error(`Editorial row "${slug}" needs an authored page`);
}
if (ancestors.includes(slug)) throw new Error(`Editorial cycle at "${slug}"`);
const members = editorialMembers.get(slug) ?? [];
const path = [...ancestors, slug];
return [{ ...node, children: members.flatMap((member) => build(member, path)) }];
};
const forest = build(EDITORIAL_SECTION, []);
// Without the root page (for example in an older version), keep the native tree.
return forest.length > 0 ? forest : tree;
}自身の editorialMembers エントリを持たない行(この例の guides/deploying)は、ネイティブでは子を持っていても編集上のツリーでは葉になります。これにより、セクションをまたぐリンクが元のセクション全体を持ち込むことを防ぎます。逆に、どこにも列挙されていないページは編集上のサイドバーとドロワーから除かれます。セクションにページを追加したら、editorialMembers にも追加してください。そのページはルートとして存在し、ページャーにも表示されたままです。
ヘッダーとサイドバーの組
パッケージの Header と Sidebar のファクトリーは、受け取ったコンテキストの buildNavTree からツリーを読みます。ラッパーは、元の引数をすべてネイティブの buildNavTree に渡し、結果だけを変換するコンテキストを 1 つ作ります。同じコンテキストを両方のファクトリーに渡すため、デスクトップのサイドバーとモバイルのドロワーは同じデータを受け取ります。他のセクションでは、変更していない別の組を使います。
import type { HeaderSlotProps, SidebarSlotProps } from "@takazudo/zudo-doc/chrome-bindings";
import type {
ChromeContext,
ChromeHostBindings,
RouteContext,
} from "@takazudo/zudo-doc/factory-context";
import { createHeaderWithDefaults } from "@takazudo/zudo-doc/header-with-defaults";
import { createSidebarWithDefaults } from "@takazudo/zudo-doc/sidebar-with-defaults";
import { EDITORIAL_SECTION, editorialSidebar, toEditorialTree } from "./editorial-tree";
/**
* Header and Sidebar replacements: Reference pages get the editorial tree,
* every other section keeps the native tree. Each pair shares one context,
* so the desktop sidebar and the mobile drawer receive the same data.
*/
export function createEditorialChrome(routeCtx: RouteContext, bindings: ChromeHostBindings) {
const nativeContext: ChromeContext = { ...routeCtx, components: {}, hostBindings: bindings };
const editorialContext: ChromeContext = {
...nativeContext,
hostBindings: {
...bindings,
sidebarsConfig: { ...bindings.sidebarsConfig, [EDITORIAL_SECTION]: editorialSidebar },
},
// Forward every argument so locale, base and href building stay native.
buildNavTree: (...args) => toEditorialTree(routeCtx.buildNavTree(...args)),
};
const native = {
Header: createHeaderWithDefaults(nativeContext),
Sidebar: createSidebarWithDefaults(nativeContext),
};
const editorial = {
Header: createHeaderWithDefaults(editorialContext),
Sidebar: createSidebarWithDefaults(editorialContext),
};
const pairFor = (navSection: string | undefined) =>
navSection === EDITORIAL_SECTION ? editorial : native;
return {
Header: (props: HeaderSlotProps) => pairFor(props.navSection).Header(props),
Sidebar: (props: SidebarSlotProps) => pairFor(props.navSection).Sidebar(props),
};
}ラッパーは lang、currentSlug、currentPath、currentVersion を含むすべての props をそのまま渡します。そのためバージョン付きのページでも / の href が保たれます。ツリーを作った後、パッケージがコピーしたネイティブノードをスラッグで再マップするからです。
バインディングの登録
クロームバインディングのモジュールは、ドキュメントルートと同じシリアライズ可能なペイロードから自前のルートコンテキストを作り、2 つのラッパーを defineChromeBindings で登録します。プロジェクトにすでにあるバインディングはすべて baseBindings に残してください。ラッパーはそれらをそのまま再利用します。
import { routeContext } from "virtual:zudo-doc-route-context";
import { createRouteContext, type RouteContextPayload } from "@takazudo/zudo-doc/route-context";
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
import { createEditorialChrome } from "./navigation/editorial-chrome";
// The project's other bindings. The editorial chrome reuses them unchanged.
const baseBindings = defineChromeBindings({
sidebarsConfig: {
guides: { items: [], scopeRoot: "configured" },
},
});
const routeCtx = createRouteContext(routeContext as unknown as RouteContextPayload);
export const chromeBindings = {
...baseBindings,
...defineChromeBindings(createEditorialChrome(routeCtx, baseBindings)),
};baseBindings の Guides のエントリは通常のフォールバックセクションです。Guides のページはネイティブのツリーを使い、Guides セクションが広げる範囲の上限になります。
両方の組がパッケージの Header を置き換えるため、パッケージが自身のルートで追加するスロットも baseBindings に含める必要があります。特に designTokenPanel: true と designTokenPanelConfigModule を併用するプロジェクトは、DesignTokenPanelBootstrap の境界をそこでバインドしてください。そうしないと、ヘッダーからパネルの切り替えボタンが消えます。
設定
chromeBindingsModule をこのモジュールに向け、各ヘッダー項目に単一セグメントの categoryMatch を指定します。
import { defineConfig } from "zfb/config";
import { zudoDoc } from "@takazudo/zudo-doc/config";
export default defineConfig(
zudoDoc({
siteName: "Acme Docs",
headerNav: [
{ label: "Guides", path: "/docs/guides", categoryMatch: "guides" },
{ label: "Reference", path: "/docs/reference", categoryMatch: "reference" },
],
chromeBindingsModule: "./src/chrome-bindings.tsx",
}),
);ほかのファイルは変わりません。ドキュメントルートはすでに chromeBindings をクロームに渡しています。
ネイティブのまま残るもの
編集上のツリーを見るのは、ラップした 2 つのファクトリーだけです。それ以外は元のルートコンテキストを読み続けます。
パンくずリストは URL のセグメントに従います。Installation では Basics ではなく Reference › Installation と表示されます。
前・次のページャー、検索、サイトマップは URL の順序のままです。
<CategoryNav>は URL のツリーを読みます(次の節を参照)。サイドバーの描画はネイティブのままです。フィルター、開閉、フォーカス枠、ツリーを広げる、復元、保存状態、フォーカス処理、アクセシビリティは他のサイトと同じように動作します。
Reference のすべてのページは同じ基準で同じ編集上のフォレストを描画するため、1 つのナビゲーションコンテキストを共有します。Installation で枝に焦点を絞った読者は、Configuration でもその枝を見ることになります。Guides のページはネイティブのコンテキストを使います。
ランディングページと CategoryNav
<CategoryNav category="reference" /> は URL のツリーにおける reference の子を一覧にします。この例では Basics、Installation、Configuration が同列に並び、Deploying は含まれません。CategoryNav のカードとクロームだけの編集上のツリーは、ナビゲーションデータの別々の利用者です。
編集上の子がディレクトリの子と異なるランディングページでは、editorialMembers と同じ順序でリストを書き、両方を一緒に更新してください。
---
title: Reference
sidebar_position: 2
---
Settings, commands, and deployment notes.
- [Basics](./basics.mdx)
- [Deploying](../guides/deploying.mdx)basics.mdx も同じように Installation と Configuration を列挙します。子がディレクトリと一致するランディングページでは、引き続き <CategoryNav> を使えます。
遷移先の所有
サイドバーのメンバーシップは、ページを所有するセクションを変えません。Deploying は Reference のサイドバーに表示されますが、URL は / で、Guides の categoryMatch に一致します。読者がこのリンクをたどると、Guides のヘッダー項目がアクティブになり、ページは Guides のサイドバーを表示します。
これがこの例の選んだ方針、つまり URL が遷移先を所有する という方針です。Reference にそのページを所有させるのは、ホスト側の別の判断です。ラッパーがそれらのスラッグに別のセクションを選ぶ必要があり、影響するすべてのページでヘッダーの状態とサイドバーを確認しなければなりません。パッケージはこれを自動では行いません。
この例の前提
実際のランディングページ。 編集上の行には、すべて実際のページ(
hasPageとhref)が必要です。生成された自動インデックスの行やcategory_no_pageの見出しに対しては、ヘルパーがエラーを投げます。バージョン管理を有効にしている場合、ヘッダーのバージョン切り替えは設定されたすべてのバージョンについてこのツリーを作ります。そのため、アーカイブされたバージョンも含めて、編集上のセクションに生成された自動インデックスのルートを置かないでください。古いバージョンで自動インデックスになっている編集上の行は、ビルドを失敗させます。存在しないページ。 ロケールやバージョンのツリーに存在しないメンバーは飛ばします。ルートのページが存在しない場合、ヘルパーはネイティブのツリーを返します。
編集上のセクションは 1 つ。 メンバーシップはスラッグがキーなので、同じデータがすべてのロケールとバージョンに使えます。編集上のセクションを複数にする場合は、それぞれにルートを用意し、同じように
navSectionで組を選んでください。
編集上のフォレスト内での表示範囲
渡すフォレストは [Reference] なので、インデックスは URL のセグメントではなく、このフォレストを表します。
[0] Reference
[0, 0] Basics
[0, 1] Deploying{ items: [{ type: "autogenerated" }], initialPath: [0], initialPathMode: "node" } は Reference の行そのものを子と一緒に選びます。フォレストのルートは 1 つなので、この基準がすでに最も広い範囲です。読者が枝に焦点を絞るまで、範囲ツールバーは表示されません。
Installation と Configuration を Basics から表示したい場合は、そのページで node モードの initialPath: [0, 0] が必要になります。つまりページごとに別のサイドバー定義を選び、定義ごとに別のファクトリーの組を作ることになります。読者に明らかな必要がない限り、基準は 1 つにしてください。
ここで { type: "autogenerated", dirName: "reference" } は使わないでください。これは Reference の子を表示して Reference の行を隠すため、[0] は Basics を選びます。
ツリーの確認
公開する前に、実際のツリーに対してヘルパーをテストしてください。ロケールごとにネイティブのツリーを作り、toEditorialTree を呼び、各メンバーの親と子の順序を検証します。セクションのネイティブのページがすべて結果からたどれることも検証してください。editorialMembers の書き間違いや、列挙し忘れた新しいページは、何も言わずにページを落とします。
トラブルシューティング
| 症状 | 原因 | 対処 |
|---|---|---|
| リンクのない見出しの最初の子に、その見出し自身のランディングページがある | ランディングページが合成の type: "category" の中にある | 実際のカテゴリノードを残すか、この手順で実際のページを親にする |
| ランディングページと記事が無関係な同列のリンクとして並ぶ | ディスク上で記事がランディングページの兄弟になっている | ファイルをランディングページのディレクトリへ移すか、その編集上のメンバーにする |
dirName を追加したら親の行が消えた | dirName 付きの autogenerated はそのディレクトリの子だけを挿入する | items: [] か dirName なしの autogenerated を使い、node モードで行を選ぶ |
initialPath が違う行を選ぶ | URL のセグメントではなく、解決済みの表示ツリーを数えている | 表示ツリーをインデックス付きで書き出してから数える |
| デスクトップのサイドバーとモバイルのドロワーが一致しない | Header と Sidebar の片方だけを置き換えたか、別々のコンテキストを渡している | createEditorialChrome のように、同じコンテキストから両方を作る |
| セクションをまたぐリンクを開くと別セクションのサイドバーになる | 遷移先の所有はページの URL に従う | この方針では想定どおり。所有を変えるのはホスト側の意図した判断としてのみ |
関連項目
ナビゲーション構造の設計 — パターンの選び方
サイドバー — サイドバーアイテム、
initialPath、scopeRootヘッダーナビゲーション —
categoryMatchとセクションの所有ホストクロームバインディング —
HeaderとSidebarのスロット