プログラミング
プログラミング
  • 2024/08/25
  • 2024/08/27

Markdownブログに文字色を変更するショートハンドを実装する

MarkdownブログでHTMLを直接書かずに文字色を変更するための仕組みを実装しました。

参考


機能


Markdown中で [colorName]{text} とすると、{} で囲った部分の文字列(text)の色を colorName にします。

また、[bg:colorName]{text} のように bg: というプレフィックスをつけると、文字色を変える代わりに背景色を変化させます。

これは [red]{赤色} です。
こうすると [bg:blue]{背景が青色} です。

実装


方針

HTMLパーサの Cheerio を使用し、DOMから特定のテキストを抽出して <span style="color: colorcode"></span> でラッピングします。

load メソッドにHTML文字列を渡してCheerio のインスタンスを初期化します。

import { load } from "cheerio";
import type { CheerioAPI } from "cheerio";

const html = "";

const $ = load(html);    // CheerioAPI 型

特定のタグをエスケープする

スタイルの衝突を避ける

このブログには、Markdownのほかの拡張として やコードのシンタックスハイライトを実装してあります。このような場合には、他のテキスト装飾との衝突を避ける必要があります。

<code> タグのような特定のタグ内では装飾が不要です。したがって、特定のタグ内では処理をエスケープする方法を考える必要があります。そこで、あらかじめ特定のタグ内のテキストを抽出しておき、一連の処理が終わってから差し戻しを行うような関数を実装しました。

const escape = ($: CheerioAPI, tagName: string, processedText: () => string): void => {
    const rawText: string[] = [];

    // タグ内のテキストを抽出して空文字列に変換
    $(tagName).each((_, elm) => {
	rawText.push($(elm).text());
	$(elm).text("");
    });

    // HTML文字列に任意の処理を施し、Cheerioインスタンスに反映
    const text = await processedText();
    $("*").html(text);

    // タグ内に文字列を差し戻す
    $(tagName).each((idx, elm) => {
	$(elm).text(rawText[idx]);
    });
};

特定の文字列を正規表現で認識する

正規表現の生成

文字列を高度に操作するためには 正規表現 が役に立ちます。ここでは、

const colors = [
  { name: "red", code: "#dc2626" },
  { name: "blue", code: "#2563eb" },
];

のように色名とカラーコードが対になったオブジェクトを用意し、これをもとにHTML中の [colorName]{text} ないし [bg:colorName]{text} を認識する正規表現を生成します。

let str = "";

colors.forEach((color, idx) => {
  str += `\\[${color.name}\\]{[^{]+}|\\[bg:${color.name}\\]{[^{]+}`;
  if (idx !== colors.length - 1) str += "|";
});

const colorRegex = new RegExp(str, "g");
//    /\[red\]{[^{]+}|\[bg:red\]{[^{]+}|\[blue\]{[^{]+}|\[bg:blue\]{[^{]+}/g
グローバルマッチングのためのトリック

グローバルフラグ g を使用してすべてのパターンマッチングを取り出すための工夫として、{} 中では { を除く任意の文字列にマッチングさせることが必要です({[^{]+} の部分)。そうでないと1つの文字列に2つ以上のパターンがあった場合に挙動が意図しないものになります。

マッチングとDOMの置換

let dom = "";

$("p,em,strong,dl,dd,ul,li,th,td").each((_, elm) => {

    // マッチするパターンを配列に格納
    const text = $(elm).text();
    const matches = text.match(colorRegex);
    dom = text;

    if (matches) {
        matches.forEach((m) => {
            // 色名 ("red", "blue",...)
            const colorName = m.replace("[", "").replace(/\]{.+}/, "");
            // カラーコード ("#dc2626", "#2563eb",...)
	    const colorCode = colors.find((c) => c.name === colorName.replace("bg:", ""))?.code;
            // {} 内のテキスト
	    const rawText = m.replace("}", "").replace(/\[[a-z:]+\]{/, "");
            // bg: プレフィックスの有無
	    const isBackground = /^bg:/.test(colorName);
            // スタイリング
            const styleDom = isBackground
		? `<span style="background-color: ${colorCode}; padding: 2px; border-radius: 8px;">${rawText}</span>`
	        : `<span style="color: ${colorCode};">${rawText}</span>`;
	    dom = dom.replace(new RegExp(regexStr), styleDom);
	});
        // Cheerioインスタンスに反映
	$(elm).html(dom);
    }
});

処理を合わせる

以上の正規表現によるDOMの置換処理をまとめて関数(styledText)に切り出します:

const colors = [
  { name: "red", code: "#dc2626" },
  { name: "blue", code: "#2563eb" },
];

const styledText = ($: CheerioAPI): string => {
  let str = "";
  colors.forEach((color, idx) => {
    str += `\\[${color.name}\\]{[^{]+}|\\[bg:${color.name}\\]{[^{]+}`;
    if (idx !== colors.length - 1) str += "|";
  });
  const colorRegex = new RegExp(str, "g");

  let dom = "";
  $("p,em,strong,dl,dd,ul,li,th,td").each((_, elm) => {

      // マッチするパターンを配列に格納
      const text = $(elm).text();
      const matches = text.match(colorRegex);
      dom = text;

      if (matches) {
          matches.forEach((m) => {
              // 色名 ("red", "blue",...)
              const colorName = m.replace("[", "").replace(/\]{.+}/, "");
              // カラーコード ("#dc2626", "#2563eb",...)
	      const colorCode = colors.find((c) => c.name === colorName.replace("bg:", ""))?.code;
              // {} 内のテキスト
	      const rawText = m.replace("}", "").replace(/\[[a-z:]+\]{/, "");
              // bg: プレフィックスの有無
	      const isBackground = /^bg:/.test(colorName);
              // スタイリング
              const styleDom = isBackground
		  ? `<span style="background-color: ${colorCode}; padding: 2px; border-radius: 8px;">${rawText}</span>`
	          : `<span style="color: ${colorCode};">${rawText}</span>`;
	      dom = dom.replace(new RegExp(regexStr), styleDom);
	  });
          // Cheerioインスタンスに反映
	  $(elm).html(dom);
      }
  });
  return $.html();
}

この関数を escape 関数に渡すことで、DOMの置換処理を <code> タグをエスケープしながら実行します。

escape($, "code", () => styledText($));

色の定義、styledText 関数の定義と escape 関数の実行処理をさらに関数にまとめて export することで、処理をユーティリティ化します。

escape関数の扱い

escape 関数は などの処理でも使用しているので、他の場所に切り出してあります。

export const textStyling = ($: CheerioAPI): void => {
  const colors = [
    { name: "red", code: "#dc2626" },
    { name: "blue", code: "#2563eb" },
  ];

  const styledText = ($: CheerioAPI): string => {
    ......
    ......
  }
  
  escape($, "code", () => styledText($));
}

完成品


これは [red]{赤色} です。

これは 赤色 です。

こうすると [bg:blue]{背景が青色} です。

こうすると 背景が青色 です。

2024/08/27 追記

色付きアンダーラインを追加しました。

これで [ul:green]{緑色の下線} が付きます。

これで 緑色の下線 が付きます。

今後の課題


現状ではボールド体やイタリック体など他のテキスト装飾と併用しづらいので、改善できたらいいなと思います(いつかやる)。

関連記事


    記事がありません

Shota Inoue
Shota Inoue

大学生 | 化学・Webプログラミング・統計学など