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:
Alan Wizemann
2026-04-23 00:09:49 +02:00
parent 11732baa3c
commit 6175bee27d
7 changed files with 969 additions and 1 deletions
+6 -1
View File
@@ -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:
+69
View File
@@ -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