set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
MMDFLUX_BIN="$REPO_ROOT/target/debug/mmdflux"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/scripts/out/svg-gallery-$(date +%Y%m%d-%H%M%S)}"
AUTO_OPEN=1
BUILD_STAMP="$(date +%Y%m%d-%H%M%S)"
while [[ $# -gt 0 ]]; do
case "$1" in
--out) OUT_DIR="$2"; shift 2 ;;
--no-open) AUTO_OPEN=0; shift ;;
*) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;;
esac
done
mkdir -p "$OUT_DIR" "$OUT_DIR/sources"
split_csv() { printf '%s' "$1" | tr ',' '\n' | grep -v '^$'; }
read_csv_array() {
local -a result=()
while IFS= read -r item; do
[[ -n "$item" ]] && result+=("$item")
done < <(split_csv "$1")
printf '%s\n' "${result[@]}"
}
preset_resolved_style() {
case "$1" in
straight) echo "direct|linear-sharp" ;;
polyline) echo "polyline|linear-sharp" ;;
step) echo "orthogonal|linear-sharp" ;;
smooth-step) echo "orthogonal|linear-rounded" ;;
curved-step) echo "orthogonal|basis" ;;
basis) echo "polyline|basis" ;;
*) echo "unknown|unknown" ;;
esac
}
ENGINES_CSV="${ENGINES:-flux-layered,mermaid-layered}"
PRESETS_CSV="${PRESETS:-basis,straight,polyline,step,smooth-step,curved-step}"
FAMILIES_CSV="${FAMILIES:-flowchart,class}"
ENGINES=()
while IFS= read -r e; do ENGINES+=("$e"); done < <(read_csv_array "$ENGINES_CSV")
PRESETS=()
while IFS= read -r p; do PRESETS+=("$p"); done < <(read_csv_array "$PRESETS_CSV")
FAMILIES=()
while IFS= read -r f; do FAMILIES+=("$f"); done < <(read_csv_array "$FAMILIES_CSV")
printf 'Building mmdflux...\n'
cargo build --quiet --manifest-path "$REPO_ROOT/Cargo.toml" --bin mmdflux
printf 'Output: %s\n\n' "$OUT_DIR"
MANIFEST=()
n_rendered=0
n_skipped=0
for family in "${FAMILIES[@]}"; do
fixture_dir="$REPO_ROOT/tests/fixtures/$family"
[[ -d "$fixture_dir" ]] || continue
while IFS= read -r fixture_path; do
fixture_name="$(basename "$fixture_path")"
stem="${fixture_name%.mmd}"
if [[ -n "${FIXTURES:-}" ]]; then
match=0
while IFS= read -r pat; do
[[ "$stem" == *"$pat"* ]] && match=1 && break
done < <(split_csv "$FIXTURES")
[[ $match -eq 1 ]] || continue
fi
for engine in "${ENGINES[@]}"; do
for preset in "${PRESETS[@]}"; do
slug="${family}_${stem}_${engine}_${preset}"
out_svg="$OUT_DIR/${slug}.svg"
if "$MMDFLUX_BIN" \
--format svg \
--layout-engine "$engine" \
--edge-preset "$preset" \
"$fixture_path" >"$out_svg" 2>/dev/null
then
IFS='|' read -r resolved_routing resolved_curve <<< "$(preset_resolved_style "$preset")"
MANIFEST+=("${family}|${stem}|${engine}|${preset}|${resolved_routing}|${resolved_curve}|${slug}.svg")
src_dest="$OUT_DIR/sources/${family}_${stem}.mmd"
[[ -f "$src_dest" ]] || cp "$fixture_path" "$src_dest"
n_rendered=$((n_rendered + 1))
printf ' OK %s\n' "$slug"
else
rm -f "$out_svg"
n_skipped=$((n_skipped + 1))
printf ' SKIP %s\n' "$slug"
fi
done
done
done < <(find "$fixture_dir" -maxdepth 1 -type f -name '*.mmd' | sort)
done
printf '\nRendered: %d Skipped (invalid combos): %d\n' "$n_rendered" "$n_skipped"
GALLERY_HTML="$OUT_DIR/gallery.html"
js_engines="$(printf '%s\n' "${ENGINES[@]}" | sort -u | awk '{printf "\"%s\",", $0}' | sed 's/,$//')"
js_presets="$(printf '%s\n' "${PRESETS[@]}" | sort -u | awk '{printf "\"%s\",", $0}' | sed 's/,$//')"
js_families="$(printf '%s\n' "${FAMILIES[@]}" | sort -u | awk '{printf "\"%s\",", $0}' | sed 's/,$//')"
{
cat <<'HTML_HEAD'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mmdflux SVG Gallery</title>
<style>
:root {
--bg: #0b1020; --fg: #e8ebf3; --muted: #98a1b3;
--card: #141a2b; --border: #2a334c;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg);
font: 14px/1.5 ui-sans-serif, -apple-system, Segoe UI, sans-serif; }
header { padding: 16px 20px 0; max-width: 1800px; margin: 0 auto; }
h1 { margin: 0 0 4px; font-size: 22px; }
.subtitle { color: var(--muted); margin: 0 0 12px; font-size: 13px; }
.controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
padding: 10px 20px; max-width: 1800px; margin: 0 auto;
border-bottom: 1px solid var(--border); }
.controls input[type=text] {
background: #111827; color: var(--fg); border: 1px solid var(--border);
border-radius: 8px; padding: 6px 10px; min-width: 240px; }
.controls label { color: var(--muted); display: flex; align-items: center; gap: 4px;
cursor: pointer; user-select: none; font-size: 13px; }
.sep { width: 1px; height: 20px; background: var(--border); }
.count { color: var(--muted); font-size: 12px; margin-left: auto; }
main { max-width: 1800px; margin: 0 auto; padding: 14px 20px; }
.grid { display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px; }
.card { background: var(--card); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden; }
.card-header { padding: 8px 10px; display: flex; flex-wrap: wrap;
gap: 5px; align-items: center; border-bottom: 1px solid var(--border); }
.name { font-family: ui-monospace, monospace; font-size: 12px;
font-weight: 600; flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge { font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .4px; border-radius: 999px; padding: 2px 7px;
border: 1px solid; white-space: nowrap; }
/* family */
.fam-flowchart { color: #bae6fd; background: #0c4a6e44; border-color: #0ea5e9; }
.fam-class { color: #fed7aa; background: #7c2d1244; border-color: #f97316; }
/* engine */
.eng-flux { color: #d9f99d; background: #36531444; border-color: #4d7c0f; }
.eng-mermaid { color: #e9d5ff; background: #4c1d9544; border-color: #7c3aed; }
/* preset */
.pre-basis { color: #bfdbfe; background: #1e3a8a44; border-color: #3b82f6; }
.pre-straight { color: #fde68a; background: #78350f44; border-color: #d97706; }
.pre-polyline { color: #ddd6fe; background: #312e8144; border-color: #6366f1; }
.pre-step { color: #fca5a5; background: #7f1d1d44; border-color: #ef4444; }
.pre-smooth-step { color: #a7f3d0; background: #064e3b44; border-color: #10b981; }
.pre-curved-step { color: #67e8f9; background: #164e6344; border-color: #06b6d4; }
/* resolved routing */
.route-direct { color: #fef3c7; background: #78350f44; border-color: #f59e0b; }
.route-polyline { color: #c7d2fe; background: #312e8144; border-color: #6366f1; }
.route-orthogonal { color: #ccfbf1; background: #134e4a44; border-color: #14b8a6; }
/* resolved curve */
.curve-basis { color: #bfdbfe; background: #1e3a8a44; border-color: #3b82f6; }
.curve-linear-sharp { color: #fde68a; background: #78350f44; border-color: #d97706; }
.curve-linear-rounded { color: #a7f3d0; background: #064e3b44; border-color: #10b981; }
.card-body { background: #fff; line-height: 0; }
.card-body img { width: 100%; height: 260px; object-fit: contain;
background: #fff; display: block; }
.card-actions { padding: 6px 10px; display: flex; gap: 10px; align-items: center;
border-top: 1px solid var(--border); }
.source-toggle { background: transparent; color: var(--muted); border: 1px solid var(--border);
border-radius: 6px; padding: 3px 10px; font-size: 12px; cursor: pointer;
transition: color .15s, border-color .15s; }
.source-toggle:hover { color: var(--fg); border-color: var(--fg); }
.download-link { color: var(--muted); font-size: 12px; text-decoration: none;
transition: color .15s; }
.download-link:hover { color: var(--fg); }
.card-source { border-top: 1px solid var(--border); max-height: 300px; overflow: auto; }
.card-source pre { margin: 0; padding: 10px; font-size: 12px; line-height: 1.5;
font-family: ui-monospace, monospace; color: #c9d1d9;
background: #0d1117; white-space: pre-wrap; word-break: break-word; }
.hidden { display: none !important; }
.no-results { color: var(--muted); padding: 40px; text-align: center; font-size: 15px; }
</style>
</head>
<body>
<header>
<h1>mmdflux SVG Gallery</h1>
<p class="subtitle" id="subtitle">Loading…</p>
</header>
<div class="controls">
<input id="search" type="text" placeholder="Filter fixture name…" oninput="applyFilters()" />
<span class="sep"></span>
HTML_HEAD
for family in "${FAMILIES[@]}"; do
printf ' <label><input type="checkbox" class="fam-cb" value="%s" checked onchange="applyFilters()"> %s</label>\n' "$family" "$family"
done
printf ' <span class="sep"></span>\n'
for engine in "${ENGINES[@]}"; do
printf ' <label><input type="checkbox" class="eng-cb" value="%s" checked onchange="applyFilters()"> %s</label>\n' "$engine" "$engine"
done
printf ' <span class="sep"></span>\n'
for preset in "${PRESETS[@]}"; do
printf ' <label><input type="checkbox" class="pre-cb" value="%s" checked onchange="applyFilters()"> %s</label>\n' "$preset" "$preset"
done
cat <<'HTML_GRID_START'
<span class="count" id="count"></span>
</div>
<main>
<div class="grid" id="grid">
HTML_GRID_START
for entry in "${MANIFEST[@]}"; do
IFS='|' read -r family stem engine preset resolved_routing resolved_curve svg_file <<< "$entry"
fam_class="fam-${family}"
eng_key="${engine%%-*}" eng_class="eng-${eng_key}"
pre_class="pre-${preset}"
route_class="route-${resolved_routing}"
curve_class="curve-${resolved_curve}"
b64_source="$(base64 < "$REPO_ROOT/tests/fixtures/$family/$stem.mmd" | tr -d '\n')"
source_file="sources/${family}_${stem}.mmd"
cat <<CARD
<div class="card" data-family="${family}" data-engine="${engine}" data-preset="${preset}" data-fixture="${stem}" data-routing="${resolved_routing}" data-curve="${resolved_curve}" data-source="${b64_source}">
<div class="card-header">
<span class="name" title="${stem}">${stem}</span>
<span class="badge ${fam_class}">${family}</span>
<span class="badge ${eng_class}">${engine}</span>
<span class="badge ${pre_class}">${preset}</span>
<span class="badge ${route_class}">${resolved_routing}</span>
<span class="badge ${curve_class}">${resolved_curve}</span>
</div>
<div class="card-body">
<img src="${svg_file}?v=${BUILD_STAMP}" loading="lazy" alt="${stem} ${engine} ${preset}" />
</div>
<div class="card-actions">
<button class="source-toggle" onclick="toggleSource(this)">View Source</button>
<a class="download-link" href="${source_file}" download="${stem}.mmd">Download .mmd</a>
</div>
<div class="card-source hidden">
<pre><code></code></pre>
</div>
</div>
CARD
done
printf '<div class="no-results hidden" id="no-results">No matching renders.</div>\n'
} > "$GALLERY_HTML"
cat >> "$GALLERY_HTML" <<HTML_FOOT
</div>
</main>
<script>
const total = ${n_rendered};
function toggleSource(btn) {
const card = btn.closest('.card');
const sourceEl = card.querySelector('.card-source');
if (sourceEl.classList.contains('hidden')) {
if (!sourceEl.dataset.loaded) {
const source = atob(card.dataset.source);
sourceEl.querySelector('code').textContent = source;
sourceEl.dataset.loaded = '1';
}
sourceEl.classList.remove('hidden');
btn.textContent = 'Hide Source';
} else {
sourceEl.classList.add('hidden');
btn.textContent = 'View Source';
}
}
function applyFilters() {
const search = document.getElementById('search').value.trim().toLowerCase();
const fams = new Set([...document.querySelectorAll('.fam-cb:checked')].map(cb => cb.value));
const engs = new Set([...document.querySelectorAll('.eng-cb:checked')].map(cb => cb.value));
const pres = new Set([...document.querySelectorAll('.pre-cb:checked')].map(cb => cb.value));
let visible = 0;
document.querySelectorAll('.card').forEach(card => {
const show = fams.has(card.dataset.family)
&& engs.has(card.dataset.engine)
&& pres.has(card.dataset.preset)
&& (!search || card.dataset.fixture.includes(search));
card.classList.toggle('hidden', !show);
if (show) visible++;
});
document.getElementById('count').textContent = visible + ' / ' + total;
document.getElementById('no-results').classList.toggle('hidden', visible > 0);
document.getElementById('subtitle').textContent =
total + ' renders — ' + ${n_skipped} + ' combos skipped (invalid engine/preset)';
}
applyFilters();
</script>
</body>
</html>
HTML_FOOT
printf '\nGallery: %s\n' "$GALLERY_HTML"
if [[ $AUTO_OPEN -eq 1 ]]; then
if command -v open &>/dev/null; then
open "$GALLERY_HTML"
elif command -v xdg-open &>/dev/null; then
xdg-open "$GALLERY_HTML"
fi
fi