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?

#201 markdown-itで技術用語にアイコン+リンクを自動付与するプラグインを作ってみる 〜 テキスト層編

0
Posted at

はじめに

前提:
本記事は「プラグインを作ってみる」の テキスト層編 です。
前回(入り口編)では markdown-it との接点として、トークン列を走査してリンク化対象の text を選ぶところを解説しました。
本記事はその続きで、選ばれた text に対して 用語をどう検出し、リンクとアイコンをどう組み立てるか を主題にします。
いずれも markdown-it に依存しない純粋関数の層(テキスト層)です。

前回からのおさらい

前回の記事では、mdTechLinks の本体を示したときに matcherrenderer を「外にある」として読み飛ばしました。
前回のコードから今回解説する、テキスト層にあたる行だけ拾うと次のとおりです。

export function mdTechLinks(options: MdTechLinksOptions = {}) {
  return (md: MarkdownIt): void => {
    const dictionary = options.dictionary ?? defaultDictionary; // ← 辞書(今回)
    detectAliasCollisions(dictionary);                          // ← 辞書(今回)
    const matcher = buildMatcher(dictionary);                   // ← matcher(今回)

    md.core.ruler.push('md-tech-links', (state) => {
      // ...(前回:トークン走査・スキップゾーン管理)

      const matches = ctx.matcher(content)                      // ← matcher(今回・走査の途中で呼ばれる)
        .filter((m) => isSafeUrl(m.term.url));                  // ← URL 検証(今回)

      // ...(前回:マッチ境界で text を分割)

      linkOpen.attrs = attrsToArray(
        buildLinkAttributes(match, ctx.externalLinks),          // ← renderer(今回)
      );
      const iconHtml = resolveIconHtml(match, ctx);             // ← resolver + renderer(今回)

      // ...(前回:link_open → icon → text → link_close を組み立て)
    });
  };
}

テキスト層の全体像

まずテキスト層の構成要素と、その土台となる型を示します。

辞書 (dictionary)                … 用語データ(YAML → 生成された TechTerm[])
   │
   ▼
matcher                          … text から用語を検出し Match[] を返す
   │
   ▼
renderer                         … Match から <a> の属性・アイコン HTML を作る
   │                                (アイコン実体の解決は resolver に委ねる)
   ▼
plugin.ts(トークン層・前回)    … Match[] をトークン列に組み立てる

中心になるデータ型はこれらの3つです。

/** 用語の定義。辞書の1エントリ。 */
interface TechTerm {
  name: string;              // 正規名(identity)
  aliases?: string[];        // 検出対象文字列。省略時は [name]
  icon?: string;             // 抽象アイコン ref
  url: string;               // リンク先
  caseInsensitive?: boolean; // 大小文字を区別しないか。デフォルト false
  category?: string;
}

/** テキスト上の1マッチ。matcher が出力する。 */
interface Match {
  start: number;  // 元テキスト内の開始インデックス(0 始まり・包含)
  end: number;    // 終了インデックス(排他)
  text: string;   // マッチした元テキスト。alias ≠ name でも書き換えない
  term: TechTerm; // 対応する用語。リンク先・アイコン解決のソース
}

/** 抽象 icon ref からアイコン実体を解決する同期関数。 */
type IconResolver = (ref: string, term: TechTerm) => IconResolverResult;

前回の記事でも説明していますが、今回特に気を使ったのがアイコン系の扱いで、各用語のアイコンの設定は Resolver へ丸投げします。
こうすることで、パッケージはアイコンの実体(権利・ライセンスの対象物)を一切持たなくて済みます。

このあとはデータの流れに沿って、辞書 → matcher → renderer → resolver の順に見ていきます。

辞書

YAML 1ファイル = 1用語

辞書は YAML で管理し、ビルド時に TS のコードとして出力(生成)します。

# src/dictionary/terms/typescript.yml
name: TypeScript
aliases:
  - TypeScript
  - TS
icon: typescript
url: https://www.typescriptlang.org/
category: language
# src/dictionary/terms/go.yml
name: Go
aliases:
  - Go
icon: go
url: https://go.dev/
category: language

YAML を採用したのは、「PRのハードルを下げるには?」というところを想定してみたかったというのが理由です。

