|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Render pana score output as a readable ASCII terminal report and generate an HTML report.""" |
| 3 | +import html |
| 4 | +import re |
| 5 | +import sys |
| 6 | +import textwrap |
| 7 | + |
| 8 | + |
| 9 | +def _bar_ascii(label, got, total): |
| 10 | + pct = got / total if total else 0 |
| 11 | + filled = int(pct * 20) |
| 12 | + return f" {label:<45} [{'█' * filled + '░' * (20 - filled)}] {got}/{total}" |
| 13 | + |
| 14 | + |
| 15 | +def _bar_html(label, got, total): |
| 16 | + pct = got / total if total else 0 |
| 17 | + pct_int = int(pct * 100) |
| 18 | + color = "#4caf50" if pct >= 0.8 else "#ff9800" if pct >= 0.5 else "#f44336" |
| 19 | + return ( |
| 20 | + f'<div class="bar-row">' |
| 21 | + f'<span class="bar-label">{html.escape(label)}</span>' |
| 22 | + f'<div class="bar-track"><div class="bar-fill" style="width:{pct_int}%;background:{color};"></div></div>' |
| 23 | + f'<span class="bar-score">{got}/{total}</span>' |
| 24 | + f'</div>' |
| 25 | + ) |
| 26 | + |
| 27 | + |
| 28 | +def render_ascii(text): |
| 29 | + """Render score markdown as ASCII terminal output.""" |
| 30 | + W = 72 |
| 31 | + lines = [] |
| 32 | + |
| 33 | + def bar(label, got, total): |
| 34 | + return _bar_ascii(label, got, total) |
| 35 | + |
| 36 | + lines.append("=" * W) |
| 37 | + lines.append(" PACKAGE SCORE REPORT".center(W)) |
| 38 | + lines.append("=" * W) |
| 39 | + |
| 40 | + m = re.search(r'Points:\s*(\d+)\s*/\s*(\d+)', text) |
| 41 | + if m: |
| 42 | + got, total = int(m.group(1)), int(m.group(2)) |
| 43 | + lines.append(f"\n Total: {got} / {total} points") |
| 44 | + lines.append(bar("Overall", got, total)) |
| 45 | + lines.append("") |
| 46 | + |
| 47 | + for sm in re.finditer(r'^## (✓|✗)\s+(.+?)\s*\((\d+)\s*/\s*(\d+)\)', text, re.M): |
| 48 | + icon = "✓" if sm.group(1) == "✓" else "✗" |
| 49 | + name, got, total = sm.group(2), int(sm.group(3)), int(sm.group(4)) |
| 50 | + lines.append("-" * W) |
| 51 | + lines.append(f" {icon} {name}") |
| 52 | + lines.append(bar(name, got, total)) |
| 53 | + |
| 54 | + sec_start = sm.end() |
| 55 | + sec_end = text.find("\n## ", sec_start) |
| 56 | + if sec_end == -1: |
| 57 | + sec_end = len(text) |
| 58 | + section = text[sec_start:sec_end] |
| 59 | + |
| 60 | + for sub in re.finditer(r'^### \[(.)\]\s+(\d+)/(\d+) points:\s+(.+)', section, re.M): |
| 61 | + mark = sub.group(1) |
| 62 | + sg, st, label = int(sub.group(2)), int(sub.group(3)), re.sub(r'\*\*([^*]+)\*\*', r'\1', sub.group(4)) |
| 63 | + sym = {"*": "✓", "x": "✗", "~": "~"}.get(mark, mark) |
| 64 | + lines.append(f" {sym} {sg:>2}/{st:<2} {label}") |
| 65 | + |
| 66 | + if "platform" in name.lower(): |
| 67 | + if re.search(r'\*\*WASM-ready:\*\*', section): |
| 68 | + lines.append(f" ✓ WASM compatible") |
| 69 | + elif re.search(r'not WASM-compatible', section): |
| 70 | + lines.append(f" ✗ WASM not compatible (partial Web score)") |
| 71 | + |
| 72 | + lines.append("-" * W) |
| 73 | + |
| 74 | + issues = re.findall( |
| 75 | + r'The constraint `([^`]+)` on (\w+) does not support the stable version `([^`]+)`', text |
| 76 | + ) |
| 77 | + if issues: |
| 78 | + lines.append(f"\n ⚠ Dependency issues ({len(issues)}):") |
| 79 | + for constraint, pkg, ver in issues: |
| 80 | + lines.append(f" • {pkg}: {constraint} doesn't support {ver}") |
| 81 | + |
| 82 | + lines.append("") |
| 83 | + return "\n".join(lines) |
| 84 | + |
| 85 | + |
| 86 | +def render_html(text): |
| 87 | + """Render score markdown as an HTML report.""" |
| 88 | + parts = [] |
| 89 | + parts.append(textwrap.dedent("""\ |
| 90 | + <!DOCTYPE html> |
| 91 | + <html lang="en"> |
| 92 | + <head> |
| 93 | + <meta charset="utf-8"> |
| 94 | + <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 95 | + <title>Package Score Report</title> |
| 96 | + <style> |
| 97 | + * { box-sizing: border-box; margin: 0; padding: 0; } |
| 98 | + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| 99 | + background: #f5f5f5; color: #333; padding: 2rem; max-width: 900px; margin: 0 auto; } |
| 100 | + h1 { text-align: center; margin-bottom: 0.5rem; } |
| 101 | + .total { text-align: center; font-size: 1.4rem; margin-bottom: 1.5rem; color: #555; } |
| 102 | + .section { background: #fff; border-radius: 8px; padding: 1.2rem 1.5rem; |
| 103 | + margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } |
| 104 | + .section-header { display: flex; align-items: center; gap: 0.5rem; |
| 105 | + font-size: 1.1rem; font-weight: 600; margin-bottom: 0.8rem; } |
| 106 | + .icon-pass { color: #4caf50; } .icon-fail { color: #f44336; } |
| 107 | + .bar-row { display: flex; align-items: center; gap: 0.8rem; margin: 0.4rem 0; } |
| 108 | + .bar-label { flex: 1; font-size: 0.9rem; } |
| 109 | + .bar-track { width: 200px; height: 14px; background: #e0e0e0; border-radius: 7px; overflow: hidden; } |
| 110 | + .bar-fill { height: 100%; border-radius: 7px; transition: width 0.3s; } |
| 111 | + .bar-score { font-size: 0.9rem; font-weight: 600; min-width: 50px; } |
| 112 | + .sub-item { padding: 0.2rem 0 0.2rem 1.5rem; font-size: 0.9rem; } |
| 113 | + .sub-item .icon-pass, .sub-item .icon-fail { font-weight: bold; } |
| 114 | + .details { margin-top: 0.5rem; padding: 0.8rem; background: #fafafa; |
| 115 | + border-radius: 4px; font-size: 0.85rem; border-left: 3px solid #ddd; } |
| 116 | + .dep-issues { background: #fff3e0; border-left-color: #ff9800; } |
| 117 | + footer { text-align: center; margin-top: 2rem; font-size: 0.8rem; color: #999; } |
| 118 | + </style> |
| 119 | + </head> |
| 120 | + <body> |
| 121 | + <h1>📊 Package Score Report</h1> |
| 122 | + """)) |
| 123 | + |
| 124 | + # Total score |
| 125 | + m = re.search(r'Points:\s*(\d+)\s*/\s*(\d+)', text) |
| 126 | + if m: |
| 127 | + got, total = int(m.group(1)), int(m.group(2)) |
| 128 | + parts.append(f'<div class="total">{got} / {total} points</div>') |
| 129 | + parts.append(_bar_html("Overall", got, total)) |
| 130 | + |
| 131 | + # Sections |
| 132 | + for sm in re.finditer(r'^## (✓|✗)\s+(.+?)\s*\((\d+)\s*/\s*(\d+)\)', text, re.M): |
| 133 | + passed = sm.group(1) == "✓" |
| 134 | + icon_cls = "icon-pass" if passed else "icon-fail" |
| 135 | + icon_char = "✓" if passed else "✗" |
| 136 | + name, got, total = sm.group(2), int(sm.group(3)), int(sm.group(4)) |
| 137 | + |
| 138 | + parts.append('<div class="section">') |
| 139 | + parts.append(f'<div class="section-header"><span class="{icon_cls}">{icon_char}</span> {html.escape(name)}</div>') |
| 140 | + parts.append(_bar_html(name, got, total)) |
| 141 | + |
| 142 | + sec_start = sm.end() |
| 143 | + sec_end = text.find("\n## ", sec_start) |
| 144 | + if sec_end == -1: |
| 145 | + sec_end = len(text) |
| 146 | + section = text[sec_start:sec_end] |
| 147 | + |
| 148 | + for sub in re.finditer(r'^### \[(.)\]\s+(\d+)/(\d+) points:\s+(.+)', section, re.M): |
| 149 | + mark = sub.group(1) |
| 150 | + sg, st = int(sub.group(2)), int(sub.group(3)) |
| 151 | + label = re.sub(r'\*\*([^*]+)\*\*', r'\1', sub.group(4)) |
| 152 | + sub_passed = mark == "*" |
| 153 | + si_cls = "icon-pass" if sub_passed else "icon-fail" |
| 154 | + si_char = "✓" if sub_passed else "✗" |
| 155 | + parts.append(f'<div class="sub-item"><span class="{si_cls}">{si_char}</span> {sg}/{st} — {html.escape(label)}</div>') |
| 156 | + |
| 157 | + # WASM status |
| 158 | + if "platform" in name.lower(): |
| 159 | + if re.search(r'\*\*WASM-ready:\*\*', section): |
| 160 | + parts.append('<div class="sub-item"><span class="icon-pass">✓</span> WASM compatible</div>') |
| 161 | + elif re.search(r'not WASM-compatible', section): |
| 162 | + parts.append('<div class="sub-item"><span class="icon-fail">✗</span> WASM not compatible</div>') |
| 163 | + |
| 164 | + # Extract detail blocks (content within <details>) |
| 165 | + for detail in re.finditer(r'<details>\s*<summary>\s*(.*?)\s*</summary>\s*(.*?)\s*</details>', section, re.S): |
| 166 | + summary_text = re.sub(r'<[^>]+>', '', detail.group(1)).strip() |
| 167 | + detail_body = re.sub(r'<[^>]+>', '', detail.group(2)).strip() |
| 168 | + if summary_text or detail_body: |
| 169 | + parts.append(f'<div class="details"><strong>{html.escape(summary_text)}</strong><br>{html.escape(detail_body)}</div>') |
| 170 | + |
| 171 | + parts.append('</div>') |
| 172 | + |
| 173 | + # Dependency issues |
| 174 | + issues = re.findall( |
| 175 | + r'The constraint `([^`]+)` on (\w+) does not support the stable version `([^`]+)`', text |
| 176 | + ) |
| 177 | + if issues: |
| 178 | + parts.append('<div class="section">') |
| 179 | + parts.append(f'<div class="section-header"><span class="icon-fail">⚠</span> Dependency issues ({len(issues)})</div>') |
| 180 | + for constraint, pkg, ver in issues: |
| 181 | + parts.append(f'<div class="sub-item dep-issues">• {html.escape(pkg)}: <code>{html.escape(constraint)}</code> doesn\'t support {html.escape(ver)}</div>') |
| 182 | + parts.append('</div>') |
| 183 | + |
| 184 | + parts.append('<footer>Generated by pana — <a href="https://pub.dev/packages/pana">pub.dev/packages/pana</a></footer>') |
| 185 | + parts.append('</body></html>') |
| 186 | + return "\n".join(parts) |
| 187 | + |
| 188 | + |
| 189 | +def main(): |
| 190 | + if len(sys.argv) < 2: |
| 191 | + print("Usage: render_score.py <score.md> [--html <output.html>]", file=sys.stderr) |
| 192 | + sys.exit(1) |
| 193 | + |
| 194 | + score_path = sys.argv[1] |
| 195 | + text = open(score_path).read() |
| 196 | + |
| 197 | + # Always print ASCII to stdout |
| 198 | + print(render_ascii(text)) |
| 199 | + |
| 200 | + # Generate HTML if --html flag is given |
| 201 | + if "--html" in sys.argv: |
| 202 | + idx = sys.argv.index("--html") |
| 203 | + if idx + 1 < len(sys.argv): |
| 204 | + html_path = sys.argv[idx + 1] |
| 205 | + else: |
| 206 | + html_path = "score_report.html" |
| 207 | + with open(html_path, "w") as f: |
| 208 | + f.write(render_html(text)) |
| 209 | + print(f"HTML report written to {html_path}") |
| 210 | + |
| 211 | + |
| 212 | +if __name__ == "__main__": |
| 213 | + main() |
0 commit comments