mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
feat(site): dogfood the Scarf dashboard format as the catalog website
Adds site/ with vanilla HTML + CSS + ~300 lines of JavaScript that
renders ProjectDashboard JSON directly in the browser. Each template's
detail page shows a live preview of the exact dashboard the user will
get post-install — the catalog IS the dogfood.
site/widgets.js mirrors the Swift widget dispatcher:
- stat (big number + colored icon + optional subtitle)
- progress (0..1 bar)
- text with inline markdown subset (headings, bold/italic, inline code,
code fences, bullet + numbered lists, links)
- table (plain HTML)
- list (with up/down/unknown status badges)
- chart (SVG line + bar — no Chart.js dependency)
- webview (sandboxed iframe)
- unknown (placeholder so the page doesn't silently omit widgets)
Plus the renderMarkdown helper used by the template detail page to
display the bundle's README.
site/index.html.tmpl + site/template.html.tmpl are substitution-only —
the Python regenerator swaps {{CARDS}}, {{COUNT}}, {{COUNT_PLURAL}},
{{NAME}}, {{DESC}}, {{VERSION}}, {{AUTHOR_HTML}}, {{TAGS_HTML}},
{{INSTALL_URL_ENCODED}}, {{SCARF_INSTALL_URL}}. The detail page fetches
dashboard.json + README.md at page load and hands them to widgets.js.
No client-side framework, no bundler, no npm.
site/styles.css: minimal CSS with scarf green accent, prefers-color-
scheme dark support, responsive at 680px. One file, ~280 lines.
build-catalog.py extended to copy dashboard.json + README.md out of each
bundle into its detail dir so widgets.js can fetch them without
reaching across directories (and so gh-pages doesn't need to serve zip
contents at request time).
Two new Python tests: end-to-end site rendering (both cards, install
URL wiring, static asset copy, per-template dashboard + README copy)
and the {{COUNT_PLURAL}} singular-vs-plural flip. 16/16 Python tests
green.
Smoke-tested locally with python3 -m http.server: every endpoint
(index, catalog.json, detail HTML, per-template dashboard.json + README,
widgets.js) returns 200. The .gh-pages-worktree/appcast.xml +
.gh-pages-worktree/index.html are untouched — the catalog is purely
additive under /templates/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Scarf Templates</title>
|
||||
<meta name="description" content="Community catalog of Scarf project templates — pre-configured AI-agent projects with dashboards, cron jobs, and agent instructions.">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="icon" type="image/png" href="assets/icon.png">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href=".">
|
||||
<img src="assets/icon.png" alt="" width="40" height="40">
|
||||
<span class="brand-name">Scarf Templates</span>
|
||||
</a>
|
||||
<nav class="site-nav">
|
||||
<a href="https://github.com/awizemann/scarf">GitHub</a>
|
||||
<a href="https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md">Contribute</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<h1>Pre-packaged projects for Scarf</h1>
|
||||
<p>
|
||||
Browse {{COUNT}} community template{{COUNT_PLURAL}} — each ships with a
|
||||
ready-to-install Scarf dashboard, a cross-agent <code>AGENTS.md</code>, optional
|
||||
cron jobs and skills. Click a template to see what it does; one click installs
|
||||
it into Scarf.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<main class="catalog">
|
||||
<div class="grid">
|
||||
{{CARDS}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>
|
||||
Scarf is open source:
|
||||
<a href="https://github.com/awizemann/scarf">github.com/awizemann/scarf</a>.
|
||||
Want to add your own template? See the
|
||||
<a href="https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md">contribution guide</a>.
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
/* Scarf Templates — catalog site.
|
||||
* Vanilla CSS, no framework. Matches Scarf's green accent and keeps
|
||||
* decoration minimal — the live dashboard preview is the point. */
|
||||
|
||||
:root {
|
||||
--fg: #1a1a1a;
|
||||
--fg-muted: #666;
|
||||
--bg: #fafafa;
|
||||
--bg-card: #ffffff;
|
||||
--border: #e5e5e5;
|
||||
--accent: #2aa876; /* scarf green */
|
||||
--accent-dark: #1f7f5a;
|
||||
--red: #d9534f;
|
||||
--blue: #3498db;
|
||||
--orange: #f0ad4e;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fg: #e5e5e5;
|
||||
--fg-muted: #9a9a9a;
|
||||
--bg: #141414;
|
||||
--bg-card: #1e1e1e;
|
||||
--border: #2a2a2a;
|
||||
--accent: #3abf8a;
|
||||
--accent-dark: #2aa876;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.3), 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code, pre {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.05);
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
pre code { background: transparent; padding: 0; }
|
||||
|
||||
a { color: var(--accent-dark); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
h1, h2, h3 { line-height: 1.25; }
|
||||
|
||||
/* ---------- header / footer ---------- */
|
||||
|
||||
.site-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 32px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.brand:hover { text-decoration: none; }
|
||||
.brand-name { font-weight: 600; font-size: 18px; }
|
||||
.site-nav a {
|
||||
margin-left: 20px;
|
||||
color: var(--fg-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--fg-muted);
|
||||
font-size: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
/* ---------- landing ---------- */
|
||||
|
||||
.hero {
|
||||
padding: 48px 32px 24px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.hero h1 { font-size: 32px; margin: 0 0 12px; }
|
||||
.hero p { color: var(--fg-muted); }
|
||||
|
||||
.catalog {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 32px 48px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
display: block;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
color: inherit;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.06);
|
||||
text-decoration: none;
|
||||
}
|
||||
.card h3 { margin: 0 0 6px; font-size: 18px; }
|
||||
.card .desc { color: var(--fg-muted); margin: 0 0 14px; font-size: 14px; }
|
||||
.card .meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card .author { font-weight: 500; }
|
||||
.card .version { font-family: var(--mono); }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: rgba(42,168,118,0.15);
|
||||
color: var(--accent-dark);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---------- template detail page ---------- */
|
||||
|
||||
.detail {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 32px;
|
||||
}
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 32px;
|
||||
margin-bottom: 40px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.detail-header h1 { margin: 0 0 4px; }
|
||||
.detail-header h1 .version {
|
||||
font-family: var(--mono);
|
||||
font-size: 16px;
|
||||
color: var(--fg-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.detail-header .desc { color: var(--fg-muted); margin: 0 0 12px; }
|
||||
.detail-header .meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--fg-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.detail-header .id { font-family: var(--mono); }
|
||||
|
||||
.install-actions { display: flex; flex-direction: column; gap: 8px; min-width: 200px; }
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-dark); text-decoration: none; color: white; }
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover { border-color: var(--accent); text-decoration: none; color: var(--accent-dark); }
|
||||
|
||||
.detail-dashboard {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.detail-dashboard h2 { margin-top: 0; }
|
||||
.detail-dashboard-note {
|
||||
color: var(--fg-muted);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-readme h2 { margin-top: 0; }
|
||||
.detail-readme > div {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ---------- dashboard preview ---------- */
|
||||
|
||||
.dashboard-header h1.dashboard-title { margin: 0 0 4px; font-size: 22px; }
|
||||
.dashboard-desc { color: var(--fg-muted); margin: 0 0 24px; font-size: 14px; }
|
||||
.dashboard-section { margin-bottom: 24px; }
|
||||
.section-title { margin: 0 0 10px; font-size: 14px; font-weight: 600; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
.widget-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols, 3), 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.widget-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* stat */
|
||||
.widget-stat { display: flex; flex-direction: column; gap: 6px; }
|
||||
.widget-stat-top { display: flex; align-items: center; gap: 8px; }
|
||||
.widget-stat-icon { font-size: 14px; color: var(--fg-muted); }
|
||||
.widget-stat-value { font-size: 32px; font-weight: 600; line-height: 1.1; }
|
||||
.widget-stat-subtitle { font-size: 11px; color: var(--fg-muted); }
|
||||
.widget-stat[data-color="green"] .widget-stat-icon { color: var(--accent); }
|
||||
.widget-stat[data-color="red"] .widget-stat-icon { color: var(--red); }
|
||||
.widget-stat[data-color="blue"] .widget-stat-icon { color: var(--blue); }
|
||||
.widget-stat[data-color="orange"] .widget-stat-icon { color: var(--orange); }
|
||||
|
||||
/* progress */
|
||||
.widget-progress-label { font-size: 13px; margin: 6px 0 8px; }
|
||||
.progress-bar { height: 8px; background: rgba(0,0,0,0.05); border-radius: 4px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: 4px; }
|
||||
|
||||
/* text */
|
||||
.widget-text-body { font-size: 14px; margin-top: 6px; }
|
||||
.widget-text-body h1 { font-size: 20px; margin: 12px 0 8px; }
|
||||
.widget-text-body h2 { font-size: 17px; margin: 10px 0 6px; }
|
||||
.widget-text-body h3 { font-size: 14px; margin: 8px 0 4px; }
|
||||
.widget-text-body p { margin: 8px 0; }
|
||||
.widget-text-body ul, .widget-text-body ol { padding-left: 22px; }
|
||||
|
||||
/* table */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
|
||||
.data-table th, .data-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; }
|
||||
.data-table th { font-weight: 500; color: var(--fg-muted); }
|
||||
|
||||
/* list */
|
||||
.widget-list-items { margin: 6px 0 0; padding-left: 18px; font-size: 13px; }
|
||||
.widget-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.widget-list-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.widget-list-status {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0,0,0,0.08);
|
||||
color: var(--fg-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.widget-list-status[data-status="up"] { background: rgba(42,168,118,0.18); color: var(--accent-dark); }
|
||||
.widget-list-status[data-status="down"] { background: rgba(217,83,79,0.18); color: var(--red); }
|
||||
|
||||
/* chart */
|
||||
.widget-chart-svg { width: 100%; height: auto; margin-top: 8px; }
|
||||
.chart-axis { stroke: var(--border); stroke-width: 1; }
|
||||
.chart-line { fill: none; stroke-width: 2; }
|
||||
.chart-line[data-color="accent"], .chart-bar[data-color="accent"] { stroke: var(--accent); fill: var(--accent); }
|
||||
.chart-line[data-color="red"], .chart-bar[data-color="red"] { stroke: var(--red); fill: var(--red); }
|
||||
.chart-line[data-color="blue"], .chart-bar[data-color="blue"] { stroke: var(--blue); fill: var(--blue); }
|
||||
.chart-line[data-color="orange"], .chart-bar[data-color="orange"] { stroke: var(--orange); fill: var(--orange); }
|
||||
.widget-chart-empty { color: var(--fg-muted); font-size: 13px; padding: 20px 0; text-align: center; }
|
||||
|
||||
/* webview */
|
||||
.widget-webview iframe { border: 1px solid var(--border); border-radius: 6px; margin-top: 8px; }
|
||||
|
||||
/* unknown */
|
||||
.widget-unknown-body { color: var(--fg-muted); font-size: 13px; margin-top: 6px; }
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.site-header { padding: 12px 16px; }
|
||||
.site-nav a { margin-left: 12px; font-size: 13px; }
|
||||
.hero { padding: 32px 16px 16px; }
|
||||
.catalog, .detail { padding: 16px; }
|
||||
.detail-header { flex-direction: column; gap: 16px; }
|
||||
.install-actions { flex-direction: row; min-width: 0; }
|
||||
.btn { flex: 1; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{NAME}} — Scarf Templates</title>
|
||||
<meta name="description" content="{{DESC}}">
|
||||
<link rel="stylesheet" href="../styles.css">
|
||||
<link rel="icon" type="image/png" href="../assets/icon.png">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="..">
|
||||
<img src="../assets/icon.png" alt="" width="40" height="40">
|
||||
<span class="brand-name">Scarf Templates</span>
|
||||
</a>
|
||||
<nav class="site-nav">
|
||||
<a href="..">Catalog</a>
|
||||
<a href="https://github.com/awizemann/scarf">GitHub</a>
|
||||
<a href="https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md">Contribute</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="detail">
|
||||
<section class="detail-header">
|
||||
<div>
|
||||
<h1>{{NAME}} <span class="version">v{{VERSION}}</span></h1>
|
||||
<p class="desc">{{DESC}}</p>
|
||||
<p class="meta">
|
||||
<span class="author">by {{AUTHOR_HTML}}</span>
|
||||
<span class="id">{{ID}}</span>
|
||||
<span class="category">{{CATEGORY}}</span>
|
||||
</p>
|
||||
<p class="tags">{{TAGS_HTML}}</p>
|
||||
</div>
|
||||
<div class="install-actions">
|
||||
<a class="btn btn-primary" href="{{SCARF_INSTALL_URL}}">Install with Scarf</a>
|
||||
<a class="btn btn-secondary" href="{{INSTALL_URL_ENCODED}}">Download .scarftemplate</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-dashboard">
|
||||
<h2>Live dashboard preview</h2>
|
||||
<p class="detail-dashboard-note">
|
||||
Exactly what you'll see inside Scarf after install. Values shown here are
|
||||
placeholders; the agent updates them each time the cron job runs.
|
||||
</p>
|
||||
<div id="dashboard-preview"></div>
|
||||
</section>
|
||||
|
||||
<section class="detail-readme">
|
||||
<h2>README</h2>
|
||||
<div id="readme-body"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>
|
||||
Scarf is open source:
|
||||
<a href="https://github.com/awizemann/scarf">github.com/awizemann/scarf</a>.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script src="../widgets.js"></script>
|
||||
<script>
|
||||
// Fetch + render dashboard + README at page load. Both files live
|
||||
// alongside index.html in this template's detail dir.
|
||||
(async function () {
|
||||
const dashboardEl = document.getElementById("dashboard-preview");
|
||||
const readmeEl = document.getElementById("readme-body");
|
||||
try {
|
||||
const d = await fetch("dashboard.json").then(r => r.json());
|
||||
ScarfWidgets.renderDashboard(dashboardEl, d);
|
||||
} catch (e) {
|
||||
dashboardEl.textContent = "Could not load dashboard preview.";
|
||||
}
|
||||
try {
|
||||
const md = await fetch("README.md").then(r => r.text());
|
||||
readmeEl.innerHTML = ScarfWidgets.renderMarkdown(md);
|
||||
} catch (e) {
|
||||
readmeEl.textContent = "Could not load README.";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
// Scarf dashboard widget renderer — the dogfood piece.
|
||||
//
|
||||
// Takes the SAME `dashboard.json` shape the Scarf macOS app renders
|
||||
// (see scarf/scarf/Core/Models/ProjectDashboard.swift) and produces an
|
||||
// HTML approximation for the catalog site. A template's detail page
|
||||
// shows a live preview of exactly what the user's project dashboard
|
||||
// will look like after install.
|
||||
//
|
||||
// Widget types mirrored from the Swift dispatcher:
|
||||
// stat — big number + label + icon + color
|
||||
// progress — label + 0..1 bar
|
||||
// text — markdown (tiny subset renderer)
|
||||
// table — plain HTML table
|
||||
// list — bulleted list with optional status badge
|
||||
// chart — SVG line/bar by series
|
||||
// webview — sandboxed <iframe>
|
||||
//
|
||||
// Vanilla JS, no build step, no external deps. ~300 lines.
|
||||
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const SF_SYMBOL_FALLBACK = "●"; // SF Symbols aren't available on the web — use a dot.
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render a ProjectDashboard JSON into `container`.
|
||||
* @param {HTMLElement} container
|
||||
* @param {object} dashboard
|
||||
*/
|
||||
function renderDashboard(container, dashboard) {
|
||||
container.innerHTML = "";
|
||||
if (!dashboard || !Array.isArray(dashboard.sections)) {
|
||||
container.appendChild(elt("div", "dashboard-error", "Could not render dashboard."));
|
||||
return;
|
||||
}
|
||||
const root = elt("div", "dashboard");
|
||||
if (dashboard.title) {
|
||||
const header = elt("div", "dashboard-header");
|
||||
header.appendChild(elt("h1", "dashboard-title", dashboard.title));
|
||||
if (dashboard.description) {
|
||||
header.appendChild(elt("p", "dashboard-desc", dashboard.description));
|
||||
}
|
||||
root.appendChild(header);
|
||||
}
|
||||
for (const section of dashboard.sections) {
|
||||
root.appendChild(renderSection(section));
|
||||
}
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function renderSection(section) {
|
||||
const wrap = elt("section", "dashboard-section");
|
||||
if (section.title) {
|
||||
wrap.appendChild(elt("h2", "section-title", section.title));
|
||||
}
|
||||
const cols = Math.max(1, Math.min(6, section.columns || 3));
|
||||
const grid = elt("div", "widget-grid");
|
||||
grid.style.setProperty("--cols", String(cols));
|
||||
// Webview widgets render in a dedicated tab in the Scarf app but
|
||||
// we inline them here so the catalog preview is single-scroll.
|
||||
for (const widget of section.widgets || []) {
|
||||
grid.appendChild(renderWidget(widget));
|
||||
}
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderWidget(widget) {
|
||||
try {
|
||||
switch (widget.type) {
|
||||
case "stat": return renderStat(widget);
|
||||
case "progress": return renderProgress(widget);
|
||||
case "text": return renderText(widget);
|
||||
case "table": return renderTable(widget);
|
||||
case "list": return renderList(widget);
|
||||
case "chart": return renderChart(widget);
|
||||
case "webview": return renderWebview(widget);
|
||||
default: return renderUnknown(widget);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("widget render error", widget, e);
|
||||
return renderUnknown({ ...widget, title: (widget.title || "") + " (render error)" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Stat
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function renderStat(widget) {
|
||||
const card = elt("div", "widget widget-stat");
|
||||
card.dataset.color = widget.color || "blue";
|
||||
const top = elt("div", "widget-stat-top");
|
||||
top.appendChild(elt("span", "widget-stat-icon", SF_SYMBOL_FALLBACK));
|
||||
top.appendChild(elt("span", "widget-title", widget.title || ""));
|
||||
card.appendChild(top);
|
||||
const value = elt("div", "widget-stat-value", displayValue(widget.value));
|
||||
card.appendChild(value);
|
||||
if (widget.subtitle) {
|
||||
card.appendChild(elt("div", "widget-stat-subtitle", widget.subtitle));
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
function displayValue(v) {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (typeof v === "number") {
|
||||
return Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1);
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Progress
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function renderProgress(widget) {
|
||||
const card = elt("div", "widget widget-progress");
|
||||
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||
if (widget.label) {
|
||||
card.appendChild(elt("div", "widget-progress-label", widget.label));
|
||||
}
|
||||
const bar = elt("div", "progress-bar");
|
||||
const fill = elt("div", "progress-fill");
|
||||
const pct = Math.max(0, Math.min(1, Number(widget.value) || 0));
|
||||
fill.style.width = (pct * 100).toFixed(1) + "%";
|
||||
bar.appendChild(fill);
|
||||
card.appendChild(bar);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Text (markdown)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function renderText(widget) {
|
||||
const card = elt("div", "widget widget-text");
|
||||
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||
const body = elt("div", "widget-text-body");
|
||||
if ((widget.format || "").toLowerCase() === "markdown") {
|
||||
body.innerHTML = renderMarkdown(widget.content || "");
|
||||
} else {
|
||||
body.textContent = widget.content || "";
|
||||
}
|
||||
card.appendChild(body);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Minimal markdown subset: headings, bold, italic, inline code, code
|
||||
* blocks, bullet/numbered lists, links, paragraphs. Deliberately tiny
|
||||
* — the catalog showcases dashboards, not blog posts. */
|
||||
function renderMarkdown(src) {
|
||||
const lines = src.split(/\r?\n/);
|
||||
let html = "";
|
||||
let inCode = false;
|
||||
let inList = null; // "ul" | "ol" | null
|
||||
const flushList = () => {
|
||||
if (inList) {
|
||||
html += `</${inList}>`;
|
||||
inList = null;
|
||||
}
|
||||
};
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine;
|
||||
if (line.trim().startsWith("```")) {
|
||||
flushList();
|
||||
if (inCode) {
|
||||
html += "</code></pre>";
|
||||
inCode = false;
|
||||
} else {
|
||||
html += "<pre><code>";
|
||||
inCode = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inCode) {
|
||||
html += escapeHTML(line) + "\n";
|
||||
continue;
|
||||
}
|
||||
if (/^#{1,6}\s/.test(line)) {
|
||||
flushList();
|
||||
const level = Math.min(6, (line.match(/^#+/) || ["#"])[0].length);
|
||||
const text = line.replace(/^#+\s*/, "");
|
||||
html += `<h${level}>${renderInline(text)}</h${level}>`;
|
||||
continue;
|
||||
}
|
||||
const bulletMatch = line.match(/^\s*[-*]\s+(.*)$/);
|
||||
const orderedMatch = line.match(/^\s*\d+\.\s+(.*)$/);
|
||||
if (bulletMatch) {
|
||||
if (inList !== "ul") { flushList(); html += "<ul>"; inList = "ul"; }
|
||||
html += `<li>${renderInline(bulletMatch[1])}</li>`;
|
||||
continue;
|
||||
}
|
||||
if (orderedMatch) {
|
||||
if (inList !== "ol") { flushList(); html += "<ol>"; inList = "ol"; }
|
||||
html += `<li>${renderInline(orderedMatch[1])}</li>`;
|
||||
continue;
|
||||
}
|
||||
if (line.trim() === "") {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
flushList();
|
||||
html += `<p>${renderInline(line)}</p>`;
|
||||
}
|
||||
flushList();
|
||||
if (inCode) html += "</code></pre>";
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderInline(text) {
|
||||
// Escape first, then re-apply formatting on the escaped text.
|
||||
let s = escapeHTML(text);
|
||||
// Inline code before bold/italic so the markers inside `…` stay literal.
|
||||
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/(^|[^\w])\*([^*]+)\*/g, "$1<em>$2</em>");
|
||||
s = s.replace(/(^|[^\w])_([^_]+)_/g, "$1<em>$2</em>");
|
||||
// Links: [text](url)
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
|
||||
return `<a href="${url}">${text}</a>`;
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Table
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function renderTable(widget) {
|
||||
const card = elt("div", "widget widget-table");
|
||||
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||
const table = elt("table", "data-table");
|
||||
if (Array.isArray(widget.columns)) {
|
||||
const thead = elt("thead");
|
||||
const tr = elt("tr");
|
||||
for (const col of widget.columns) {
|
||||
tr.appendChild(elt("th", null, col));
|
||||
}
|
||||
thead.appendChild(tr);
|
||||
table.appendChild(thead);
|
||||
}
|
||||
if (Array.isArray(widget.rows)) {
|
||||
const tbody = elt("tbody");
|
||||
for (const row of widget.rows) {
|
||||
const tr = elt("tr");
|
||||
for (const cell of row) {
|
||||
tr.appendChild(elt("td", null, cell));
|
||||
}
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
}
|
||||
card.appendChild(table);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// List
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function renderList(widget) {
|
||||
const card = elt("div", "widget widget-list");
|
||||
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||
const ul = elt("ul", "widget-list-items");
|
||||
for (const item of widget.items || []) {
|
||||
const li = elt("li", "widget-list-item");
|
||||
li.appendChild(elt("span", "widget-list-text", item.text || ""));
|
||||
if (item.status) {
|
||||
const badge = elt("span", "widget-list-status", item.status);
|
||||
badge.dataset.status = item.status;
|
||||
li.appendChild(badge);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
}
|
||||
card.appendChild(ul);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Chart (SVG — no Chart.js dep)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function renderChart(widget) {
|
||||
const card = elt("div", "widget widget-chart");
|
||||
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||
const series = widget.series || [];
|
||||
if (series.length === 0) {
|
||||
card.appendChild(elt("div", "widget-chart-empty", "No chart data."));
|
||||
return card;
|
||||
}
|
||||
// Collect x-labels (assume aligned across series).
|
||||
const xs = series[0].data.map((p) => p.x);
|
||||
const ys = series.flatMap((s) => s.data.map((p) => p.y));
|
||||
const maxY = Math.max(0, ...ys);
|
||||
const minY = Math.min(0, ...ys);
|
||||
const W = 320;
|
||||
const H = 120;
|
||||
const padL = 24, padR = 8, padT = 8, padB = 22;
|
||||
const plotW = W - padL - padR;
|
||||
const plotH = H - padT - padB;
|
||||
|
||||
const svgNS = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(svgNS, "svg");
|
||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||
svg.classList.add("widget-chart-svg");
|
||||
|
||||
const yToPixel = (y) => {
|
||||
if (maxY === minY) return padT + plotH / 2;
|
||||
return padT + plotH - ((y - minY) / (maxY - minY)) * plotH;
|
||||
};
|
||||
const xToPixel = (i) => padL + (plotW * (i / Math.max(1, xs.length - 1)));
|
||||
|
||||
// Axis baseline
|
||||
const axis = document.createElementNS(svgNS, "line");
|
||||
axis.setAttribute("x1", String(padL));
|
||||
axis.setAttribute("y1", String(padT + plotH));
|
||||
axis.setAttribute("x2", String(W - padR));
|
||||
axis.setAttribute("y2", String(padT + plotH));
|
||||
axis.setAttribute("class", "chart-axis");
|
||||
svg.appendChild(axis);
|
||||
|
||||
const kind = (widget.chartType || "line").toLowerCase();
|
||||
series.forEach((s, idx) => {
|
||||
const color = s.color || ["accent", "red", "blue", "orange"][idx % 4];
|
||||
if (kind === "bar") {
|
||||
const barW = Math.max(2, plotW / (xs.length * series.length) - 2);
|
||||
s.data.forEach((p, i) => {
|
||||
const rect = document.createElementNS(svgNS, "rect");
|
||||
const x = xToPixel(i) - barW / 2 + idx * barW;
|
||||
const y = yToPixel(p.y);
|
||||
rect.setAttribute("x", String(x));
|
||||
rect.setAttribute("y", String(y));
|
||||
rect.setAttribute("width", String(barW));
|
||||
rect.setAttribute("height", String(padT + plotH - y));
|
||||
rect.setAttribute("class", "chart-bar");
|
||||
rect.dataset.color = color;
|
||||
svg.appendChild(rect);
|
||||
});
|
||||
} else {
|
||||
const d = s.data.map((p, i) => {
|
||||
const x = xToPixel(i);
|
||||
const y = yToPixel(p.y);
|
||||
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
const path = document.createElementNS(svgNS, "path");
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("class", "chart-line");
|
||||
path.dataset.color = color;
|
||||
svg.appendChild(path);
|
||||
}
|
||||
});
|
||||
|
||||
card.appendChild(svg);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Webview
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function renderWebview(widget) {
|
||||
const card = elt("div", "widget widget-webview");
|
||||
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = widget.url || "about:blank";
|
||||
frame.setAttribute("sandbox", "allow-scripts allow-popups allow-forms");
|
||||
frame.style.width = "100%";
|
||||
frame.style.height = (widget.height ? Number(widget.height) : 300) + "px";
|
||||
frame.loading = "lazy";
|
||||
card.appendChild(frame);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Unknown / placeholder
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function renderUnknown(widget) {
|
||||
const card = elt("div", "widget widget-unknown");
|
||||
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||
card.appendChild(elt("div", "widget-unknown-body",
|
||||
`Unknown widget type: ${widget.type}`));
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Utilities
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function elt(tag, cls, text) {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text !== undefined && text !== null) e.textContent = String(text);
|
||||
return e;
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
global.ScarfWidgets = {
|
||||
renderDashboard,
|
||||
renderMarkdown, // exposed for the template detail page's README block
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
@@ -603,7 +603,12 @@ def render_index(tmpl: str, records: list[TemplateRecord]) -> str:
|
||||
tags=tags_html,
|
||||
)
|
||||
)
|
||||
return tmpl.replace("{{CARDS}}", "\n".join(cards)).replace("{{COUNT}}", str(len(records)))
|
||||
count = len(records)
|
||||
return (
|
||||
tmpl.replace("{{CARDS}}", "\n".join(cards))
|
||||
.replace("{{COUNT}}", str(count))
|
||||
.replace("{{COUNT_PLURAL}}", "" if count == 1 else "s")
|
||||
)
|
||||
|
||||
|
||||
def render_detail(tmpl: str, record: TemplateRecord) -> str:
|
||||
|
||||
@@ -364,6 +364,75 @@ class CatalogJsonTests(unittest.TestCase):
|
||||
self.assertEqual(entry["detailSlug"], "tester-shape")
|
||||
|
||||
|
||||
class SiteRenderingTests(unittest.TestCase):
|
||||
"""Verify the regenerator produces usable HTML + copies dashboard.json
|
||||
+ README.md into each detail dir for widgets.js to fetch. No browser
|
||||
automation — just shape checks so we catch silly breakages
|
||||
(missing tokens, stale templates, broken copy)."""
|
||||
|
||||
def test_render_site_end_to_end(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo = make_fake_repo(Path(tmp))
|
||||
# Build a couple templates so the grid has more than one card.
|
||||
make_template_dir(repo, "alice", "alpha")
|
||||
make_template_dir(repo, "bob", "beta")
|
||||
|
||||
# Give the fake repo a site/ dir so render_site produces HTML.
|
||||
site_src = repo / "site"
|
||||
site_src.mkdir()
|
||||
(site_src / "index.html.tmpl").write_text(
|
||||
"<h1>Catalog ({{COUNT}} template{{COUNT_PLURAL}})</h1>{{CARDS}}"
|
||||
)
|
||||
(site_src / "template.html.tmpl").write_text(
|
||||
"<h1>{{NAME}}</h1><p>{{DESC}}</p>"
|
||||
"<a href=\"{{SCARF_INSTALL_URL}}\">install</a>"
|
||||
"<a href=\"{{INSTALL_URL_ENCODED}}\">download</a>"
|
||||
)
|
||||
(site_src / "widgets.js").write_text("/* test widgets */")
|
||||
(site_src / "styles.css").write_text("/* test styles */")
|
||||
|
||||
records = []
|
||||
for tdir in build_catalog._iter_templates(repo):
|
||||
r, errors = build_catalog.validate_template(tdir)
|
||||
self.assertEqual(errors, [])
|
||||
records.append(r)
|
||||
|
||||
out = Path(tmp) / "out"
|
||||
build_catalog.render_site(records, out, repo)
|
||||
|
||||
# Index: both cards present, plural form flipped for count=2.
|
||||
idx = (out / "index.html").read_text()
|
||||
self.assertIn("Catalog (2 templates)", idx)
|
||||
self.assertIn("alice-alpha/", idx)
|
||||
self.assertIn("bob-beta/", idx)
|
||||
|
||||
# Static assets copied.
|
||||
self.assertTrue((out / "widgets.js").exists())
|
||||
self.assertTrue((out / "styles.css").exists())
|
||||
self.assertTrue((out / "catalog.json").exists())
|
||||
|
||||
# Each detail dir has index.html + dashboard.json + README.md.
|
||||
alpha = out / "alice-alpha"
|
||||
self.assertTrue((alpha / "index.html").exists())
|
||||
self.assertTrue((alpha / "dashboard.json").exists())
|
||||
self.assertTrue((alpha / "README.md").exists())
|
||||
|
||||
alpha_html = (alpha / "index.html").read_text()
|
||||
# Install URL wires through the scarf:// scheme + raw GH URL.
|
||||
self.assertIn("scarf://install?url=https://raw.githubusercontent.com/", alpha_html)
|
||||
|
||||
def test_render_index_singular_form_for_one_template(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo = make_fake_repo(Path(tmp))
|
||||
make_template_dir(repo, "alice", "alpha")
|
||||
records = []
|
||||
for tdir in build_catalog._iter_templates(repo):
|
||||
r, _ = build_catalog.validate_template(tdir)
|
||||
records.append(r)
|
||||
html = build_catalog.render_index("{{COUNT}} template{{COUNT_PLURAL}}", records)
|
||||
self.assertEqual(html, "1 template")
|
||||
|
||||
|
||||
class RealBundleTest(unittest.TestCase):
|
||||
"""Run the validator against the actual shipped Site Status Checker
|
||||
bundle. Catches drift between validator + real-world author
|
||||
|
||||
Reference in New Issue
Block a user