Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save ackkerman/d60a849f7e3a0a63f983e4aa2d561f1e to your computer and use it in GitHub Desktop.

Select an option

Save ackkerman/d60a849f7e3a0a63f983e4aa2d561f1e to your computer and use it in GitHub Desktop.
pre-commitでUIプレビューを自動生成し、READMEを常に最新に保つ仕組み
title slug published-on published-at
pre-commit+playwrightでUIプレビューを自動生成し、READMEを常に最新に保つ仕組み
keep-readme-ui-preview-up-to-date-with-pre-commit-and-playwright
2025-12-12 05:00:00 -0800

UIを触るたびにREADMEのスクリーンショットが古くなる問題は、地味にストレスが溜まる。 毎回手動でキャプチャして差し替えるのも面倒だし、やらなくなるのが目に見えている。

そこで、pre-commitをフックにしてUIプレビューを自動生成し、そのままREADMEに反映させる仕組みを組んだ。 一度作ってしまえば、以降は「いつの間にかREADMEが最新になっている」状態になる。

例えば、こんな感じのUIプレビューがコミットごとに自動更新される。

GitHub上での成果物の見え方

全体の流れ

やっていることはシンプルで、処理の流れはこうなる。

  1. コードやUIを編集する
  2. pre-commitでローカル開発サーバーを起動し、Playwrightでスクリーンショットを撮る
  3. [Optional] 生成されたPNGをBase64でSVGに埋め込む(GitHub表示対策)
  4. README.mdはそのSVG(またはPNG)を参照するだけ

ポイントは 「pre-commitで必ず実行されるが、無駄な再生成はしない」 ところ。

[Optional] なぜSVGに変換するのか

最初は単純にPNGをREADMEに貼っていたが、GitHubではCSSや影、背景の表現が効かない。 UIの雰囲気が伝わりにくく、せっかくのプレビューがのっぺりして見える。

そこで、PNGを Base64でSVGに埋め込み、枠やシャドウをSVG側で表現 する方式に切り替えた。 これならGitHub上でも見た目をコントロールできる。

pre-commit構成

.pre-commit-config.yaml ではローカルフックを2つ定義している。

  • UIプレビュー生成
  • PNG → SVGカード生成
- repo: local
  hooks:
    - id: make-preview
      name: make preview with PREVIEW_OUTPUT
      language: system
      entry: ./scripts/maybe_make_preview.sh
      pass_filenames: false
    - id: generate-web-editor-card
      name: generate web editor card svg
      language: system
      entry: python3 scripts/generate_web_editor_card.py
      pass_filenames: false

ここでは ファイル差分はpre-commitに任せず、スクリプト側で制御 している。

::: details .pre-commit-config.yaml 全体

# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v3.2.0
    hooks:
    -   id: trailing-whitespace
    -   id: end-of-file-fixer
    -   id: check-yaml
    -   id: check-added-large-files
-   repo: local
    hooks:
    -   id: make-preview
        name: make preview with PREVIEW_OUTPUT
        language: system
        entry: ./scripts/maybe_make_preview.sh
        pass_filenames: false
    -   id: generate-web-editor-card
        name: generate web editor card svg
        language: system
        entry: python3 scripts/generate_web_editor_card.py
        pass_filenames: false

:::

無限ループを避けるための工夫

pre-commitで生成物を書き換えると、毎回差分が出てコミットできなくなる。 この問題を避けるために、影響しうるファイル群のハッシュを取る方式を採用した。

scripts/maybe_make_preview.sh の役割は以下。

  • UIに影響するファイルだけを git ls-files で列挙
  • それらのSHA-256をまとめてハッシュ化
  • 前回と同じならプレビュー生成をスキップ
  • 変わっていれば生成し、ハッシュを .preview.hash に保存
CURRENT_HASH=$(printf '%s\0' "${PREVIEW_FILES[@]}" | xargs -0 sha256sum | sha256sum | cut -d ' ' -f1)

if [[ -f "$HASH_FILE" ]] && [[ "$(cat "$HASH_FILE")" == "$CURRENT_HASH" ]]; then
  echo "preview: no relevant changes detected, skipping"
  exit 0
fi

この方式にしてから、pre-commitが「賢くなった」感覚がある。 不要な起動やキャプチャが走らず、体感もかなり軽い。

プレビュー生成の中身

実際のスクリーンショット生成はPythonとPlaywrightに任せている。

  • Pythonでローカル開発サーバーを起動
  • URLが立ち上がるまでポーリング
  • NodeスクリプトでPlaywrightを起動しキャプチャ
  • 終わったらサーバーを確実に終了

preview.py はこのオーケストレーション役だ。

if not wait_for_server(PREVIEW_URL):
    print("Server did not become ready within the timeout window.")
    return 1

subprocess.run(
  ["node", "scripts/capture.mjs", str(OUTPUT_PATH), PREVIEW_URL],
  check=True,
)

