npm 開発で脱 Babel してみる

2017年1月25日 0 開発 , , ,

自作 npm の開発で脱 Babel したときの対応と問題点まとめ。

  • 2017/1/31 訂正
    power-assert の作者、t_wada さんより power-assert は babel-register を通すなどしないと assert 置換が働かず素の assert になってしまうという指摘があったのでユニットテスト関連の記述を訂正

脱 Babel を決めた背景

私はいくつか自作 npm を公開している。これらは ES2015 以降の機能と構文を利用して npm publish の際に Babel で ES5 相当へ transpile している。この運用で特に問題も起きていない。またプリセットに babel-preset-latest を採用することで ES 関係の規格追従を Babel 任せにできる安心感もあり、ずっとこのままでいいと思っていた。

ある日、職場で Node アプリを開発している人から「Babel 依存は怖くないですか?」という質問があった。Babel にバグがあったら調査や修正は困難だし、使わなくてよいならばそうするに越したことはないのでは?と。

これまで C++、C#、Java などコンパイル前提の言語で開発した経験から、よほどのことがない限りコンパイル結果は信頼に足ると判断していた。また、コンパイルされたマシン語や中間言語を人間が直に記述するのは非常にキツイ。そのため脱コンパイラーという選択肢は現実的ではないと認識している。

一方、JavaScript における transpile は高級言語どうしの変換となる。その気になれば人間が書けるのだ。

transpiler への慣れから、この事実をすっかり忘れていた。Node であれば Web フロントエンドと異なり動作環境の分岐は少なく、package.json の engines にて対象環境を限定することも可能である。ならば新しい Node を前提として脱 Babel を検討してみるのもよさそうと考えはじめた。

そんな折、npm-run-all が v4.0.0 で Babel による transpile を廃止。Node は v4 時点で ES Modules を除く大半の ES2015 機能が実装されているため、この範囲で足りるなら transpile せずにそのままリリース可能だ。その前例として普段利用しているツールが脱 Babel したのはインパクトある。

これらを踏まえ、まずは自作 npm のうちダウンロード数の少なくニッチな wpxml2md から脱 Babel を試してみることにした。

脱 Babel への道のり

脱 Babel 対応で実施したことを書く。

Node 環境の明示的な指定

脱 Babel における前提条件として動作環境とする Node のバージョンを決める。npm-run-all は Node v4 を下限としているようだが、wpxml2md では v6 としておく。

開発と検証コストを考慮して自作 npm の動作環境は「最新 + 最新 LTS」としている。2017/1 時点の最新 Node は v7 系、LTS は v6 と v4 があるため対象は「v7 + v6」となる。これまでは Babel による transpile で v4 以下でも動作していたのだが、これを廃止することで明示的な下限の指定が必要となった。これは package.json の engines プロパティに記述する。

{
  "engines": {
    "node": ">= 6"
  }
}

ちなみに

node v6, v7

というバッヂを用意していて前から README へ掲載していた。今回の対応により、ようやくこれが本来の意味をあらわすようになった。

Babel の transpile を廃止

これまでは Babel の transpile を前提として以下のような Babel 設定と npm-scripts を利用していた。

{
  "babel": {
    "presets": [
      "latest"
    ],
    "env": {
      "development": {
        "presets": [
          "power-assert"
        ]
      }
    }
  },
  "scripts": {
    "test": "mocha --timeout 50000 --compilers js:babel-register test/**/*.test.js",
    "watch": "babel src --out-dir ./ --watch",
    "start": "npm run watch",
    "build": "babel src --out-dir ./",
    "prepublish": "npm run build"
  }
}
script 内容
test 予約された npm run のタスク。 mocha と power-assert によるユニット テスト。
watch 開発用。ファイル監視による自動 transpile を実行。
start 予約された npm run のタスク。watch を呼び出すだけ。
build リリース用。現時点のソース コードで transpile を実行。
prepublish 予約された npm publish 時に呼び出されるタスク。build を呼び出しているため npm として公開されるイメージは transpile されたものになる。なお prepublish は現在 deprecated になっていて prepare へ修正すべきなのだが直し忘れていた。

