MENU

FastAPI + Jinja2 + difflibでgit風のdiff表示UIを実装する

WordPressブログの修正ツールを作っていて、AIが生成した修正候補を確認するモーダルのUIを改善した。もともと元記事とAI修正候補を2カラムの <pre> で並べていたが、WordPressのraw HTMLには <!-- wp:heading --> といったブロックコメントやHTMLタグが混在しており、本文の差異がほとんど見えない状態だった。

この記事では、その改善のために実装した3つのコンポーネントを整理する。

  • Python正規表現によるWordPressブロックHTMLのプレーンテキスト化
  • difflib.ndiff を使ったbefore/after行単位diff計算
  • Jinja2 + CSSによるgit diffライクなunified diff表示
目次

対象環境

  • Python 3.x(標準ライブラリ difflib 使用)
  • FastAPI(Webアプリフレームワーク)
  • Jinja2(テンプレートエンジン)
  • WordPress REST API(content.raw 取得元)
  • 実装対象ファイル:webapp.py / post_edit.html / app.css

課題背景:修正確認モーダルのHTMLタグ混在問題

WordPress REST APIから取得した content.raw は、ブロックエディタ形式のHTMLで返ってくる。

<!-- wp:heading -->
<h2 class="wp-block-heading">見出しテキスト</h2>
<!-- /wp:heading -->

<!-- wp:paragraph -->
<p>本文テキスト</p>
<!-- /wp:paragraph -->

この形式のまま2カラム表示すると、比較したいのは「本文の言葉」なのに、視線はHTMLタグとブロックコメントに吸われ続ける。変更点を確認するUIがノイズだらけという本末転倒な状態だった。

解決方針:HTMLストリップ+difflibによるdiff生成

問題を2段階で解決した。

  1. HTMLストリップ:ブロックコメントとHTMLタグを正規表現で除去し、プレーンテキスト化する
  2. Unified diff表示:プレーンテキスト化した現在内容と修正候補をdifflibで比較し、git diffライクな1カラム表示に切り替える

ここがポイント: 並列比較からunified diff表示に切り替えることで、変更行だけにフォーカスできる。HTMLノイズを先に除いてからdiffを取ることで、本文の実際の変更点が浮き上がる。

実装①:_strip_wp_html() でWordPressブロックHTMLをプレーンテキスト化

webapp.py に追加した関数。正規表現で2ステップ処理する。

import re

def _strip_wp_html(html: str) -> str:
    # WordPressブロックコメントを除去
    text = re.sub(r'<!--\s*/?\s*wp:[^>]*-->', '', html)
    # 残りのHTMLタグを除去
    text = re.sub(r'<[^>]+>', '', text)
    # 空行を正規化
    lines = [line.strip() for line in text.splitlines()]
    lines = [line for line in lines if line]
    return '\n'.join(lines)

注意点

  • WordPressのブロックコメント形式(<!-- wp:〜 -->)は将来変わる可能性があり、このパターンの保守が必要になることがある
  • <[^>]+> でHTMLタグを除去するが、<script><style> 内のテキストが残る構造の場合は別途処理が必要

実装②:_compute_diff_lines() でndiffによるdiff計算

difflib.ndiff() は行リストを受け取り、各行に以下のプレフィックスを付けて返す。

  • - :削除行
  • + :追加行
  • :同一行
  • ? :文字レベルの差異ヒント(表示には不要)
import difflib

def _compute_diff_lines(before: str, after: str) -> list[dict]:
    before_lines = before.splitlines()
    after_lines = after.splitlines()

    diff = difflib.ndiff(before_lines, after_lines)

    result = []
    for line in diff:
        if line.startswith('- '):
            result.append({'type': 'remove', 'text': line[2:]})
        elif line.startswith('+ '):
            result.append({'type': 'add', 'text': line[2:]})
        elif line.startswith('  '):
            result.append({'type': 'same', 'text': line[2:]})
        # '? ' プレフィックスのヒント行は無視
    return result

? 行は文字単位ハイライトに活用もできるが、行単位の色分けで十分だったため除外している。

FastAPIのエンドポイント側では、HTMLストリップ後のテキストをdiff関数に渡す。

current_text = _strip_wp_html(post_content_raw)
diff_lines = _compute_diff_lines(current_text, ai_suggestion_text)

AI生成の修正候補はMarkdown形式で返ることが多い。HTMLストリップ後のプレーンテキストとMarkdownは構造が近いため、そのまま比較しやすくなるという実用上のメリットがある(すべてのケースで保証されるわけではない)。

テンプレート側:Jinja2でdiff_linesをレンダリング

post_edit.html のモーダル内で diff_lines を受け取り、type に応じたCSSクラスを付与して表示する。

<div class="diff-container">
  {% for line in diff_lines %}
    <div class="diff-line diff-{{ line.type }}">
      {% if line.type == 'remove' %}-{% elif line.type == 'add' %}+{% else %} {% endif %}
      {{ line.text }}
    </div>
  {% endfor %}
</div>

typeremove / add / same の3種類なので、CSSクラスも diff-remove / diff-add / diff-same の3パターンで対応できる。フロントエンドフレームワーク不要でシンプルに実装できる点がJinja2アプローチのメリット。

CSS:git diffライクな色分けスタイリング

app.css に追加。削除行は赤系、追加行は緑系で色分けする。

.diff-container {
  font-family: monospace;
  font-size: 0.875rem;
  line-height: 1.6;
  overflow-x: auto;
}

.diff-line {
  padding: 2px 8px;
  white-space: pre-wrap;
  word-break: break-all;
}

.diff-remove {
  background-color: color-mix(in srgb, red 15%, white);
  color: #c0392b;
}

.diff-add {
  background-color: color-mix(in srgb, green 15%, white);
  color: #27ae60;
}

.diff-same {
  background-color: transparent;
  color: inherit;
}

注意color-mix(in srgb, ...) は比較的新しいCSS構文のため、対応ブラウザを確認する必要がある(要外部確認)。古い環境への対応が必要な場合は rgba() や固定カラーコードへのフォールバックを検討する。

/* フォールバック例 */
.diff-remove { background-color: #fde8e8; color: #c0392b; }
.diff-add    { background-color: #e8f8e8; color: #27ae60; }

マージ時の手順

作業中の変更がある状態でmainブランチの変更を取り込む必要があったため、stash → fast-forward merge → stash pop の手順を使った。コンフリクトなしで解決できた。

git stash
git merge main   # fast-forward
git stash pop

変更前・変更後のUI比較

項目変更前変更後
レイアウト2カラム並列 <pre> 表示1カラム unified diff
表示内容HTMLタグ・ブロックコメント含むrawプレーンテキスト化済み
差異の見え方目視で探す必要あり赤/緑で変更行が明示
実装ファイル数3ファイル(webapp.py / post_edit.html / app.css)

まとめ・応用範囲

今回の改善で解決したのは「HTMLタグが混在するコンテンツをそのまま比較しようとしていた」というミスマッチ。HTMLをストリップしてからdiffを取るという順序が核心で、実装自体はシンプルだった。

他に使えるシーン

  • CMSやリッチテキストエディタのraw出力を比較するあらゆる場面
  • 設定ファイルや構造化テキストのbefore/after確認UI
  • フロントエンドフレームワークなしで動く管理ツールへのdiff機能追加

残る課題は2点。WordPressのブロックコメントパターンが将来変わった場合の正規表現保守と、color-mix() のブラウザ互換性確認。どちらも今すぐ問題になるわけではないが、定期的に見直したいポイントになる。

参照リンク

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)

目次