アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

gatsby-remark-copy-relative-linked-files

April 09, 2019開発GatsbyJS, remark

gatsby-transformer-remark 向けプラグイン gatsby-remark-copy-relative-linked-files 開発の覚書。

本ブログの過去記事ではサンプル プログラムの ZIP ファイルを公開している。しかしそれらがリンク切れになっているとの知らせを受けて調査したところ、

  • GatsbyJS で構築したサイトのリソースは public ディレクトリー配下に置く必要がある
  • 画像ファイルは gatsby-remark-copy-images により public へコピーできていた
  • それ以外はコピーされていない

という状態だった。そこで公式プラグイン gatsby-remark-copy-linked-files を以下のように設定して対応。

module.exports = {
  plugins: [
    {
      resolve: 'gatsby-transformer-remark',
      options: {
        plugins: [
          'gatsby-remark-copy-images',
          {
            resolve: 'gatsby-remark-copy-linked-files',
            options: {
              destinationDir: 'files'
            }
          }
        ]
      }
    }
  ]
}

これで Markdown からリンクされている全ファイルが public へコピーされて URL も適切に更新される。しかしこれらのプラグインはコピーの挙動が異なり gatsby-remark-copy-images は Markdown の相対リンクを加味して public 内を階層化する。例えば Markdown ファイルが page/blog/2019/04/10/ に配置されていて、そこから ![](04.png) とリンクしているなら public/page/blog/2019/04/10/04.png へコピー。リンク URL もそのように書き換えられる。

一方 gatsby-remark-copy-linked-filesoptions.destinationDir へ指定された階層を作成し、すべてのファイルをそこへコピー。おそらくこの仕様による同一ディレクトリー内の衝突を回避するためだろう、コピーされたファイル名にはハッシュのような文字列を末尾に付与してリンク URL もそうなる。例えば WpfAudioPlayer5-fc6be74b9a93c9a961b689bf7bce9572.zip のような感じで。

リンク切れの問題は解決した。しかしダウンロードしたファイル名に長大で余計な文字列が入るのは避けたい。gatsby-remark-copy-images の README に

This was adapted from gatsby-remark-copy-linked-files.

とあるのもこの挙動を指してだと思われる。以上を踏まえ gatsby-remark-copy-linked-filesgatsby-remark-copy-images の実装を参考にしながら

  • gatsby-remark-copy-linked-files のように <a><img> を両方とも処理
  • gatsby-remark-copy-images のようにコピー階層とリンク処理

という処理を実行する gatsby-transformer-remark プラグインを作成してみた。名前は gatsby-remark-copy-relative-linked-files。相対パスのみを対象に全ファイル種別をコピーするのでこのようにした。

gatsby-remark-copy-images 解析

gatsby-remark-copy-images実装は非常に小さい。すべて index.js 内におさまっている。プラグインとして公開されている部分は

module.exports = ({ files, linkPrefix, markdownNode, markdownAST, getNode }) => {}

となる。ここへ gatsby-transformer-remark が Markdown を解析した内容などが渡されるので、それを元にファイルのコピーやリンクの書き換えをおこなう。重要なのは unist-util-visit の絡む箇所。これは Markdown 解析結果となる AST を取り、合致する構文に対してコールバック関数を実行してくれる。

visit(markdownAST, `image`, image => visitor(image))

は Markdown でいうと ![]()、つまり <img> にあたるものに対して処理しているので、これに通常のリンクを加えればいい。

gatsby-remark-copy-linked-files 解析

gatsby-remark-copy-linked-files の README を読むと gatsby-remark-images との組み合わせを考慮している節がある。ignoreFileExtensions オプションの既定値は画像系の拡張子が指定されており AST でいう image はスキップされるようだ。こちらも実装を見てみよう。まずは公開部分。

module.exports = (
  { files, markdownNode, markdownAST, pathPrefix, getNode },
  pluginOptions = {}
) => {}

第一引数の展開結果は linkPrefixpathPrefix が異なるだけで他は一緒だ。それに加えて pluginOptions というパラメーターが増えている。名前から察するに gatsby-config.js 上で指定するプラグイン固有オプションだろう。こちらは gatsby-remark-copy-images に比べて様々な構文を処理しているが、今回必要なのはリンクなのでそこに注目。

visit(markdownAST, `link`, link => {
  const ext = link.url.split(`.`).pop()
  if (options.ignoreFileExtensions.includes(ext)) {
    return
  }

  visitor(link)
})