これが脱 Babel によりこうなる。

{
  "babel": {
    "env": {
      "development": {
        "presets": [
          "power-assert"
        ]
      }
    }
  },
  "scripts": {
    "test": "mocha --timeout 50000 --compilers js:babel-register test/**/*.test.js"
  }
}

transpile 不要となるため関連するタスクが消え、ユニット テストだけが残った。ただし power-assert を利用しているなら標準 assert を置換するための transpile が必要なので、このための設定は維持する。

env.development.presetspower-assert だけ指定することで、Babel 依存がユニット テストに限定されていることが明示されるだろう。必要な Babel 関連の npm も babel-registerbabel-preset-power-assert だけになる。

ES Moduels を CommonJS 化する

Node の ES Modules 対応については以下が詳しい。

なお現時点の最新 Node である v7 においても ES Modules には対応していないため、export/import は CommonJS の exports/require へ修正する必要がある。例えば

export const Options = {
};

export default class CLI {
}

const Options = {
};

class CLI {
}

module.exports = {
  Options: Options,
  CLI: CLI
};

とする。読み込む側は

import CLI from `cli.js`;
import { Options } from `cli.js`;

const CLI = require( `cli.js` ).CLI;
const Options = require( `cli.js` ).Options;

とする。ひとつのモジュールから exportdefault export で複数のインターフェースを公開していると面倒である。しかし ES Modules で書いていたなら import はソース コード冒頭に集約されているから悩む余地なく機会的な作業になる。なんなら正規表現でまとめて変換できる。

ユニット テストについても同様に対応すること。

余談。

CommonJS にした後でも将来の ES Modules 移行を容易にするため require はソース コード冒頭へ書く習慣をつけたほうがよいかもしれない。その場合、読み込み先を camelCase で命名しているとローカル変数と競合する可能性が高くなるため PascalCase にしたくなり、私はそうしている。例えば fsFs と命名している。

Node と CommonJS だとスコープを意識して require を使い分け、なるべく関数ローカルで宣言する派が多数な感じなので ES Modules 対応されたときが気になる。私のようにするか、それともよりよい慣習となるのか?実に楽しみだ。

ESDoc 対応

npm のコード ドキュメント生成に ESDoc を採用している場合、そのままでは CommonJS を解釈できないので対応が必要になる。CommonJS は

上記 issue で紹介されている esdoc-node により対応できる。ESDoc にプラグイン機能があることを初めて知った。これを指定することでコードが ESDoc に解釈される前処理を実行できるらしい。例えば esdoc-node は CommonJS を ES Modules に変換して ESDoc に渡す。

さっそく使ってみよう。まず esdoc-node をプロジェクトに追加。

$ npm install -D esdoc-node

次にこれを ESDoc 設定へ追加する。私は ESDoc の設定を package.json に定義しているので

{
  "esdoc": {
    "source": "./src",
    "destination": "./esdoc",
    "test": {
      "type": "mocha",
      "source": "./test"
    },
    "plugins": [
      { "name": "esdoc-node" }
    ]
  }
}

このようにした。設定後、ESDoc を実行して CommonJS なコードも正しく解析されることを確認。

ただしローカル実行ではなく ESDoc Hosting Service は CommonJS やプラグインに対応していないようだ。試しに wpxml2md の API Document を生成 してみたのだが、2017/1/25 時点では esdoc-node 未指定の状態と同様に CommonJS の絡むものが解釈できていない。

この点について要望 issue を登録してみたのだけど、もしプラグイン対応する場合

  • Hosting Service 側でプラグインを網羅しておき選択実行する
    • プラグインを中央管理する仕組みがないと網羅できない
    • プラグインのバージョンはどうするのか?常に最新?
  • プロジェクトの ESDoc 設定と package.json から動的にプラグインを決定する
    • プラグインのインストールはどうする?
    • ドキュメント生成とごにプラグインをインストールすると Hosting Service の負荷が大きい
    • CI 系サービスのように VM や Docker コンテナで対応するとしても負荷は大きい