Playwright側は極力シンプルにしている。 viewport指定と networkidle 待ちだけで、余計なことはしない。

SVGカード生成

最後に、生成されたPNGをSVGに包む。

scripts/generate_web_editor_card.py では、

  • PNGをBase64エンコード
  • SVGテンプレートに埋め込み
  • 影・角丸・背景をSVG側で定義

という処理をしている。

この段階で「READMEに載せる前提の見た目」を完成させておくのがポイント。 README側はただ <img> で参照するだけになる。

実際に使ってみて

一度この仕組みを入れると、

  • UIを触る
  • そのまま git commit
  • READMEのプレビューが勝手に更新されている

という状態になる。

「ドキュメントは後で直す」が起きにくくなるのが一番の収穫だった。 pre-commitは整形ツールという印象が強いが、ローカルで完結する自動化にはかなり向いている。

UIを持つリポジトリなら、わりと汎用的に使える構成だと思う。


# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: local
hooks:
- id: make-preview
name: make preview with PREVIEW_OUTPUT
language: system
entry: ./scripts/maybe_make_preview.sh
pass_filenames: false
- id: generate-web-editor-card
name: generate web editor card svg
language: system
entry: python3 scripts/generate_web_editor_card.py
pass_filenames: false
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
PNPM ?= pnpm
PYTHON ?= python3
.PHONY: preview
preview:
$(PYTHON) scripts/preview.py
// scripts/capture.mjs
import { chromium } from "playwright";
import { dirname } from "node:path";
import { mkdirSync } from "node:fs";
const [outputPath = "artifacts/web-home.png", previewUrl = "http://localhost:3000", viewportArg] =
process.argv.slice(2);
const parseNumber = (value) => {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isFinite(parsed) ? parsed : null;
};
const parseViewport = (value) => {
if (!value) return null;
const match = value.match(/(\d+)x(\d+)/i);
if (!match) return null;
return { width: Number.parseInt(match[1], 10), height: Number.parseInt(match[2], 10) };
};
const envViewport = {
width: parseNumber(process.env.PREVIEW_WIDTH),
height: parseNumber(process.env.PREVIEW_HEIGHT),
};
const viewport =
parseViewport(viewportArg) ??
(envViewport.width && envViewport.height ? envViewport : null) ??
{ width: 1980, height: 1020 };
mkdirSync(dirname(outputPath), { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport });
await page.goto(previewUrl, { waitUntil: "networkidle" });
await page.screenshot({ path: outputPath, fullPage: true });
await browser.close();
console.log(`Saved preview to ${outputPath}`);
#!/usr/bin/env python3
# scripts/generate_web_editor_card.py
"""Generate assets/web-editor-card.svg from assets/web-editor.png.
This script base64 エンコードした PNG をスタイリング済みの SVG テンプレートに埋め込みます。
"""
from __future__ import annotations
import base64
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PNG_PATH = ROOT / "assets" / "web-editor.png"
SVG_PATH = ROOT / "assets" / "web-editor-card.svg"
# Layout constants
SVG_WIDTH = 2100
SVG_HEIGHT = 1140
FRAME_X = 60
FRAME_Y = 40
IMAGE_WIDTH = 1980
IMAGE_HEIGHT = 1020
OUTLINE_RX = 28
GLOW_RX = 38
def main() -> int:
if not PNG_PATH.exists():
raise FileNotFoundError(f"PNG not found: {PNG_PATH}")
encoded = base64.b64encode(PNG_PATH.read_bytes()).decode("ascii")
svg = f"""
<svg width="{SVG_WIDTH}" height="{SVG_HEIGHT}" viewBox="0 0 {SVG_WIDTH} {SVG_HEIGHT}" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Web Video Editor UI Preview</title>
<desc id="desc">Framed preview of the web video editor interface with rounded corners and soft shadow.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f172a" stop-opacity="0.95" />
<stop offset="50%" stop-color="#0b1224" stop-opacity="0.92" />
<stop offset="100%" stop-color="#0a0f1f" stop-opacity="0.94" />
</linearGradient>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="24" stdDeviation="28" flood-color="rgba(8,15,35,0.38)" />
<feDropShadow dx="0" dy="6" stdDeviation="18" flood-color="rgba(8,15,35,0.22)" />
</filter>
<clipPath id="frame">
<rect x="{FRAME_X}" y="{FRAME_Y}" width="{IMAGE_WIDTH}" height="{IMAGE_HEIGHT}" rx="{OUTLINE_RX}" ry="{OUTLINE_RX}" />
</clipPath>
</defs>
<g filter="url(#shadow)">
<rect x="{FRAME_X}" y="{FRAME_Y}" width="{IMAGE_WIDTH}" height="{IMAGE_HEIGHT}" rx="{OUTLINE_RX}" ry="{OUTLINE_RX}" fill="#0f172a" />
<image
href="data:image/png;base64,{encoded}"
x="{FRAME_X}"
y="{FRAME_Y}"
width="{IMAGE_WIDTH}"
height="{IMAGE_HEIGHT}"
preserveAspectRatio="xMidYMid slice"
clip-path="url(#frame)"
/>
<rect x="{FRAME_X}" y="{FRAME_Y}" width="{IMAGE_WIDTH}" height="{IMAGE_HEIGHT}" rx="{OUTLINE_RX}" ry="{OUTLINE_RX}" fill="none" stroke="rgba(255,255,255,0.06)" stroke-width="2" />
</g>
</svg>
"""
SVG_PATH.write_text(svg.strip() + "\n", encoding="utf-8")
print(f"Wrote {SVG_PATH.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env bash
# scripts/maybe_make_preview.sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
HASH_FILE="${HASH_FILE:-$ROOT_DIR/.preview.hash}"
OUTPUT_PATH="${PREVIEW_OUTPUT:-./assets/web-editor.png}"
# ファイル一覧を収集(プレビューに影響しうるものに限定)
mapfile -t PREVIEW_FILES < <(git -C "$ROOT_DIR" ls-files -z -- \
"apps/web" "apps/lp" "packages" "scripts" "docs" \
"Makefile" "package.json" "pnpm-lock.yaml" "README.md" | tr '\0' '\n')
if [[ ${#PREVIEW_FILES[@]} -eq 0 ]]; then
echo "preview: no tracked files matched, running preview" >&2
env PREVIEW_OUTPUT="$OUTPUT_PATH" make preview
exit 0
fi
CURRENT_HASH=$(printf '%s\0' "${PREVIEW_FILES[@]}" | xargs -0 sha256sum | sha256sum | cut -d ' ' -f1)
if [[ -f "$HASH_FILE" ]] && [[ "$(cat "$HASH_FILE")" == "$CURRENT_HASH" ]]; then
echo "preview: no relevant changes detected, skipping" >&2
exit 0
fi
echo "preview: changes detected, generating screenshot..." >&2
env PREVIEW_OUTPUT="$OUTPUT_PATH" make preview
echo "$CURRENT_HASH" > "$HASH_FILE"
echo "preview: hash recorded to $(basename "$HASH_FILE")" >&2
#!/usr/bin/env python3
# scripts/preview.py
"""
Start a web app locally and generate a preview screenshot.
"""
from __future__ import annotations
import os
import shlex
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Optional
ROOT = Path(__file__).resolve().parent.parent
ARTIFACTS = ROOT / "artifacts"
APP = os.environ.get("PREVIEW_APP", "web")
DEFAULT_PORT = os.environ.get("PREVIEW_PORT") or ("3000" if APP == "web" else "4321")
PREVIEW_URL = os.environ.get("PREVIEW_URL", f"http://localhost:{DEFAULT_PORT}")
OUTPUT_PATH = Path(os.environ.get("PREVIEW_OUTPUT", ARTIFACTS / f"{APP}-home.png"))
COMMANDS = {
"web": [
"pnpm",
"--filter",
"web",
"dev",
"--hostname",
"0.0.0.0",
"--port",
DEFAULT_PORT,
],
"lp": [
"pnpm",
"--filter",
"lp",
"dev",
"--host",
"0.0.0.0",
"--port",
DEFAULT_PORT,
],
}
def wait_for_server(url: str, timeout: int = 60) -> bool:
"""Poll the url until it responds or timeouts."""
end = time.time() + timeout
while time.time() < end:
try:
with urllib.request.urlopen(url) as response:
if 200 <= response.status < 500:
return True
except urllib.error.URLError:
time.sleep(1)
return False
def resolve_command(app: str) -> list[str]:
if app in COMMANDS:
return COMMANDS[app]
override = os.environ.get("PREVIEW_COMMAND")
if override:
return shlex.split(override)
raise ValueError(
f"Unsupported PREVIEW_APP '{app}'. Set PREVIEW_COMMAND to pass a custom dev command."
)
def main() -> int:
ARTIFACTS.mkdir(parents=True, exist_ok=True)
command = resolve_command(APP)
server: Optional[subprocess.Popen[str]] = None
try:
server = subprocess.Popen(
command,
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
)
print(f"Starting {APP} development server for preview...")
if not wait_for_server(PREVIEW_URL):
print("Server did not become ready within the timeout window.")
return 1
print(f"Server is ready at {PREVIEW_URL}. Capturing screenshot...")
subprocess.run(
["node", "scripts/capture.mjs", str(OUTPUT_PATH), PREVIEW_URL],
cwd=ROOT,
check=True,
)
print(f"Preview written to {OUTPUT_PATH}")
return 0
finally:
if server and server.poll() is None:
server.terminate()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
if __name__ == "__main__":
sys.exit(main())
@ackkerman
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment