Markdown内のコードブロックだけにPrettierをかける

2023/11/26に公開

なぜ?

技術書の執筆をMarkdownでしているとき、コードブロックはPrettierなどで勝手にフォーマットされて欲しいが、本文はTextlintのルールなり編集者の方針なりを優先したいケースがある。

また、Markdownの中でコードを書いてると文法ミスを見逃したり、インデントの不統一なども放置しがちになってしまうが、Prettierに失敗すればなにかがおかしいと気づけることも期待できる。

そういうわけで、Markdown内のコードブロックだけにPrettierをかけるスクリプトが欲しい気持ちになってくる。

問題

PrettierにはMarkdownのサポートがあり、コードブロックも(Prettierがサポートしている言語であれば)同時にフォーマットできる。

https://prettier.io/blog/2017/11/07/1.8.0.html

しかし、本文はそのままでコードブロックだけをフォーマットするオプションはないようだ。

方針

PrettierのRange Ignoreを活用する。

https://prettier.io/docs/en/ignore.html#range-ignore

つまり、コードブロック以外の部分をすべて<!-- prettier-ignore-start --><!-- prettier-ignore-end -->で囲み、その後でPrettierをかけるスクリプトを書く。

実装

丁寧にやるならMarkdownをパースし、コードブロックかそうでないかをチェックすべきだが、面倒なので正規表現で済ませる。
汎用性を目指さないなら、「``` で囲まれている行」という仮定を置けばそこまで難しくないはず。

実装にはNode.jsを用いる。node format_code_block.jsのように実行したいのでTypeScriptを使わず// @ts-checkとJSDocだけで済ませている。

完成形を見せるとこんな感じ。

format_code_block.js
// @ts-check

const fs = require("fs/promises");
const prettier = require("prettier");

/**
 * @param {string} markdownFilePath
 */
async function formatCodeBlock(markdownFilePath) {
  // ファイルを読み込む
  const data = await fs.readFile(markdownFilePath, "utf8");

  // 先頭と末尾にコメントを挿入
  const modifiedData = `<!-- prettier-ignore-start -->\n${data}\n<!-- prettier-ignore-end -->`;

  const codeBlockRegex = /(```[\x20-\x7e]*\n[\s\S]*?\n```)/g;
  const formattedData = modifiedData
    // コードブロックを正規表現で検索し、コメントを挿入
    .replace(
      codeBlockRegex,
      "<!-- prettier-ignore-end -->\n$1\n<!-- prettier-ignore-start -->",
    )
    // (注:)をコメントアウトする
    .replace(
      /\(注:/g,
      '// (注:'
    );

  // Prettierをかける
  const prettierOptions = await prettier.resolveConfig(markdownFilePath);
  const formattedContent = await prettier.format(formattedData, {
    ...prettierOptions,
    filepath: markdownFilePath,
  });

  // コメントを除去
  let finalContent = formattedContent;
  finalContent = finalContent.replace(
    /\n<!-- prettier-ignore-start -->\n?/g,
    "",
  );
  finalContent = finalContent.replace(/\n?<!-- prettier-ignore-end -->\n/g, "");

  finalContent = finalContent.replace(/<!-- prettier-ignore-start -->\n/g, "");
  finalContent = finalContent.replace(/\n<!-- prettier-ignore-end -->/g, "");

  finalContent = finalContent.replace(/\/\/ \(注:/g, '(注:');

  // ファイルに書き込む
  await fs.writeFile(markdownFilePath, finalContent);
}

// ファイル名を指定
const [markdownFilePath] = process.argv.slice(2);

formatCodeBlock(markdownFilePath);

PrettierのJavaScript APIを直接叩くのは初めてだったし、正規表現もそらで書くのはしんどいのでChatGPTの助けを借りた。以下のプロンプトで出てきたコードを手直ししたのが上の状態、ということになる。

markdownのファイル名が与えられたとき、以下の操作を行うスクリプトを書いてください

  1. markdown 全体の先頭に<!-- prettier-ignore-start -->を挿入、末尾に<!-- prettier-ignore-end -->を挿入する
  2. markdown内のすべてのコードブロックを発見し、その手前に<!-- prettier-ignore-end -->、後ろに<!-- prettier-ignore-start -->コメントを挿入する
  3. その状態でprettierをかける
  4. 2 で付与したコメントを除去する

markdownFilePathをコマンド引数で受け取れるようにしてください。たとえばこのファイルを node index.js -- path/to/your/markdown/file.mdのように実行したいです

注意点

正直ChatGPTに投げたプロンプトがそのままコードの説明になってるので、これ以上の解説は不要にも思われるが、少しだけ。

コードブロックに言語名とファイル名が入るケースに対応する

コードブロックの後ろには言語名などを書くことができる。
たとえば```js:format_code_block.jsのように。

最初私は[a-z:]*しか来ないと思って書いていたが、ファイル名が来るケースでは当然動かないので[\x20-\x7e]*(すべてのASCII文字)のように変更した。

書籍用のMarkdown方言に対応する

https://github.com/naoya/md2inao

書籍によっては、コードブロックに見出しをつけるための特別な記法がサポートされていることがある(以下はWEB+DB PRESS編集部の例)。

(注:見出し的に使う)
function bar(b) {
    alert(b);
}

こういう行が入っていると当然コードの文法としてはinvalidなものになりフォーマットがかけられなくなってしまう。

上のコードでは(注:を発見したらとりあえず// (注:に置換するようにしてお茶を濁している(コメント扱いになる)。

この方法はコメントが// ではない言語には当然使えないので適宜変える必要がある。
また、1個の書籍にコメントが// な言語と<!-- -->な言語が両方出てくるケースも困る。

今回はたまたま大丈夫だったにすぎない。
(実際JSとHTMLとCSSが出てきてそれぞれコメントの記法が異なる問題はあったが、私は(注:記法をかなり限定的にしか使っていなかった。)

使ってみて

自分の書いたコードと公式サイトからの引用でインデントが違ったり、;の有無が違うといった不統一を全部なくすことができた。

今回の例だと、コードブロックに文法エラーがある場合は別にスクリプトが1を返して終了するわけではなく、サイレントに失敗する。なのでdiffが意図せず少ないのを見てうまく行ってなさそうなのを推し量るしかない面はあった。それでもないよりは遥かにマシだったと思う。

ツールとして公開するつもりはないが、上のコードについては特に権利を主張しないので好きに改変してもらえれば。

Discussion