といった問題が予想されるため難しそうだ。しかし要望があることだけは記録しておきたかったので issue 登録することにした。

別の方法として ESDoc 本体が esdoc-node 処理を取り込むという選択肢もある。ただ、いずれ Node が ES Modules へ正式に対応するとしたら CommonJS は過渡期の存在である。

  • そのために対応コストを割くのか?
  • ESDoc と名乗るツールとして ECMAScript とは直に関係しない CommonJS へ対応することは設計思想として望ましくないのでは?

という考えもあるだろう。どのような対応、または非対応のままになったとしても ESDoc 作者の意向を尊重したい。

ESDoc Hosting Service を利用しない場合、対象プロジェクトが GitHub で管理されているならそのリポジトリに対して GitHub Pages を使う手もある。ローカルの ESDoc + esdoc-node で出力したものを自前でアップロードするか、そういうタスクを npm-scripts に定義して CI サービス経由で生成から公開まで自動化するなどの方法が考えられる。

本記事を書いた直後に ESDoc 作者の @h13i32maru さんから見解が。

ESDoc Hosting Service 的にはセキュリティや負荷を考慮し、ESDoc 公式プラグインのみサポートしているとのこと。ただ Node の ES Modules 対応は相当に先となりそうなので、この長い過渡期に Node かつ脱 Babel したい開発者としてどうか?という悩ましい課題がある。

まとめ

一部、問題もあったが transpile 不要となったことで Node のバージョンだけを意識して開発すればよい。記述したコードはそのまま実行されることが保証される。Babel 由来の潜在的なトラブルを考えなくてよいため精神的に楽。

あと npm をユニット テストではなく普通のプログラムとして走らせて検証してみたい場合、いちいち transpile しなくて済むのも助かる。

検証はテストに定義すべきでは?という意見もあるだろうけどリポジトリの examples フォルダに npm として参照したときのサンプルを配置しているとき、その動作検証で npm publish する前の現行コードを試したくなったりする。その場合、transpile なしだとビルド系タスクを実行せず動かせてよい。

以上を踏まえ、他の npm についても気が向いたら脱 Babel してゆく予定。

WordPress の Markdown 移行補助ツールを開発してみた

2016年3月7日 0 開発 , , ,

先月、WordPress に Markdown と highlight.js という記事を書き、少しずつブログ記事の Markdown 化に取り組んでいた。しかしあまりにも定型作業 & 数が多いので、移行補助ツールを開発することにした。

WordPress は標準で記事を XML としてエクスポートする機能がある。これを解析して Markdown ファイルに変換する。そんなコンセプトで開発してゆき、私的に実用十分なものができたので wpxml2md としてリリースしてみた。

以下に wpxml2md の説明や開発で得られた知見などをまとめる。

もくじ

ツールを使ってみたいだけならば wpxml2md の使い方WordPress XML について のエクスポート手順を参照のこと。それ以外は開発の話が中心。

wpxml2md の使い方

まずは想定環境から。

WordPress の記事を Markdown で書く方法はいくつかあるが、このツールでは Jetpack by WordPress.com を想定している。Jetpack をインストールして設定から Markdown を有効にすると、従来の記法に加えて Markdown も解釈してくれる。

Jetpack 以外の Markdown プラグインはテストしていない。とはいえ HTML タグを対応する Markdown 記法に変換するだけだから、問題は起きにくいとは思うけれど。

また、ソースコードの構文強調は SyntaxHighlighter Evolved を想定。広く普及しているうえ WP Code Highlight.js のようにショートコードの互換運用もされていたりする。このプラグイン用に書かれたショートコードを Markdown ( GitHub Flavored Markdown ) のコードブロックに変換する。

その他のショートコードは基本、無視。そのため異なるショートコード記法の構文強調プラグインだと変換されない。

wpxml2md のインストールと使い方を解説する。

このツールは npm として実装しているので Node.js 環境が必要。Node は公式サイトのインストーラーや Homebrewcreationix/nvm などで構築できる。

wpxml2md は以下のコマンドで取得する。ここでは説明を簡単にするために -g オプションを付けてグローバルにインストールしておく。

