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>
201 lines
8.2 KiB
Python
Executable File
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"" if a else "_missing_"
|
|
i_cell = f"" 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())
|