こうすることで、用語の追加については TS への知識が不要で特定のディレクトリに特定の形式でファイル(YAML)を追加するのみで運用できます。

export const TechTermSchema = z.object({
  name: z.string().min(1),
  aliases: z.array(z.string().min(1)).optional(),
  icon: z.string().optional(),
  url: z.string().min(1).refine(isSafeUrl, { message: 'must be a valid http(s) URL' }),
  category: z.string().optional(),
  caseInsensitive: z.boolean().optional(),
});

YAML はそのままランタイムに読ませず、ビルド時に Zod スキーマで検証し、 TechTerm[] として TS のコードを生成するようにしています。

こうすることで、自由入力性の高い YAML 定義をCIでチェックしつつ、安全性の高い定義を生成するようにしています。

用語の利用者側管理機能

これは便利機能程度ですが、利用者が用語の 削除・上書き・追加 を行いたい場合の API を用意してみました。

適用順序は remove → override → add で、add は同名の既存用語を上書きしないようにしています(デフォルト辞書を壊さないため)。

const myDictionary = mergeDictionary(defaultDictionary, {
  // デフォルトの辞書から用語を消す
  remove: ['Go'],
  // デフォルト辞書の上書き
  override: [{ name: 'React', url: 'https://ja.react.dev', /* … */ }],
  // 辞書の追加
  add: [{ name: 'MyInternalTool', url: 'https://wiki.example.com/my-tool', icon: '' }],
});

md.use(mdTechLinks({ dictionary: myDictionary }));
// 用語の 削除・上書き・追加 を行うAPI
export function mergeDictionary(base: TechTerm[], ops: MergeDictionaryOptions = {}): TechTerm[] {
  const byName = new Map(base.map((term) => [term.name, term]));

  for (const name of ops.remove ?? []) {
    byName.delete(name);
  }
  for (const term of ops.override ?? []) {
    byName.set(term.name, term);
  }
  for (const term of ops.add ?? []) {
    if (!byName.has(term.name)) byName.set(term.name, term);
  }

  return [...byName.values()];
}

エイリアス衝突は初期化時にエラーとする

辞書が大きくなると「TS が TypeScript と別の用語(仮に Titanium Studio とでも)に割り当てられる」事故が起こりえます。
alias → term が一意でないと「どちらの URL でリンク化するか」が決められないため、初期化時に throw するようにしています。

type AliasEntry = { term: TechTerm; alias: string };

export function detectAliasCollisions(terms: TechTerm[]): void {
  const entriesByLower = new Map<string, AliasEntry[]>();

  for (const term of terms) {
    for (const alias of term.aliases ?? [term.name]) {
      const entry: AliasEntry = { term, alias };
      const lower = alias.toLowerCase();
      const group = entriesByLower.get(lower);
      if (group === undefined) {
        entriesByLower.set(lower, [entry]);
        continue;
      }

      const conflict = group.find((existing) => isConflicting(existing, entry));
      if (conflict) throw collisionError(conflict, entry);
      group.push(entry);
    }
  }
}

function isConflicting(existing: AliasEntry, entry: AliasEntry): boolean {
  if (existing.term.name === entry.term.name) return false;
  const eitherIgnoresCase =
    existing.term.caseInsensitive === true || entry.term.caseInsensitive === true;
  return eitherIgnoresCase || existing.alias === entry.alias;
}

この検査はビルド時・プラグイン初期化時の両方で走り、引っかかると以下のような表示になります。

2608-failed.png

matcher

matcher は「text 内のどこが用語か」を判定します。前回の記事で「検出の精度は matcher の担当」と述べた部分です。

どういう検出にしたいのか表で整理しました。

内容
独立した単語として書かれたときだけリンク化する(部分文字列 ≠ 言及) React と Vue を使うReactVue
ReactJS の中の ReactReactを使うReact
大小文字は区別する。誤検出は検出漏れより重いので、疑わしいものは拾わない React
react(一般英単語。caseInsensitive で緩和可)

隣が文字か Unicode で判定する