$ npm install -g wpxml2md

これで wpxml2md というコマンドを利用できる。WordPress からエクスポートした XML ファイルの置かれた階層に cd して以下を実効すると Markdown 変換されたファイル群を得られる。

$ wpxml2md -i wp.xml -o ./ -r

あとは変換された内容を WordPress 上で対応する記事にコピペしてゆけばよい。なお npm wp2md のように WordPress へログインして直に記事を上書きすることも検討したが、以下の理由から却下した。

  • 記事の内容によっては Markdown 変換が不完全になる可能性もありえる
  • Markdown 変換が不完全な場合、上書き前に戻すのが面倒
  • 変換結果をローカルで確認してから記事に反映したい
  • npm とはいえ、ログイン情報を渡すのはユーザーとして抵抗がある

Markdown は広く普及しているので、編集やプレビューするためのツールは豊富にある。不完全な部分があれば、それらで修正してから WordPress へ反映することをオススメする。私は AtomQuiver などを利用している。

WordPress XML について

WordPress の記事を XML としてエクスポートする手順は以下。

  1. WordPress の管理画面を開く
  2. 左メニューからツールを選択
  3. エクスポート欄ですべてのコンテンツを選択
  4. エクスポートファイルをダウンロードボタンを押下

これで XML ファイルが得られる。

XML の構造

XML 解析にあたり公式仕様をあたろうと WordPress XML Specification などでググってもそれらしい資料を見つけられなかった。代りにフォーラムでWXR Specification OR WordPress Extended XML Spec という投稿があり、その中で提示されていた The WordPress eXtended Rss (WXR) Export/Import, XML Document Format Decoded and Explained. – The Developer’s Tidbits が網羅的かつ詳細だったので参考にした。

この XML は WXR ( WordPress eXtended RSS ) と呼ぶそうだ。参考記事をもとに XML 構造を自分用にまとめてみる。まずは全体図。

<?xml version="1.0" encoding="UTF-8" ?>
<rss>
  <channel>
    <item></item>
  </channel>
</rss>

channel 要素がサイト全体を表す。ユーザー情報やタグ、カテゴリなどの要素は全体的なので channel 直下に定義される。投稿や固定ページは item 要素になり、記事の数だけ定義される。

今回は投稿と固定ページを Markdown 化したいので item 要素を解析できればよい。その構造は以下となる。記事の本文など、要素内に XML などが入れ子になり得る値は CDATA セクションに定義されている。

<item>
  <title>Sample Post</title>
  <link>http://akabeko.me/blog/</link>
  <pubDate>Sun, 06 Mar 2016 12:00:00 +0000</pubDate>
  <dc:creator><![CDATA[akabeko]]></dc:creator>
  <guid isPermaLink="false">http://akabeko.me/blog/?p=46</guid>
  <description></description>
  <content:encoded><![CDATA[Sample post.]]></content:encoded>
  <excerpt:encoded><![CDATA[]]></excerpt:encoded>
  <wp:post_id>46</wp:post_id>
  <wp:post_date><![CDATA[2016-03-06 12:00:00]]></wp:post_date>
  <wp:post_date_gmt><![CDATA[2016-03-06 12:00:00]]></wp:post_date_gmt>
  <wp:comment_status><![CDATA[open]]></wp:comment_status>
  <wp:ping_status><![CDATA[open]]></wp:ping_status>
  <wp:post_name><![CDATA[Sample Post]]></wp:post_name>
  <wp:status><![CDATA[publish]]></wp:status>
  <wp:post_parent>0</wp:post_parent>
  <wp:menu_order>0</wp:menu_order>
  <wp:post_type><![CDATA[post]]></wp:post_type>
  <wp:post_password><![CDATA[]]></wp:post_password>
  <wp:is_sticky>0</wp:is_sticky>
  <category domain="post_tag" nicename="Sample"><![CDATA[Sample]]></category>
  <category domain="category" nicename="Sample"><![CDATA[Sample]]></category>
  <wp:postmeta>
    <wp:meta_key><![CDATA[syntaxhighlighter_encoded]]></wp:meta_key>
    <wp:meta_value><![CDATA[1]]></wp:meta_value>
  </wp:postmeta>
  <wp:postmeta>
    <wp:meta_key><![CDATA[_edit_last]]></wp:meta_key>
    <wp:meta_value><![CDATA[1]]></wp:meta_value>
  </wp:postmeta>
