W WordPress記事をMarkdown形式で
コピーできるプラグインを作った

自分のサイト用に記事をMarkdown形式でコピーできるプラグインを作りました。
ChatGPTに貼り付けたり、NotionやObsidianに保存したりするときに便利です。

関連記事

1. 作ったもの

Gutenbergブロックとして配置できる「Markdown copy」ボタンと「download(.md)」ボタンを提供するプラグインです。

フロントエンド側でHTML→Markdown変換を行い、記事タイトルと本文を含むMarkdownファイルを生成します。

変換にはTurndownというJavaScriptライブラリを使っています。
管理画面から除外したい要素のCSSセレクタを設定でき、広告やSNSシェアボタンなどを変換対象から外せます。

1.1. プラグインのコード構造

開発を進める中で、コードの構造も段階的に整理していきました。

最終的な構成は、エントリーポイントとなるメインファイルと、srcディレクトリ配下のクラスファイル群に分かれています。メインファイルは必要なクラスを読み込み、Plugin::boot()を呼び出すだけのシンプルな作りです。

require_once __DIR__ . '/src/ExcludeSelectors.php';
require_once __DIR__ . '/src/MarkdownDocument.php';
require_once __DIR__ . '/src/BlockMarkup.php';
require_once __DIR__ . '/src/ButtonColor.php';
require_once __DIR__ . '/src/Plugin.php';

\Chiilabo\CopyAsMarkdown\Plugin::boot();Code language: PHP (php)

依存関係の注入やオートローダーは使わず、素朴なrequire_onceで読み込んでいます。

Pluginクラスは、WordPressのフックシステムとの接続点になっています。
boot()メソッドで各種アクションフックを登録し、それぞれの処理を静的メソッドに委譲する設計です。

add_action('init', [self::class, 'registerAssets']);
add_action('init', [self::class, 'registerBlock']);
add_action('wp_enqueue_scripts', [self::class, 'enqueueFrontendAssets']);Code language: PHP (php)

Gutenbergブロックの登録にはregister_block_type()を使い、レンダリングコールバックで実際のHTML出力を行います。
この部分はBlockMarkupクラスに分離しました。

設定値の管理には、WordPressのSettings APIを使っています。
除外セレクタはExcludeSelectorsクラス、ボタンの色設定はButtonColorクラスがそれぞれ担当します。
入力値のサニタイズと正規化はこれらのクラスに委譲することで、Pluginクラスの肥大化を防いでいます。

フロントエンド側へのデータ受け渡しには、wp_localize_script()を使いました。
除外セレクタ、投稿スラグ、UIラベルをJavaScriptのグローバル変数として渡します。

wp_localize_script('chiilabo-copy-md-frontend', 'ChiilaboCopyMdData', [
    'excludeSelectors' => self::currentExcludeSelectors(),
    'postSlug' => self::currentPostSlug(),
    'labels' => [...],
]);Code language: PHP (php)

ボタンの色設定はCSS変数として展開し、インラインスタイルとして出力しています。
wp_add_inline_style()を使うことで、設定値を動的に反映できます。

テストコードは、PHPUnitの単体テストとWordPress統合テストに分かれています。
単体テストはtests/Unit配下に、統合テストはtests/Integration配下に配置しました。phpunit.xml.distphpunit.wp.xml.distで実行環境を分けることで、依存関係を明確にしています。

この構成により、各クラスの責務が明確になり、テストも書きやすくなりました。

2. 工夫したポイント

2.1. h1タグにSVGが混在する

最初の動作確認で、記事タイトルの取得がうまくいかない問題に直面しました。
h1タグは、ちょっと特殊な構造になっています。

<h1 class="entry-title">
  記事のタイトル
  <svg>...</svg>
  <br>
</h1>
Code language: HTML, XML (xml)

テーマが装飾用のSVGアイコンをタイトル内に埋め込んでいました。単純にtextContentを取得すると、SVG内のテキストや改行が混入します。

解決策として、タイトル要素をcloneしてから不要なノードを削除する方式に変更しました。

const titleClone = titleElement.cloneNode(true);
titleClone.querySelectorAll('svg, script, style, br').forEach(el => el.remove());
const cleanTitle = titleClone.textContent.trim().replace(/\s+/g, ' ');
Code language: JavaScript (javascript)

svg、script、style、brを明示的に除去してから、textContentを正規化します。空白の連続を1つのスペースに圧縮することで、余分な改行やインデントも取り除けます。

2.2. コードブロックの構造がテーマで変わる

次に遭遇したのが、コードブロックの変換品質の問題です。

WordPressのコードブロックは、テーマやプラグインによって構造が異なります1
シンタックスハイライトを使っているサイトでは、こんな入れ子になっていました。

<pre><span><code class="hljs language-python">...</code></span></pre>
Code language: HTML, XML (xml)

Turndownの標準ルールだと、この構造を想定どおりに変換できないケースがありました。
フェンスコードブロックとして出力されなかったり、言語情報が欠落したりします。

カスタムルールを追加して、pre要素を見つけたら強制的にフェンス形式で出力するようにしました。

