用語を一箇所で定義し、本文へ自動リンクする — サイト横断の glossary を作った
本文に出てくる概念語(VMOST・Mission・Roadmap・ドッグフーディング…)を、その用語の正規ページへ自動でリンクする仕組みを作った記録。用語辞書を content/glossary.ts 一本の唯一の出所にし、構造化コンテンツ用の照合ロジック(lib/link-terms)と Markdown 記事用の rehype プラグイン(依存ゼロで hast を自前走査)の 2 経路で取り込む。ASCII は語境界・非 ASCII は部分一致・長い語優先・描画単位ごと初出 1 回・自己リンク抑止という「過剰リンクを避ける」方針をどう実装したか、そして同じ 1 エントリが人間向けの内部リンクと AI 向けの語彙集(JSON-LD の DefinedTermSet・MCP の get_glossary)を同時に賄う設計を、実際のコードで残します。
何が問題だったか
サイトが育つと、本文のあちこちに同じ概念語が出てくる——「VMOST」「Mission」「Roadmap」「ドッグフーディング」。読者(と AI)にとっては、その語が SUZ-LAB で何を指すのか、正規のページへ 1 クリックで飛べると親切だ。回遊性も上がるし、内部リンクは SEO にも効く。
しかし、これを毎回手でリンクするのは現実的でない。
- 書くたびにリンクを張り忘れる(漏れる)。
- 逆に律儀に全部張ると、同じ段落に「VMOST」が 5 回出てきて 5 回リンクされ、過剰リンクで読みにくくなる。
- 用語の定義や飛び先 URL が本文のあちこちに散らばり、変えるときに全部を追えない。
欲しいのは、用語の「定義」と「正規 URL」を 1 か所に集めた唯一の出所と、それを本文へ適度に(過剰にならず)自動リンクする取り込み層だ。
やってみたこと
1. 唯一の出所 — content/glossary.ts
用語辞書を 1 ファイルにまとめた。ここに 1 行足すだけで、後述する 2 経路(構造化コンテンツと Markdown 記事)の両方に反映される。
// content/glossary.ts
export type GlossaryEntry = {
href: string; // リンク先(#anchor 付きも可)
terms: readonly [string, ...string[]]; // 表記ゆれ。先頭を正規表記(型で 1 件保証)
description?: string; // 一行定義(AI 向け語彙集で使う)
};
export const GLOSSARY: readonly GlossaryEntry[] = [
{
href: "/topics/vmost",
terms: ["VMOST"],
description:
"理念(Vision)から日々の行動(Tactics)までを一本の線でつなぐ 5 階層の戦略フレームワーク。…",
},
{ href: "/vmost#mission", terms: ["Mission"], description: "Vision に近づくために果たす役割。…" },
{ href: "/logs", terms: ["Logs", "Log"], description: "…実際にやってみたことの時系列の一次情報。" },
// …
] as const;
terms を配列にしたのは表記ゆれ(Logs / Log)を 1 つの飛び先に束ねるため。先頭を正規表記として扱うので、型 [string, ...string[]] で「最低 1 件」を保証している。
2. 照合ロジック — lib/link-terms.ts
「本文のどこに、どの用語が出るか」を判定する中核をここに集約した。方針はコメントのとおり。
- ASCII の語は語境界で照合(
MissionはCommissionに一致させない)。 - 非 ASCII(日本語)の語はそのまま部分一致(語境界の概念が薄いため)。
- 長い語を優先(
LogsをLogより先に照合する)。 - 1 描画単位あたり、語ごと・リンク先ごとに初出の 1 回だけ(過剰リンク回避)。
- 現在ページ自身への自己リンクは張らない。
Mission と Commission を撃ち分けるのは、ASCII 語だけ lookbehind / lookahead で語境界を要求する正規表現でやっている。
// lib/link-terms.ts
export function firstTermIndex(text: string, term: string): number {
if (!isAsciiTerm(term) || !hasAsciiWordChar(term)) {
return text.indexOf(term); // 日本語などは部分一致
}
const re = new RegExp(`(?<![A-Za-z0-9])${escapeRegExp(term)}(?![A-Za-z0-9])`);
const match = re.exec(text);
return match ? match.index : -1;
}
「長い語優先」は、用語をフラットに展開したうえで語の長さの降順にソートしておくことで実現する。こうすれば Logs を先に照合でき、Log が食い込まない。
export const TERM_LINKS: readonly TermLink[] = GLOSSARY.flatMap((entry) =>
entry.terms.map((term) => ({ term, href: entry.href })),
).sort((a, b) => b.term.length - a.term.length);
linkTerms(text, self) は、usedTerms / usedHrefs の 2 つの Set で「語ごと・リンク先ごとに初出 1 回」を守り、self(現在ページ)と同一パスの飛び先は除外する(自己リンク判定は # より前のパスで行う)。この結果を、構造化コンテンツ側の RichText にそのまま渡せる。
3. Markdown 記事用の rehype プラグイン — lib/rehype-glossary.ts
Log と Topic は Markdown を MDXRemote で描画する。こちらは HTML 抽象木(hast)に対して同じ方針でリンクを張る。ポイントは unist-util-visit を足さず、hast を自前で歩くこと——依存を増やさない方針に合わせた(走査自体は単純な再帰で足りる)。
同じ firstTermIndex を共有するので、構造化コンテンツと Markdown で「照合」と「分割」の判定が必ず一致する。rehype 側で特に気をつけたのは 2 点。
// lib/rehype-glossary.ts
// リンクを張らない領域: 既存リンク・コード・整形済みブロック
const SKIP_TAGS = new Set(["a", "code", "pre"]);
- 既存の
<a>/code/preの中には張らない(コード中のLogをリンクにしたら台無し)。walkでinSkipフラグを伝播させる。 - 本文が既に手書きでリンクしている飛び先は、辞書からは張らない。ツリーを先に一巡して既存
<a>のhrefをusedHrefsに seed しておき、同じページへの重複リンクを避ける(手書き優先)。
テキストノードは、辞書の語で「テキスト → <a> → テキスト → …」のノード列に分割して差し替える。何もリンクしなければ null を返して元のまま残す。
4. 取り込み口
- 構造化コンテンツ(
/roadmap・/vmostの各セクション)はRichTextがlinkTermsの結果を描画に使い、明示リンクと辞書リンクをマージ(selfで自己リンク抑止)。 - Markdown 記事(
logs/topics)は MDXRemote のパイプラインにrehypeGlossary({ self })を足すだけ。
5. 同じ 1 エントリが、AI 向けの語彙集も兼ねる
ここが設計上いちばん気に入っている点だ。description は内部リンクには使わない——けれど捨てずに持っておく。この一行定義が、AI 向けに 2 つの機械可読な出力を同時に賄う。
- JSON-LD の
DefinedTermSet(lib/structured-data.ts)。各用語が SUZ-LAB で何を指すか+正規 URL を schema.org 語彙で宣言する。 - 公開 MCP の
get_glossary(公開 MCP サーバの回)。AI が本文を読む前の「語彙合わせ」に使う。
つまり content/glossary.ts の 1 エントリが、人間向けの内部リンク・JSON-LD の用語集・MCP の語彙 API の 3 つの消費者を、1 つの出所から養う。用語を 1 行足すだけで、3 経路すべてが更新される。
ハマったところ・判断
- 過剰リンクをどう抑えるかが本質だった。素朴に「見つけたら全部リンク」だと 1 段落が青字だらけになる。
usedTerms/usedHrefsの 2 段の重複排除(語でも・飛び先でも初出 1 回)と、self抑止、code/pre/既存<a>のスキップを重ねて、ようやく「ちょうどいい」密度になった。 LogとLogsの食い込みは longest-first ソートで解決したが、これは「照合順」の話であって「検索位置」の話ではない。rehype 側では各テキストで「最も手前に出る語・同位置なら長い語」を毎回選び直す必要があり、firstTermIndexを共有することで構造化側と挙動を揃えた。- 依存を足すか自前で書くか。hast 走査は
unist-util-visitを入れれば数行だが、この 1 用途のために依存を増やすより、単純な再帰walkを書くほうが読みやすいと判断した。
検証
Commissionを含む文でMissionがリンクされないこと、LogsがLogに食われず正しく/logsを指すことを実本文で確認。- 同じ用語が 1 記事に複数回出ても、リンクは初出の 1 回だけ。現在ページ自身へのリンクは張られない。
- コードブロック(
```ts)や既存の手書きリンク内の用語がリンク化されないこと。 - 用語を 1 行足すと、
/roadmapの本文・Markdown 記事・/llms.txtの Glossary セクション・MCP のget_glossary・JSON-LD のDefinedTermSetに同時に反映されること。
学び
- 「定義」は 1 か所に置き、消費者を増やす。 同じ 1 エントリが人間リンク・JSON-LD・MCP を賄う構造にできたのは、辞書を「表示のためのデータ」ではなく「意味の出所」として設計したから。用語 1 行の追加コストで、3 経路が同期する。
- 自動リンクは「張ること」より「張りすぎないこと」が難しい。 初出 1 回・自己抑止・スキップ領域という制約の積み重ねが、読みやすさを決める。ルールは glossary.ts のコメントに宣言し、実装(link-terms / rehype)はそれに従うだけにした。
- 判定ロジックは 1 本にして共有する。 構造化コンテンツと Markdown で別々に照合を書くと、いつか挙動がずれる。
firstTermIndexを唯一の判定関数にして、2 経路の食い違いを構造的に防いだ。