</item>

子要素の内容を簡単にまとめる。勘違いしている箇所もありそう。もし誤りを見つけたらコメント欄などで指摘してほしい。

要素 内容
title 記事のタイトル。
link 記事の URL。
pubDate 記事を公開した日時。前述の参考記事には RFC 822 とあるけど、おそらく現在なら RFC 2822 と思われる。この値は JavaScript の Date コンストラクタに指定できる。
dc:creator 記事の著者名。
guid 記事の一意な識別子。パーマリンクを設定していない状態の記事 URL になる。
description 記事の説明文。
content:encoded 記事の本文。Markdown 化の対象とするデータ。
excerpt:encoded 記事を RSS 配信するときの抜粋文。設定が全文配信なら空になるようだ。
wp:post_id 記事の識別子。WordPress 内がインクリメンタルに自動採番する。guid 要素の末尾に指定される値もこれ。
wp:post_date 投稿日時。書式は YYYY-MM-DD hh:mm:ss になっている。
wp:post_date_gmt 投稿日時の GMT 版。
wp:comment_status コメント欄の状態。openclosed になるようだ。
wp:ping_status ピンバックの状態。openclosed になるようだ。
wp:post_name URL 用の投稿名。記事の編集画面でパーマリンク欄へ入力したものになる。
wp:status 記事の状態。publishdraft など。
wp:post_parent 親となる記事の識別子。WordPress の固定ページは記事に階層を設定可能で、これはその親子関係を知るための情報。
wp:menu_order 記事メニュー上の並び順を示す番号。
wp:post_type 記事の種別。投稿なら post で固定ページは page になる。投稿と固定ページは共に item 要素として定義されるため、処理を分岐したい場合はこの値を判定する。
wp:post_password 記事にアクセス制限を掛けた場合のパスワード。
wp:is_sticky 記事がサイトのトップに固定されていることを示す値。0 = false、1 = truetrue なら固定。
category 記事のカテゴリーとタグ情報。どちらを示すのかは domain 属性で区別できる。ひとつの item 内に複数定義される。
wp:postmeta 記事のメタ情報。プラグインに関する設定などが定義される。

Markdown 変換エンジン

当初、記事の Markdown 変換は正規表現で対応する予定だった。途中までそのように設計していたのだが、table や ul、ol のように DOM の入れ子を解析して変換するのが面倒すぎて諦めた。

次に jsdom を検討した。これは Node 用の HTML DOM 解析ツールで、HTML テキストを指定すると Web ブラウザのように document オブジェクトを返してくれる。以降は querySelector なり childNodes をたどってゆくなどすればよい。

to-markdown

DOM パーサーを手に入れたが、Markdown 変換を自前で全て実装するより、既存の OSS を参考にしたほうがよいだろう。結果、to-markdown がよさそうだった

to-markdown を npm として参照するか迷ったが、このツールでは DOM 全般の改行と空白を除去してしまう。HTML の Markdown 変換という意味では適切な仕様なのだけど、WordPress の場合、SyntaxHighlighter Evolved などで構文強調している部分はプレーンテキストとしての改行と空白を維持する必要がある。

よって to-markdown の変換エンジンを移植して、wpxml2md 用にカスタマイズする方法を採用した。ありがたいことに to-markdown も Node で動作するときは jsdom を利用しているので設計思想も近い。そのため移植はかなりスムーズだった。

to-markdown エンジンの移植

to-markdown エンジンは以下で構成されている。

  • index.js
    • npm エントリー ポイント
    • 入力テキストの空白と改行の除去や jsdom 解析などを実行
    • Markdown と GitHub Flavored Markdown 変換エンジンの実行
  • md-converters.js
    • Markdown 変換エンジン
    • Array になっている
    • Array の要素は HTML タグ単位
    • DOM を列挙して一致するタグを見つけたら変換、という設計になっている
  • gfm-converters.js
    • GitHub Flavored Markdown 変換エンジン
    • Markdown 版と同じ設計
    • index.js 上では Markdown 版より優先実行される

