アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

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

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

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

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

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>/blog/</link>
  <pubDate>Sun, 06 Mar 2016 12:00:00 +0000</pubDate>
  <dc:creator><![CDATA[akabeko]]></dc:creator>
  <guid isPermaLink="false">/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>

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

要素 内容
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:postdategmt 投稿日時の 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 );
    }  
  }
}

この設計だと <li><td><th> など入れ子構造における子となる要素は親よりも先に検出される。その際 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 だと <ul><ol> 内の <li>*___.___ になる (_ はスペース)。しかしシンボルとテキスト間の空白が多すぎるから 1 文字に詰めて .* にした。Markdown のリスト記法では、こちらで説明されていることが多いし GitHub の Basic writing and formatting syntax - User Documentation でもそうなので、これでよしとする。

その他

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

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

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

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

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

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