Files
honeyDueKMP/scripts/build_parity_gallery.py
Trey T 3944223a5e P4: gitea-renderable parity-gallery-grid.md (markdown with inline images)
Gitea serves raw .html with Content-Type: text/plain for security, so the
HTML gallery only renders via `open` locally or external static hosting.
Add a parallel markdown version that gitea's /src/ view renders natively
with inline images.

View: https://gitea.treytartt.com/admin/honeyDueKMP/src/branch/rc/android-ios-parity/docs/parity-gallery-grid.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:59:06 -05:00

201 lines
8.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Build docs/parity-gallery.html pairing iOS + Android goldens per screen.
The output is a single self-contained HTML file that gitea's raw-file view
can render directly. Relative <img> paths resolve within the repo so the
images load without any webserver.
Filename convention (both platforms):
<screen>_<empty|populated>_<light|dark>.png
iOS snapshots live under
iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests/
with swift-snapshot-testing's `test_<name>.<nameArg>.png` prefix which we
strip to align with Android's plain `<name>.png`.
Usage: python3 scripts/build_parity_gallery.py
"""
from __future__ import annotations
import glob
import html
import os
import re
import sys
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ANDROID_DIR = "composeApp/src/androidUnitTest/roborazzi"
IOS_DIR = "iosApp/HoneyDueTests/__Snapshots__/SnapshotGalleryTests"
OUT_HTML = "docs/parity-gallery.html"
OUT_MD = "docs/parity-gallery-grid.md"
# swift-snapshot-testing names files "test_<func>.<name>.png" — strip prefix
IOS_NAME_RE = re.compile(r"^test_[^.]+\.(.+)\.png$")
def canonical_name(platform: str, path: str) -> str | None:
"""Return the canonical <screen>_<state>_<mode> key or None if unparseable."""
base = os.path.basename(path)
if platform == "ios":
m = IOS_NAME_RE.match(base)
if not m:
return None
return m.group(1)
return base[:-4] if base.endswith(".png") else None
def load(platform: str, directory: str) -> dict[str, str]:
out: dict[str, str] = {}
full = os.path.join(REPO_ROOT, directory)
if not os.path.isdir(full):
return out
for p in glob.glob(f"{full}/**/*.png", recursive=True):
key = canonical_name(platform, p)
if key is None:
continue
out[key] = os.path.relpath(p, os.path.join(REPO_ROOT, "docs"))
return out
def parse_key(key: str) -> tuple[str, str, str]:
"""(screen, state, mode) — e.g. 'login_empty_dark' → ('login', 'empty', 'dark')."""
m = re.match(r"^(.+)_(empty|populated)_(light|dark)$", key)
if m:
return m.group(1), m.group(2), m.group(3)
return key, "?", "?"
def write_markdown(android: dict[str, str], ios: dict[str, str], screens: list[str]) -> None:
"""Emit a gitea-renderable grid as markdown tables.
Gitea serves .html as text/plain (security), but renders .md natively at
its /src/ URL with inline images. This output is the browser-viewable one.
"""
out = os.path.join(REPO_ROOT, OUT_MD)
os.makedirs(os.path.dirname(out), exist_ok=True)
# Markdown image paths are relative to the .md file, which lives in docs/.
# We already compute relative-to-docs paths in load(); those apply here too.
with open(out, "w", encoding="utf-8") as f:
f.write("# honeyDue parity gallery\n\n")
f.write(f"*{len(android)} Android · {len(ios)} iOS · {len(screens)} screens*\n\n")
f.write("Auto-generated by `scripts/build_parity_gallery.py` — do not hand-edit.\n\n")
f.write("See [parity-gallery.md](parity-gallery.md) for the workflow guide.\n\n")
f.write("## Screens\n\n")
for s in screens:
f.write(f"- [{s}](#{s.replace('_', '-')})\n")
f.write("\n---\n\n")
for screen in screens:
anchor = screen.replace("_", "-")
f.write(f"## {screen}<a id='{anchor}'></a>\n\n")
f.write("| State / Mode | Android | iOS |\n")
f.write("|---|---|---|\n")
for state in ("empty", "populated"):
for mode in ("light", "dark"):
key = f"{screen}_{state}_{mode}"
a = android.get(key)
i = ios.get(key)
a_cell = f"![]({a})" if a else "_missing_"
i_cell = f"![]({i})" if i else "_missing_"
f.write(f"| **{state}** / {mode} | {a_cell} | {i_cell} |\n")
f.write("\n[top](#honeydue-parity-gallery)\n\n---\n\n")
print(f"wrote {OUT_MD}")
def main() -> int:
android = load("android", ANDROID_DIR)
ios = load("ios", IOS_DIR)
keys = sorted(set(android) | set(ios))
screens = sorted({parse_key(k)[0] for k in keys})
write_markdown(android, ios, screens)
out_path = os.path.join(REPO_ROOT, OUT_HTML)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
f.write(PAGE_HEAD)
f.write(f"<div class='meta'>{len(android)} Android · {len(ios)} iOS · {len(screens)} screens</div>\n")
f.write("<div class='nav'>")
f.write(" ".join(f"<a href='#{html.escape(s)}'>{html.escape(s)}</a>" for s in screens))
f.write("</div>\n")
f.write(
"<div class='grid-header'>"
"<div class='label'></div>"
"<div>Android</div>"
"<div>iOS</div>"
"</div>\n"
)
for screen in screens:
f.write(f"<div class='screen' id='{html.escape(screen)}'>\n")
f.write(f" <h2>{html.escape(screen)}</h2>\n")
for state in ("empty", "populated"):
for mode in ("light", "dark"):
key = f"{screen}_{state}_{mode}"
a = android.get(key)
i = ios.get(key)
a_cell = (
f"<img loading='lazy' src='{html.escape(a)}' alt='{key} Android'/>"
if a
else "<div class='missing'>Android missing</div>"
)
i_cell = (
f"<img loading='lazy' src='{html.escape(i)}' alt='{key} iOS'/>"
if i
else "<div class='missing'>iOS missing</div>"
)
f.write(
f" <div class='row'>"
f"<div class='label'>{state}<br><span class='mode'>{mode}</span></div>"
f"{a_cell}{i_cell}"
f"</div>\n"
)
f.write("</div>\n")
f.write(PAGE_FOOT)
print(f"wrote {OUT_HTML}: {len(screens)} screens, {len(android)} Android + {len(ios)} iOS images")
return 0
PAGE_HEAD = """<!DOCTYPE html>
<html lang='en'><head><meta charset='utf-8'>
<title>honeyDue parity gallery</title>
<style>
:root { color-scheme: dark; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117; color: #e6edf3; margin: 0; padding: 20px; }
h1 { margin: 0 0 4px; font-size: 20px; }
.meta { color: #8b949e; font-size: 13px; margin-bottom: 16px; }
.nav { position: sticky; top: 0; background: #0d1117; padding: 8px 0; margin-bottom: 16px;
border-bottom: 1px solid #30363d; font-size: 12px; z-index: 10; }
.nav a { color: #79c0ff; margin-right: 10px; text-decoration: none; white-space: nowrap; }
.nav a:hover { text-decoration: underline; }
.grid-header { display: grid; grid-template-columns: 120px 1fr 1fr; gap: 12px;
padding: 0 12px 8px; color: #8b949e; font-size: 12px; font-weight: 600;
position: sticky; top: 38px; background: #0d1117; z-index: 9;
border-bottom: 1px solid #30363d; }
.screen { background: #161b22; border-radius: 8px; padding: 12px; margin-bottom: 20px; }
.screen h2 { margin: 0 0 8px; font-size: 16px; color: #e6edf3; }
.row { display: grid; grid-template-columns: 120px 1fr 1fr; gap: 12px;
margin-bottom: 8px; align-items: start; }
.label { font-size: 12px; color: #c9d1d9; padding-top: 4px; }
.label .mode { color: #8b949e; font-weight: 400; }
.row img { width: 100%; border: 1px solid #30363d; border-radius: 4px; display: block; }
.missing { display: flex; align-items: center; justify-content: center;
min-height: 200px; background: #21262d; border: 1px dashed #484f58;
border-radius: 4px; color: #8b949e; font-size: 12px; }
</style></head><body>
<h1>honeyDue parity gallery</h1>
"""
PAGE_FOOT = """<script>
// Ctrl/Cmd-F friendly: expose screen names in the document title on anchor change.
window.addEventListener('hashchange', () => {
const s = location.hash.slice(1);
document.title = s ? `${s} · parity` : 'honeyDue parity gallery';
});
</script>
</body></html>
"""
if __name__ == "__main__":
sys.exit(main())