英単語のみの判定については /\bReact\b/ を使用すれば良いですが、CJKを含む文字については /\bReact\b/ を使用できません。
JavaScript の \bASCII の \w[A-Za-z0-9_])基準の境界 なので、「React」と「使」の間も \b が成立し、Reactを使う のような単語にヒットしてしまいます。

対象 text /\bReact\b/ の挙動 期待する挙動
I use React マッチ マッチ(空白で区切られている)
ReactJS 不一致 不一致(後ろが英字に接着)
Reactを使う マッチしてしまう 不一致(日本語に接着)

しかし冒頭の整理表からすると、CJK に隣接した React は弾きたい要素の1つです。

そこで、用語の直前・直後が Unicode の Letter(\p{L})でも Number(\p{N})でもないこと を要求する形にしました。

参考:https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape

function compileCandidate(term: TechTerm, alias: string): Candidate {
  const pattern = `(?<![\\p{L}\\p{N}])${escapeRegExp(alias)}(?![\\p{L}\\p{N}])`;
  const flags = term.caseInsensitive ? 'giu' : 'gu';
  return { term, re: new RegExp(pattern, flags) };
}

このルールで、Reactを使う の「使」は Letter なので後読みが成立せず弾かれ、ReactJS の「J」も Letter なので同じく弾かれます。

※ 一方で空白・句読点・括弧・文字列端は Letter でも Number でもないのでヒットします。

全列挙して左から順に選ぶ

エイリアスを張ると複数候補が重なることがあります。
それらを解消するため、候補ごとに matchAll で全出現を列挙・ソートしてから左から採用する形にしています。

たとえば辞書に ReactReact Native があり、text が React と React Native であった場合など...

export function buildMatcher(dictionary: TechTerm[]): (text: string) => Match[] {
  const candidates = dictionary.flatMap((term) => {
    const aliases = term.aliases ?? [term.name];
    return aliases.map((alias) => compileCandidate(term, alias));
  });

  return (text: string): Match[] => {
    if (text.length === 0 || candidates.length === 0) return [];

    // 1. 各候補の全出現を列挙(境界条件済み)。
    const raw: Match[] = [];
    for (const { term, re } of candidates) {
      for (const m of text.matchAll(re)) {
        raw.push({ start: m.index, end: m.index + m[0].length, text: m[0], term });
      }
    }

    // 2. 左→右のためのソート: 開始位置昇順。
    raw.sort((a, b) => a.start - b.start);

    // 3. 左から順に選択: cursor 以降で最も早い開始位置の候補を採用し、cursor を終端へ進める。
    const result: Match[] = [];
    let cursor = 0;
    for (const match of raw) {
      if (match.start >= cursor) {
        result.push(match);
        cursor = match.end;
      }
    }
    return result;
  };
}

なお、ReactReact Native のように同じ位置から始まる候補では、今の実装はソートで先に来た方を勝たせてしまい、結果が辞書の並び順に左右されます。
字句解析でいう maximal munch(重なったら最長を取る)を選べるようにしたい、というのが今後の課題です。

renderer:属性とアイコン HTML

matcher が「どこを」決めたら、renderer が「どのような <a> を」作るかを決めます。入出力は文字列とプレーンなオブジェクトだけで、DOM も markdown-it も現れません。

リンクの属性

export function buildLinkAttributes(
  match: Match,
  externalLinks: boolean | ExternalLinksConfig | undefined,
): LinkAttributes {
  const { target, rel } = resolveExternalAttrs(externalLinks);
  const title = match.text !== match.term.name ? match.term.name : undefined;
  return {
    href: match.term.url,
    class: LINK_CLASS,
    ...(target !== undefined && { target }),
    ...(rel !== undefined && { rel }),
    ...(title !== undefined && { title }),
  };
}

外部リンク属性の正規化は resolveExternalAttrs に閉じ込めています。

リンク先は外部サイトを想定しているため、デフォルトで target="_blank"rel="noopener noreferrer" を付けています。

export function resolveExternalAttrs(externalLinks: boolean | ExternalLinksConfig | undefined) {
  if (externalLinks === false) return {};
  if (externalLinks === undefined || externalLinks === true) {
    return { target: '_blank', rel: 'noopener noreferrer' };
  }
  const { target, rel } = externalLinks;
  return { ...(target !== undefined && { target }), ...(rel !== undefined && { rel }) };
}