turndownService.addRule('pre', {
  filter: 'pre',
  replacement: function(content, node) {
    const codeElement = node.querySelector('code');
    let language = '';
    
    if (codeElement) {
      const classList = Array.from(codeElement.classList);
      const langClass = classList.find(c => c.startsWith('language-'));
      if (langClass) {
        language = langClass.replace('language-', '');
      }
    }
    
    return '\n```' + language + '\n' + content.trim() + '\n```\n';
  }
});
Code language: JavaScript (javascript)

code要素のクラス名からlanguage-xxx形式の言語情報を抽出し、info stringとして付与します。
末尾の改行処理も統一することで、余分な空行が入らないようにしています。

2.3. 番号のエスケープは仕様

変換結果を確認していると、見出し内で4\.のようにピリオドがエスケープされる現象に気づきました。
最初は不具合かと思いましたが、これはTurndownの正常な動作です2

Markdownでは、行頭の4.を番号付きリストと誤認識する可能性があります。
Turndownはこれを防ぐため、見出し内の数字の後ろにバックスラッシュを挿入します。

ただ、見出しとして出力する場合は不要なので、見出し行に限定して正規化しました。

markdown = markdown.replace(/^(#{1,6}\s.*?)\\([.:])/gm, '$1$2');
Code language: JavaScript (javascript)

正規表現で見出し行だけをマッチさせ、エスケープを取り除きます。
全文置換すると本文中の意図的なエスケープまで壊してしまうため、慎重に範囲を限定しています。

2.4. 脚注プラグインとの干渉

easy-footnotesという脚注プラグインが有効になっているサイトでテストしたところ、変換結果にこんなリンクが大量に出力されました3

[6](#easy-footnote-bottom-6-12345)
Code language: CSS (css)

本文中に埋め込まれた脚注リンクが、そのままMarkdownに変換されていたのです。

<span class="easy-footnote">
  <a href="#easy-footnote-bottom-6-12345">6</a>
</span>
Code language: HTML, XML (xml)

加えて、フッター部分にも脚注リストが残っていました。

<ol class="easy-footnotes-wrapper">
  <li>脚注の内容...</li>
</ol>
Code language: HTML, XML (xml)

これらは変換前に除去する必要があります。対象DOMをcloneした後、明示的にセレクタで削除しました。

const clone = targetElement.cloneNode(true);
clone.querySelectorAll('.easy-footnote, a[href^="#easy-footnote"], .easy-footnotes-wrapper').forEach(el => el.remove());
Code language: PHP (php)

脚注リンクと脚注リスト本体の両方を削除することで、ノイズのない変換結果が得られます。

2.5. ファイル名の決め方

ダウンロード機能では、最初タイトルをスラグ化してファイル名にしていました。
しかし長いタイトルや記号を含むタイトルだと、ファイル名が扱いにくくなります。

投稿のslugを優先する方式に変更しました4

const postSlug = document.querySelector('body').className.match(/postid-(\d+)/);
// APIでslugを取得
const filename = slug ? `${slug}.md` : `${titleSlug}.md`;
Code language: JavaScript (javascript)

slugが取得できない場合のみ、タイトルから生成したスラグをフォールバックとして使います。URLに使われているslugなら、短く一意で扱いやすいファイル名になります。

3. 今後の改善余地

フロントJavaScriptの単体テストがまだありません。DOM fixtureを用意して変換結果を比較するテストを追加すれば、リグレッションを防ぎやすくなります。

Turndownは現在CDN経由で読み込んでいます。プラグインにローカル同梱すれば、配布が完全に自己完結します。

設定画面にプレビュー領域を追加して、色設定の変更を即座に確認できるようにするのも有用でしょう。easy-footnotesの除去をデフォルト除外セレクタに含めるかどうかも、再検討の余地があります。

実際のサイトで動かしてみると、想定していなかった構造やプラグインとの組み合わせが次々と見つかります。一つずつ対処していくことで、より多くの環境で使えるプラグインになっていくはずです。

  1. シンタックスハイライト機能を持つプラグインの多くは、highlight.jsなどのライブラリを使用し、コードブロックをpre > span > code.hljsといった独自の構造で出力します。 – HTML to Markdown Converter With Pure JavaScript – Turndown
  2. Turndownは現在バージョン7.2.1が最新で、npmパッケージとして広く利用されています。GitHub上で活発にメンテナンスされており、1000以上のプロジェクトで依存関係として使用されています。 – turndown – npm
  3. Easy Footnotesは、WordPress公式プラグインディレクトリで配布されている脚注プラグインです。ショートコード形式で脚注を挿入でき、ホバー表示とページ下部へのリスト出力に対応しています。 – Easy Footnotes Plugin — WordPress.com
  4. WordPressの投稿スラグ(post slug)はURLに使用される一意の識別子で、通常は投稿タイトルから自動生成されますが、日本語タイトルの場合は手動で英数字のスラグを設定することが推奨されます。スラグは短く、意味を持ち、SEOにも影響する重要な要素です。 – WordPressのバージョン確認とアップデート方法を解説