mmdflux 2.1.0

Render Mermaid diagrams as Unicode text, ASCII, SVG, and MMDS JSON.
Documentation
#!/usr/bin/env bash
# Generate a filterable web gallery of mmdflux SVG output.
# Renders all fixture × engine × preset permutations; skips invalid combos silently.
#
# Usage:
#   scripts/svg-gallery
#   scripts/svg-gallery --out /tmp/my-gallery
#   scripts/svg-gallery --no-open
#
# Environment overrides (comma-separated):
#   ENGINES="flux-layered,mermaid-layered"
#   PRESETS="basis,straight,polyline,step,smooth-step,curved-step"
#   FAMILIES="flowchart,class"
#   FIXTURES="simple,chain"   # stem substring match

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"

# --- Config (env overrideable) ---

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")

# --- Build ---

printf 'Building mmdflux...\n'
cargo build --quiet --manifest-path "$REPO_ROOT/Cargo.toml" --bin mmdflux
printf 'Output: %s\n\n' "$OUT_DIR"

# --- Render loop ---
# Collect metadata for HTML generation alongside rendering.

MANIFEST=()   # "family|fixture_stem|engine|preset|resolved_routing|resolved_curve|svg_filename"

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}"

    # Optional fixture name filter
    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")
          # Copy fixture source for download (dedup by destination)
          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"

# --- Generate HTML ---

GALLERY_HTML="$OUT_DIR/gallery.html"

# Build JS arrays of distinct values for filter checkboxes
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

# Family checkboxes
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'

# Engine checkboxes
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'

# Preset checkboxes
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

# Emit one card per manifest entry
for entry in "${MANIFEST[@]}"; do
  IFS='|' read -r family stem engine preset resolved_routing resolved_curve svg_file <<< "$entry"

  # Derive badge CSS classes
  fam_class="fam-${family}"
  eng_key="${engine%%-*}"  # flux, mermaid, elk
  eng_class="eng-${eng_key}"
  pre_class="pre-${preset}"
  route_class="route-${resolved_routing}"
  curve_class="curve-${resolved_curve}"

  # Base64-encode fixture source for inline viewing
  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

# JS variables derived from rendered data
printf '<div class="no-results hidden" id="no-results">No matching renders.</div>\n'

} > "$GALLERY_HTML"

# Append JS footer
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