# 用語を一箇所で定義し、本文へ自動リンクする — サイト横断の 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）を同時に賄う設計を、実際のコードで残します。

- 出典: SUZ-LAB（実験ログ / 一次情報）
- URL: https://suz-lab.co.jp/logs/cross-site-glossary-and-auto-linking
- 公開: 2026-07-09
- 更新: 2026-07-09
- 著者: Hiro（主任研究員・https://suz-lab.co.jp/researchers/hiro・X: https://x.com/suz_lab_hiro）
- 柱: テクノロジー
- タグ: #Next.js #TypeScript #rehype #Markdown #情報設計 #単一の出所 #AI #SEO
- カバー画像: https://suz-lab.co.jp/assets/logs/cross-site-glossary-and-auto-linking.png?v=5c578c70

---


## 何が問題だったか

サイトが育つと、本文のあちこちに同じ概念語が出てくる——「VMOST」「Mission」「Roadmap」「ドッグフーディング」。読者（と AI）にとっては、その語が SUZ-LAB で何を指すのか、正規のページへ 1 クリックで飛べると親切だ。回遊性も上がるし、内部リンクは SEO にも効く。

しかし、これを**毎回手でリンクするのは現実的でない**。

- 書くたびにリンクを張り忘れる（漏れる）。
- 逆に律儀に全部張ると、同じ段落に「VMOST」が 5 回出てきて 5 回リンクされ、**過剰リンクで読みにくくなる**。
- 用語の定義や飛び先 URL が本文のあちこちに散らばり、変えるときに全部を追えない。

欲しいのは、**用語の「定義」と「正規 URL」を 1 か所に集めた唯一の出所**と、それを本文へ**適度に**（過剰にならず）自動リンクする取り込み層だ。

## やってみたこと

### 1. 唯一の出所 — content/glossary.ts

用語辞書を 1 ファイルにまとめた。ここに 1 行足すだけで、後述する 2 経路（構造化コンテンツと Markdown 記事）の両方に反映される。

```ts
// 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 で語境界を要求する正規表現でやっている。

```ts
// 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` が食い込まない。

```ts
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 点。

```ts
// 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 サーバの回](/logs/building-the-public-mcp-server)）。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 経路の食い違いを構造的に防いだ。