これらを wpxml2md の構成へ沿うように再構築し、ES2015 として rewrite した。

to-markdown の設計で最も重要なのは DOM Node の処理順である。これを適切に制御するため、事前に document.body 以下の childNodes をたどって直列化している。関数を再帰していないのも設計としてポイント高い。以下はその移植版。

export default class Converter {
  static flattenNodes( node ) {
    const inqueue  = [ node ];
    const outqueue = [];

    while( 0 < inqueue.length ) {
      const elm = inqueue.shift();
      outqueue.push( elm );

      for( let i = 0, max = elm.childNodes.length; i < max; ++i ) {
        const child = elm.childNodes[ i ];
        if( child.nodeType === NodeTypes.ELEMENT_NODE ) {
          inqueue.push( child );
        }
      }
    }

    outqueue.shift(); // Remove root
    return outqueue;
  }
}

これで全 Node をシーケンシャル リードできるようになった。そのうえ各 DOM Node の childNodes 参照も残っているため、階層を意識した処理も可能となっている。これらを処理するときは降順に読んでゆく。

export default class Converter {
  static convert( post, options = {} ) {
    // Process through nodes in reverse ( so deepest child elements are first ).
    for( let i = nodes.length - 1; 0 <= i; --i ) {
      Converter.process( nodes[ i ], converters, options );
    }  
  }
}

この設計だと litdth など入れ子構造における子となる要素は親よりも先に検出される。その際、parentNode をたどることで自身の位置を取得し、インデントなどの処理を実行できる。

変換エンジンへ渡すテキストの事前処理はショートコードなどを活かすため、以下のように ELEMENT_NODE のみを対象としている。空白除去は to-markdown と同様に collapse-whitespace を利用。

export default class Converter {
  static collapseWhitespace( nodes ) {
    nodes.forEach( ( node ) => {
      if( node.nodeType === NodeTypes.ELEMENT_NODE ) {
        CollapseWhitespace( node, Util.isBlockElement );
      }
    } );
  }
}

WordPress ではテキスト間に空行を入れることで、自動的に p タグに包まれて段落化される。そのため大半の文章は HTML タグを利用せず TEXT_NODE になる。よってこのように処理することで空白と改行を維持できる。

他にも工夫はあるのだが、to-markdown と wpxml2md 的に重要なのはこの 2 点である。

あと、Markdown 的に変更したところがあった。to-markdown だと ulol 内の li*. になるのだが、シンボルとテキスト間の空白が多すぎるので 1 文字に詰めて .* にした。Markdown のリスト記法では、こちらで説明されていることが多いし GitHub の Basic writing and formatting syntax – User Documentation でもそうなので、これで良しとする。

その他

前に icon-gen という npm を開発したときは CLI 部分をユニット テストしていなかった。そのためオプション名の変更をミスして何度か手戻りがあり、これは自動テストするべきだと感じていた。

そこで今回は CLI 部分もユニット テストしてみた。CLI オプション解析とヘルプ、バージョン出力を対象としている。オプションについては簡単で、単に process.argv を解析する関数を実装し、それに色々な Array を与えればテストできる。

このブログの Markdown 移行について。

wpxml2md が一段落ついたので、変換結果の反映に取り組みはじめた。構文強調とテーブル変換が成功していた場合、ほぼコピペと上書きで済むので随分と楽になった。しかしヘッダー行のないテーブルには対応しておらず、コードブロックの行強調もなくなるため、それらの対応は面倒に感じる。

ただ、過去記事の棚卸しと考えて、誤字脱字や表現の向上なども実施する予定。

Markdown 変換した記事は Quiver 上に保存している。最近、Markdown な資料はこのツールで編集 & 管理していて、Dropbox で共有している。有料だが購入してよかった。後で Quiver について独立したレビュー記事を書きたい。