アイコン HTML

アイコンは Resolver の結果(後述)を renderIconHtml で HTML にします。Resolver が返すのは次の4種です。

export type IconResolverResult =
  | { kind: 'inline-svg'; html: string }       // インライン SVG
  | { kind: 'img'; src: string; alt?: string } // <img> で参照する外部画像
  | { kind: 'emoji'; char: string }            // 絵文字1文字
  | { kind: 'none' };                          // 解決できなかった

class には常に md-tech-link が付きます。

export function renderIconHtml(result: IconResolverResult): string {
  switch (result.kind) {
    case 'inline-svg':
      return `<span class="md-tech-link__icon">${result.html}</span>`;
    case 'img':
      return `<span class="md-tech-link__icon"><img src="${escapeAttr(result.src)}" alt="${escapeAttr(result.alt ?? '')}"/></span>`;
    case 'emoji':
      return `<span class="md-tech-link__icon">${result.char}</span>`;
    case 'none':
      return '';
  }
}

なお、Resolver が解決に失敗したときの warn / throw は renderer の外にあります。

resolver:アイコン解決の外部化

「入り口編」で述べた内容の実装部です。パッケージはアイコンの実体を持たず、IconResolver という1関数を利用者が注入します。

export type IconResolver = (ref: string, term: TechTerm) => IconResolverResult;

ref は辞書の icon フィールドの値 不透明な文字列 として扱います。

「どう解釈するか」は Resolver 側に丸投げで、TechTerm 側にヒントを持たせません。Resolver を差し替えるだけでアイコンの素材源全体を入れ替えられます。

パッケージ側には便宜のため simple-icons 向けの Resolver ファクトリを同梱しています。

simple-icons Resolver

export interface SimpleIconLike {
  svg: string;
}

export function createSimpleIconsResolver(options: SimpleIconsResolverOptions): IconResolver {
  const { icons } = options;
  return (ref) => {
    if (!ref) return { kind: 'none' };
    const icon = icons[ref];
    if (!icon) return { kind: 'none' };
    return { kind: 'inline-svg', html: icon.svg };
  };
}
import { siReact, siVuedotjs } from 'simple-icons';
const resolver = createSimpleIconsResolver({ icons: { react: siReact, vue: siVuedotjs } });

simple-icons は3,000個超のアイコンを持っていて、全量 import するとバンドルが数十 KB 肥大化します。そこで「どのアイコンを使うか」を利用者の named import に委ねて、icons マップに載ったものだけがバンドルに載るようにしました。

自作する

Resolver はただの関数なので、ファクトリを使わずに自作するのも簡単です。icon ref(辞書の icon フィールドの値)を受け取り、IconResolverResult を返す関数を書くだけです。

// icon ref → 絵文字の対応を引いた、最小の自作 Resolver
const emojiResolver: IconResolver = (ref) => {
  // 絵文字は Unicode エスケープで記述
  const table: Record<string, string> = { react: '\u269B\uFE0F', vue: '\u{1F49A}' };
  const char = table[ref];
  return char ? { kind: 'emoji', char } : { kind: 'none' };
};

md.use(mdTechLinks({ resolver: emojiResolver }));

対応しない ref では { kind: 'none' } を返します。none は失敗ではなく「アイコンなしリンク」への劣化を表し、リンク自体は生きます。

ここまでがテキスト層の実装です。辞書 は YAML から生成され、matcher が Unicode 境界で用語を検出し、renderer<a> とアイコン HTML を組み立て、resolver だけがアイコンの実体を知っている。

どの部品も markdown-it に依存しない純粋関数であることに気を遣った実装にできたかなと思います。

おわりに

本記事では、前回の入り口編で「外に委ねた」テキスト層を解説しました。

今回、純粋関数と層を明確に意識した構成にすることで、テストや後から実装を変える際の影響を明確化しやすかったことが印象的でした。

これは個人的にですが、パッケージとして配布できるような形を意識したことにより、貢献のしやすさなどを観点として取り入れられたことは学びとなりました。

進めていく中で最大長の検出など、課題ができましたので今後実装してみたいと思っています。

長くなりましたが、ここまで読んでいただきありがとうございました。

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?