オプションに除外指定された拡張子でなければ処理、という単純な実装である。私のプラグインでは imagelink 両方を含めて pluginOptions.ignoreFileExtensions で判定することにした。そうすれば visitor 側に判定を局所化できる。個別にすることで細かな制御は可能になるが、リンクと画像で個別に除外指定したくなる場面が思いつかないのでやめておく。

プラグイン開発

参考にしたプラグインに対して以下を独自実装した。

それ以外はほぼ gatsby-remark-copy-images と一緒。

gatsby-transformer-remark に対する JavaScript としてのインターフェースさえ実装すれば、特別な npm 依存や設定ファイルなどは不要だった。gatsby-remark-copy-linked-filesbabel-preset-gatsby-package を利用しているけど、これもなくてよい。

現状では package.json"engines": { "node": ">= 10" } としてるから Babel すら不要なのだがビルドとリリース管理を踏襲するために採用。せっかく@babel/preset-env を設定しているのだから Modules だけでも利用しておけばよかったかも。

既定の除外ファイル

実際にプラグインを組み込んで動作確認して気付いたのだが link を処理すると Markdown ファイルも含まれる。そのため拡張子 .md を既定で除外するようにした。

本ブログは GitHub へリポジトリーを公開しているため Markdown ファイルをダウンロードできてもよいのだが

  • Web サイトとして余計なファイルである

    • Web サイトのリソースとして公開を想定したものではない
    • このファイルに起因する問題が起きる可能性もある
  • 他のユーザーが gatsby-remark-copy-relative-linked-files 利用する際に困るかもしれない

    • サイトのリポジトリーをプライベートにしているなら、そのデータが漏洩していることになる

という理由から除外を決定。稀だろうけど Markdown ファイルも含めたいなら ignoreFileExtensions オプションを明示的に指定して、そこに .md を設定しなければよい。

...というつもりでテストも通ったのだが、この記事を書きがてら修正を反映した v1.0.1 をブログに組み込んだら既定で .md は除外されなかった。バグなので調査する。

Jest

gatsby-remark-copy-linked-filesgatsby-remark-copy-images と異なりユニット テストが付属している。フレームワークは Jest を採用。個人的に Mocha + power-assert-js へ馴染んでいるので自作版はそうしようと考えたけど最近の Web フロントエンド界隈では React 対応などもあり Jest が人気のようなので、こちらを採用して学習の契機とする。

テストを見ると Jest のモック機能を利用して

jest.mock(`fs-extra`, () => {
  return {
    existsSync: () => false,
    copy: jest.fn(),
    ensureDir: jest.fn(),
  }
})

「プラグインが fs-extracopy を呼び出した = コピー処理した」ことをチェックしている。

expect(fsExtra.copy).toHaveBeenCalled()

私のプラグインでは更にリンク書き換え結果をチェックするようにした。これはプラグインに渡した Markdown AST を処理後に調べることで判定可能。

Jest については --silent=false を指定しないと console.log がでない仕様にハマったぐらい。API やアサーション、モックなどは Mocha とその代表的なツール チェインに親しんでいれば差分学習の範疇なので乗り換えを検討してもよいと感じた。

TypeScript 化は見送り

最近 TypeScript で書くようにしているので今回もそうしようとした。しかし gatsby-transformer-remarkremark 関連の型定義が見つからず、これらを自前で定義するのはキツイので見送り。Electron、React、Redux とその周辺ツールなど、自分がよく使うものは @types 公開されているので npm 全般としてそうなっていると認識していたのだが甘かった。

そのため久しぶりに素の JavaScript (on Node.js) で書いたのだが TypeScript + vscode の快適な環境に慣れた身としては、これほど小規模でも不安で仕方なかった。vscode は優秀なので require したモジュールのインターフェースも推測してくれるけど @types 提供されたものには到底、およばない。

Babel も導入していることだし、いっそ any まみれを許容して TypeScript 化してしまおうか迷っている。

まとめ

ブログのファイル添付まわりに対する問題が解消されてよかった。GatsbyJS に対してはもうひとつ、コードの構文強調を Prism から highlight.js へ移行したいという展望があるので、気が向いたらプラグインを作るかもしれない。

GatsbyJS プラグインの npm 公開について。名前に gatsby を戴くものをそのまま npm publish してよいものか迷った。当初は @akabeko に属する Scoped Package とするつもりだったが Babel のように本家側が明示する例を見ているので Scoped にせず公開。これについてはいずれ公式に本家用の @gatsby が新設されるのでは?と予想している。