SUZ-LAB

用語を一箇所で定義し、本文へ自動リンクする — サイト横断の 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 の語は語境界で照合MissionCommission に一致させない)。
  • 非 ASCII(日本語)の語はそのまま部分一致(語境界の概念が薄いため)。
  • 長い語を優先LogsLog より先に照合する)。
  • 1 描画単位あたり、語ごと・リンク先ごとに初出の 1 回だけ(過剰リンク回避)。
  • 現在ページ自身への自己リンクは張らない

MissionCommission を撃ち分けるのは、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 をリンクにしたら台無し)。walkinSkip フラグを伝播させる。
  • 本文が既に手書きでリンクしている飛び先は、辞書からは張らない。ツリーを先に一巡して既存 <a>hrefusedHrefs に seed しておき、同じページへの重複リンクを避ける(手書き優先)。

テキストノードは、辞書の語で「テキスト → <a> → テキスト → …」のノード列に分割して差し替える。何もリンクしなければ null を返して元のまま残す。

4. 取り込み口

  • 構造化コンテンツ/roadmap/vmost の各セクション)は RichTextlinkTerms の結果を描画に使い、明示リンクと辞書リンクをマージ(self で自己リンク抑止)。
  • Markdown 記事logs / topics)は MDXRemote のパイプラインに rehypeGlossary({ self }) を足すだけ。

5. 同じ 1 エントリが、AI 向けの語彙集も兼ねる

ここが設計上いちばん気に入っている点だ。description内部リンクには使わない——けれど捨てずに持っておく。この一行定義が、AI 向けに 2 つの機械可読な出力を同時に賄う。

  • JSON-LDDefinedTermSetlib/structured-data.ts)。各用語が SUZ-LAB で何を指すか+正規 URL を schema.org 語彙で宣言する。
  • 公開 MCPget_glossary公開 MCP サーバの回)。AI が本文を読む前の「語彙合わせ」に使う。

つまり content/glossary.ts の 1 エントリが、人間向けの内部リンクJSON-LD の用語集MCP の語彙 API の 3 つの消費者を、1 つの出所から養う。用語を 1 行足すだけで、3 経路すべてが更新される。

ハマったところ・判断

  • 過剰リンクをどう抑えるかが本質だった。素朴に「見つけたら全部リンク」だと 1 段落が青字だらけになる。usedTerms / usedHrefs の 2 段の重複排除(語でも・飛び先でも初出 1 回)と、self 抑止、code/pre/既存 <a> のスキップを重ねて、ようやく「ちょうどいい」密度になった。
  • LogLogs の食い込みは longest-first ソートで解決したが、これは「照合順」の話であって「検索位置」の話ではない。rehype 側では各テキストで「最も手前に出る語・同位置なら長い語」を毎回選び直す必要があり、firstTermIndex を共有することで構造化側と挙動を揃えた。
  • 依存を足すか自前で書くか。hast 走査は unist-util-visit を入れれば数行だが、この 1 用途のために依存を増やすより、単純な再帰 walk を書くほうが読みやすいと判断した。

検証

  • Commission を含む文で Mission がリンクされないこと、LogsLog に食われず正しく /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 経路の食い違いを構造的に防いだ。
#Next.js#TypeScript#rehype#Markdown#情報設計#単一の出所#AI#SEO

同じ柱のほかのログ

この柱の Roadmap を見る