0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

#198 markdown-itで技術用語にアイコン+リンクを自動付与するプラグインを作ってみる 〜 入り口編

0
Posted at

はじめに

ドキュメントを書いていると、「React」「Docker」と書くたびに [React](https://react.dev) と手動でリンクを張り、さらにブランドアイコンも並べたくなります。

ということで、本文に用語を書くだけで公式リンクとアイコンを自動付与する markdown-it プラグインを作ってみようと思いました。


前提:
本記事は「プラグインを作ってみる」連載の 入り口編 です。

markdown-it と接する入口が トークン列をどう走査してリンク化対象のテキストを選ぶか を主題にします。

モチベーション

既存プラグインには「両方備えたもの」がない

VitePress 周辺には似たことをするプラグインが2つあります。

  • vitepress-plugin-glossary …… 用語を自動リンク化する。ただしアイコンは付かない
  • vitepress-plugin-group-icons …… simple-icons のブランドアイコンを自動表示する。ただしコードグループのタブラベル専用で、本文中の用語は対象外

片方がリンク、もう片方がアイコン(しかもタブ専用)で、「本文の技術用語を自動リンク化し、かつブランドアイコンも付ける」ことを1つのパッケージで両立するものがなく、 markdown-it のプラグインを何かしら作ってみたいと思っていたことから作ってみようと思いました。

思想:アイコンはユーザー自身に解決してもらう

ブランドロゴは権利・ライセンスの問題があるため、パッケージ内にアイコンの実体を持つことを避けたいという気持ちがありました(仮に公開する場合、問題になりがち)。

代わりに「Resolver」という1関数で、利用者側にアイコンを解決してもらおうと考えました。

これにより、パッケージが権利対象物を含まずコミュニティ貢献がクリーンになり、利用者が自身で素材源を選べ、バンドルにも利用者が使ったアイコンしか載りません。

また、Resolver を省略しても「テキストのみのリンク」として機能するように設計しました。

全体像

本プラグインを使用すると以下の様な表示になります。

(記載内容としては React などとしているだけです。)

198-example.png

使い方のイメージですが、 markdown-it のインスタンスにプラグインを use するだけです。

import MarkdownIt from 'markdown-it';
import { mdTechLinks } from 'md-tech-links';

const md = new MarkdownIt();
md.use(mdTechLinks()); // デフォルト辞書が効き、アイコンなしでリンク化

本文に「React」「Vue」「Docker」などと書くだけで、それぞれ公式サイトへのリンクに変換されます。

アイコンを付けるなら resolver を、用語を追加・書き換えるなら dictionary を渡すイメージです。

md.use(
  mdTechLinks({
    resolver: createSimpleIconsResolver({ icons: { react: siReact } }), // アイコン
    dictionary: mergeDictionary(defaultDictionary, { add: [...] }),     // 用語の追加
  }),
);

プラグインは3つのモジュールで成り立ちます。下2つが markdown-it に依存しない純粋関数(テキスト層)、上が markdown-it を操作する トークン層 です。

  • plugin.ts(トークン層・markdown-it 依存)…… markdown-it のコアルールとして登録され、パース結果のトークン列を受け取ります。トークン列を 走査 してリンク化対象のテキストを選び、matcher と renderer を呼んでリンクのトークン列を組み立てを行います。
  • matcher(テキスト層)…… テキストから技術用語を検出し、マッチのリストを返します。
  • renderer(テキスト層)…… マッチからリンクの属性(href / class / target 等)・アイコン HTML を生成します。

データは次のように流れます。

markdown-it → パース結果のトークン列
  │
  ▼  plugin.ts(コアルール)← markdown-it との接点 = 入り口
   ・トークン列を走査し、text を選別
   ・matcher(content) で用語検出
   ・buildLinkAttributes(match) で <a> の属性、resolver + renderIconHtml でアイコン
   ・link_open → html_inline → text → html_inline → link_close を組み立て
  │
  ▼
markdown-it → HTML

このデータフローの中心が plugin.ts です。

設計の軸は 「コアはトークン走査に集中し、それ以外は外に委ねる」 ことです。

matcher と renderer をトークン層から切り離し、resolver(アイコン)と dictionary(用語)は利用者がオプションで外から注入するようにします。

この切り離しのおかげで resolver と dictionary を差し替えるだけでアイコン素材や用語セットを環境に合わせられます。

テキスト処理を別のエンジン(remark 等)へ再利用するときも、この構造がそのまま使えるように設計しています。

機能整理

plugin.ts に「どのテキストを走査対象(=リンク化対象)にするか」を判断させます。トークン列を走査する上での判断基準を整理します。

箇所 リンク化するか 理由
段落・リスト・テーブル・引用の本文 する 通常の text トークン
インラインコード `React` しない コードなので別扱い
フェンス/インデントコードブロック しない ブロックが子トークンを持たない
既存リンク [React](url) のテキスト しない <a> のネストになる
生 HTML <a>React</a> しない 同上
画像の alt テキスト しない image トークンで別扱い
見出し # React デフォルトしない オプションで ON 可

「どの text を走査対象にするか」をトークンを見て判定するのが、トークン走査の核心です。

※ 辞書の用語がテキストに含まれていても ReactJSReactを使う ではリンク化しない、といった「検出の精度」は matcher の担当で、本記事(走査)の対象外です。

実装

まず plugin.ts の本体(mdTechLinks 関数)の全体像を示し、その後こまかに解説します。

全体像

export function mdTechLinks(options: MdTechLinksOptions = {}) {
  return (md: MarkdownIt): void => {
    const dictionary = options.dictionary ?? defaultDictionary;
    detectAliasCollisions(dictionary); // エイリアス衝突は初期化時に throw
    const matcher = buildMatcher(dictionary);

    md.core.ruler.push('md-tech-links', (state) => {
      // markdown-it の Token コンストラクタとインスタンス型を core rule 内で取得。
      const TokenConstructor = state.Token;
      type Token = InstanceType<typeof TokenConstructor>;

      const makeText = (content: string): Token => {
        const token = new TokenConstructor('text', '', 0);
        token.content = content;
        return token;
      };

      const makeHtmlInline = (content: string): Token => {
        const token = new TokenConstructor('html_inline', '', 0);
        token.content = content;
        return token;
      };

      /** 1マッチを `link_open` → [`html_inline`] → `text` → [`html_inline`] → `link_close` に展開。 */
      const expandMatch = (match: Match, ctx: PluginContext): Token[] => {
        const linkOpen = new TokenConstructor('link_open', 'a', 1);
        linkOpen.attrs = attrsToArray(buildLinkAttributes(match, ctx.externalLinks));

        const iconHtml = resolveIconHtml(match, ctx);
        const icon = iconHtml ? [makeHtmlInline(iconHtml)] : [];

        return [
          linkOpen,
          ...(ctx.iconPosition === 'before' ? icon : []),
          makeText(match.text),
          ...(ctx.iconPosition === 'after' ? icon : []),
          new TokenConstructor('link_close', 'a', -1),
        ];
      };

      /** text をマッチ境界で分割。安全でない URL のマッチはスキップし元テキストとして残す。 */
      function* expandTextToken(token: Token, ctx: PluginContext): Generator<Token> {
        const content = token.content;
        const matches = ctx.matcher(content).filter((m) => isSafeUrl(m.term.url));
        if (matches.length === 0) {
          yield token;
          return;
        }

        let cursor = 0;
        for (const match of matches) {
          if (match.start > cursor) {
            yield makeText(content.slice(cursor, match.start));
          }
          yield* expandMatch(match, ctx);
          cursor = match.end;
        }
        if (cursor < content.length) {
          yield makeText(content.slice(cursor));
        }
      }

      /** 生 HTML の `<a>` 開始タグなら +1、`</a>` 閉じタグなら -1、それ以外は 0。 */
      const anchorTagDelta = (content: string): number => {
        if (/^<a[\s/>]/.test(content)) return 1;
        if (/^<\/a>/.test(content)) return -1;
        return 0;
      };

      /** トークンがスキップゾーン深さに与える変化(markdown リンク / 生 `<a>` タグ)。 */
      const skipDelta = (token: Token): number => {
        if (token.type === 'link_open') return 1;
        if (token.type === 'link_close') return -1;
        if (token.type === 'html_inline') return anchorTagDelta(token.content);
        return 0;
      };

      /**
       * inline トークン群を走査し、スキップゾーンを管理しながら text をリンク化する。
       * - `code_inline` / `image`: そのまま。
       * - `link_open`..`link_close`(markdown リンク)/ `<a>`..`</a>`(生 HTML): 内部の text を処理しない。
       * - フェンス/インデントコードブロックは inline children を持たないため自然にスキップ。
       */
      function* walkInlineTokens(tokens: Token[], ctx: PluginContext): Generator<Token> {
        let depth = 0;
        for (const token of tokens) {
          depth = Math.max(0, depth + skipDelta(token));
          if (token.type === 'text' && depth === 0) {
            yield* expandTextToken(token, ctx);
          } else {
            yield token;
          }
        }
      }

      /** walkInlineTokens の結果を配列として取り出す。 */
      const processInlineTokens = (tokens: Token[], ctx: PluginContext): Token[] => {
        return Array.from(walkInlineTokens(tokens, ctx));
      };

      const ctx: PluginContext = {
        matcher,
        resolver: options.resolver,
        iconPosition: options.iconPosition ?? 'before',
        externalLinks: options.externalLinks,
        strict: options.strict === true,
      };

      const targetBlocks = state.tokens.filter(
        (block, index, tokens): block is Token & { children: Token[] } => {
          if (block.type !== 'inline' || !block.children) {
            return false;
          }

          const inHeading = tokens[index - 1]?.type === 'heading_open';
          return !(inHeading && options.headings !== true);
        },
      );

      for (const block of targetBlocks) {
        const expanded = processInlineTokens(block.children, ctx);

        block.children.length = 0;
        block.children.push(...expanded);
      }
    });
  };
}

トップレベルは、オプションを受け取って (md) => void を返すファクトリです。

md.use(mdTechLinks(options)) されると、matcher 構築を一度だけ行い、コアルール md-tech-links を登録します。

以降、コアルールの中を上から順に解説します。

Token コンストラクタの取得

const TokenConstructor = state.Token;
type Token = InstanceType<typeof TokenConstructor>;

markdown-it の Token クラスは、公式ドキュメントの import Token from 'markdown-it/lib/token'@types/markdown-it + Bundler 解決では取れないため、実行時に state.Token から取得し InstanceType<typeof> で型を導出します。

makeText / makeHtmlInline はこのコンストラクタで text/html_inline トークンを作る薄いラッパです。

markdown-it のコード上にも似たような記述がコメントされています。

https://github.com/markdown-it/markdown-it/blob/master/lib/rules_core/state_core.mjs#L14-L15

処理対象ブロックの選別

const targetBlocks = state.tokens.filter(
  (block, index, tokens): block is Token & { children: Token[] } => {
    if (block.type !== 'inline' || !block.children) {
      return false;
    }
    const inHeading = tokens[index - 1]?.type === 'heading_open';
    return !(inHeading && options.headings !== true);
  },
);

state.tokens(ブロックトークン列)から処理対象を絞ります。

inlinechildren を持つブロックだけを残し、見出し(直前が heading_open)はデフォルトで除外します。

フェンス/インデントコードブロックはブロックレベルで children を持たないため、ここで自然に弾かれます。

headings: true はプラグインのオプションとして用意している値です。

具体的な入力で動きを見てみます。見出しと本文で扱いが分かれます。

# React 入門

React は UI ライブラリです。

見出し「React 入門」の inline は(デフォルトで)スキップされ、本文の「React」だけがリンク化の対象になります。

PluginContext の構築

走査で使うオプション群を、1つの ctx に正規化してまとめます。

const ctx: PluginContext = {
  matcher,
  resolver: options.resolver,
  iconPosition: options.iconPosition ?? 'before',
  externalLinks: options.externalLinks,
  strict: options.strict === true,
};

matcher は初期化時に構築したものをそのまま載せ、残りのオプションは走査で扱いやすい形に正規化して詰めます。

iconPosition:

  • 省略時は 'before''none' ならアイコンを出さず、resolver の呼び出しもスキップします。

externalLinks:

  • リンク先は外部サイトを想定しているため、デフォルトで新しいタブ(target="_blank")+ noopener noreferrer で安全に開くための属性を制御します。
  • false で属性なし、オブジェクトで任意指定。展開は renderer に委ね、undefined は renderer 側で true 相当として扱われます。

strict:

  • resolver がアイコンを解決できなかったとき、デフォルトは warn、strict: true のときだけ throw します。

オプションの正規化をこの1箇所に集めることで、以降の走査関数(walkInlineTokens / expandTextToken / expandMatch)は ctx を通じてだけオプションを参照し、optionsundefined のハンドリングを各自で書かずに済むようにしています。

走査の核心

インラインのトークン列を見たとき、リンク化してよい text は 「いまリンクの外にいるか」 だけで決めています。

markdown のリンク [..](..)link_open/link_close で囲まれ、生 HTML の <a>html_inline の連続で囲まれ、表現は2通りあります。

しかし走査に必要なのは「リンクの中か外か」の1状態です。

そこで 2つの表現を1つの数値 depth で表現します。リンクの開始で +1、終了で -1 し、depth === 0 の text だけを処理対象にします。

機能整理のスキップ規則をこの1つの仕組みで覆うようにしています。

code_inlineimage は text でないため同じ判定の外(else)に回り、機能整理の残りの規則もこれで揃います。以降は、この depth をどう実装するかの詳細です。

スキップゾーンを管理しながら走査

walkInlineTokens が children を順に走査し、skipDelta で「いま何階層めのリンクの中にいるか」を追跡しながら text を処理対象に選びます。

const anchorTagDelta = (content: string): number => {
  if (/^<a[\s/>]/.test(content)) return 1;
  if (/^<\/a>/.test(content)) return -1;
  return 0;
};

const skipDelta = (token: Token): number => {
  if (token.type === 'link_open') return 1;
  if (token.type === 'link_close') return -1;
  if (token.type === 'html_inline') return anchorTagDelta(token.content);
  return 0;
};

function* walkInlineTokens(tokens: Token[], ctx: PluginContext): Generator<Token> {
  let depth = 0;
  for (const token of tokens) {
    depth = Math.max(0, depth + skipDelta(token));
    if (token.type === 'text' && depth === 0) {
      yield* expandTextToken(token, ctx);
    } else {
      yield token;
    }
  }
}

skipDelta は核心で述べた「開始で +1、終了で −1」を、トークン種ごとに返す関数です。

link_open/link_close はそのまま +1/−1、html_inline は中身を anchorTagDelta<a> 開始/終了タグか判定して +1/−1。2つの表現ルートをこの1関数に束ねており、将来スキップ要素が増えても skipDelta の分岐を足すだけで済みます。

Math.max(0, ...) は、生 HTML 混在等で </a> 単独が現れて depth が負に落ちたとき、後続の text が誤って処理対象になりネスト <a> を生むのを防ぐ安全策です。

具体的な入力で depth の動きを追ってみます。

[React 公式](https://react.dev) と Vue を使う
  • 「React 公式」は link_openlink_close の中(depth > 0)→ そのまま(<a> のネストを抑制)
  • 「Vue」はリンクの外(depth === 0)→ リンク化

生 HTML の <a> も同じ depth で追います。

<a href="...">React 詳解</a> より Vue が好き
  • <a>depth +1、「React 詳解」は depth > 0 → そのまま
  • </a>depth -1 に戻り、「Vue」は depth === 0 → リンク化

Math.max(0, ...) は、閉じタグだけ現れるようなアンバランスな入力を守ります。

</a> React のように </a> 単独が現れたとき、クランプが無いと depth が -1 に落ちて「React」が depth === 0 を満たさなくなり、リンク化されなくなってしまいます。

text をマッチ境界で分割

走査で選んだ text を matcher に渡し、マッチ境界で分割します。

function* expandTextToken(token: Token, ctx: PluginContext): Generator<Token> {
  const content = token.content;
  const matches = ctx.matcher(content).filter((m) => isSafeUrl(m.term.url));
  if (matches.length === 0) {
    yield token;
    return;
  }
  let cursor = 0;
  for (const match of matches) {
    if (match.start > cursor) {
      yield makeText(content.slice(cursor, match.start));
    }
    yield* expandMatch(match, ctx);
    cursor = match.end;
  }
  if (cursor < content.length) {
    yield makeText(content.slice(cursor));
  }
}

matcher(content) で用語を検出し、isSafeUrlurl モジュール)で http/https 以外の危なそうな URL を一応弾きます。

安全でないマッチはここで消え、元のテキストとして残ります。

cursor を進めながら「マッチ前の text → リンク列 → … → 末尾 text」に分割し、それぞれを yield します。

例えば React と Vue を使う という text から matcher が ReactVue を返した場合には、cursor を進めながら次のように分割します。

text → リンク(React) → text「 と 」 → リンク(Vue) → text「を使う」

リンクのトークン列を組み立て

1つのマッチを link_open → [html_inline] → text → [html_inline] → link_close の正規トークン列に展開します。

const expandMatch = (match: Match, ctx: PluginContext): Token[] => {
  const linkOpen = new TokenConstructor('link_open', 'a', 1);
  linkOpen.attrs = attrsToArray(buildLinkAttributes(match, ctx.externalLinks));
  const iconHtml = resolveIconHtml(match, ctx);
  const icon = iconHtml ? [makeHtmlInline(iconHtml)] : [];
  return [
    linkOpen,
    ...(ctx.iconPosition === 'before' ? icon : []),
    makeText(match.text),
    ...(ctx.iconPosition === 'after' ? icon : []),
    new TokenConstructor('link_close', 'a', -1),
  ];
};

buildLinkAttributes(renderer)が href / class / target / rel / title を作り、attrsToArray がそれを markdown-it の attrs 配列に変換します。

アイコンは iconPosition(オプション)` 次第で前後に置けるよう柔軟性を持たせています。

生 HTML 1塊ではなく link_open/text/link_close に分けるのは、html: false 環境でもリンクが機能し、他プラグインと協調できるようにしたからです。

expandMatch が呼ぶ resolveIconHtml / attrsToArraymdTechLinks の外にあるヘルパーです。

resolveIconHtml は resolver を呼んで renderIconHtml(renderer)で HTML を作り、未解決なら strict で throw・ otherwise warn して空文字を返します。

attrsToArray は属性オブジェクトを [key, value][] に変換する薄い関数です。

本記事ではプラグインの入り口(トークン走査)に絞ったため、renderer や resolver、辞書といったテキスト層の実装は次回扱います。

おわりに

本記事では、プラグインの入り口である plugin.ts がトークン列をどう走査するかを整理・解説しました。

パース結果を受け取り、targetBlocks で処理対象ブロックを絞り、walkInlineTokens でスキップゾーンの深さを追いながら text を選別し、expandTextToken/expandMatch でリンクのトークン列に組み立てます。

コード内・既存リンク内・見出しといった「リンク化してはいけない箇所」を、トークンの種類と1つの depth で漏れなく弾くのが肝でした。

次回は、本記事で「外に委ねた」テキスト層を掘り下げます。
ここまで読んでいただきありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?