From baafe1258ee01335f0bc6db82afe276687e59ad4 Mon Sep 17 00:00:00 2001 From: Jiun Bae Date: Fri, 25 Sep 2026 20:54:23 +0900 Subject: [PATCH 1/3] feat(site): generate widget pages and the sitemap from the registry The landing site had one indexable page. scripts/build-widget-pages.py now writes /widgets/ with every registry entry and a page per widget that has a real preview render plus a README or a substantial description (12 today), each with its own title, description, OG image, facts from the manifest, and install steps. Widgets below that bar stay on the index under an anchor rather than as thin pages, and gain a page as soon as they get a preview and a README. The Pages workflow runs the generator after staging site/, and it now also writes sitemap.xml, so the static one is gone. CI builds the pages to catch a registry change that breaks them. The home page links to /widgets/ from the nav, the widget gallery, and the footer. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + .github/workflows/pages.yml | 6 + scripts/build-widget-pages.py | 508 ++++++++++++++++++++++++++++++++++ site/index.html | 4 + site/sitemap.xml | 6 - 5 files changed, 521 insertions(+), 6 deletions(-) create mode 100644 scripts/build-widget-pages.py delete mode 100644 site/sitemap.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c6e2a6..25fc5d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,9 @@ jobs: - name: Check release version references run: python3 scripts/check-release-versions.py + - name: Build widget pages + run: python3 scripts/build-widget-pages.py "$(mktemp -d)" + - name: Validate JSON schemas run: | for f in schema/*.json registry/index.json; do diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 26037bd..80e9303 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -6,6 +6,10 @@ on: paths: - "site/**" - "schema/**" + - "registry/**" + - "widgets/**" + - "assets/widget-previews/**" + - "scripts/build-widget-pages.py" - ".github/workflows/pages.yml" workflow_dispatch: @@ -32,6 +36,8 @@ jobs: mkdir -p _site/schema cp -R site/. _site/ cp schema/*.json _site/schema/ + - name: Generate widget pages and sitemap + run: python3 scripts/build-widget-pages.py _site - uses: actions/upload-pages-artifact@v3 with: path: _site diff --git a/scripts/build-widget-pages.py b/scripts/build-widget-pages.py new file mode 100644 index 0000000..7ecd683 --- /dev/null +++ b/scripts/build-widget-pages.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python3 +"""Generates the landing site's widget pages from the registry. + + python3 scripts/build-widget-pages.py + +Writes `/widgets/index.html` (every registry entry), +`/widgets//index.html` for each widget with enough to say, and +`/sitemap.xml`. The Pages workflow runs it after staging `site/`. + +A widget gets its own page only when it has a real preview render and either a +README or a substantial registry description. The rest stay on the index page +under an anchor: a page that only repeats one sentence is thin content, and a +pile of those counts against the whole site in search. Adding a preview and a +README to a widget is all it takes to give it a page. + +Everything comes from `registry/index.json`, `widgets//`, and +`assets/widget-previews/`, so the pages cannot drift from what ships. +""" +from __future__ import annotations + +import html +import json +import pathlib +import re +import shutil +import sys +from urllib.parse import quote + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SITE_URL = "https://barshelf.jiun.dev" +REPO_URL = "https://github.com/Open330/barshelf" +BUNDLED_PREFIX = f"{REPO_URL}/tree/main/widgets/" +PREVIEWS = ROOT / "assets" / "widget-previews" + +MIN_README_WORDS = 80 +MIN_DESCRIPTION_WORDS = 30 + +# Preview files named before the widget ids settled. +PREVIEW_ALIASES = {"aas-usage": "aas", "otpeek": "otp", "recent-files-grid": "files"} + +# Search-facing titles for widgets whose names are not what people type. +TITLES = { + "aas-usage": "Claude Code and Codex usage in the macOS menu bar", + "otpeek": "OTP codes in the macOS menu bar", + "developer-inbox": "GitHub review requests and notifications in the macOS menu bar", + "sensors": "Mac temperatures, fans and power in the menu bar", + "system": "CPU, memory and disk in the macOS menu bar", + "muxa-watch": "Watch coding agents from the macOS menu bar", +} + +# Widgets that live in another repository have no README here. Say what the +# companion tool is, in words taken from that tool's own README. +INTROS = { + "aas-usage": ( + "[aas](https://github.com/Open330/aas) lets you use several Claude Code, Codex and other " + "coding-agent accounts side by side. This widget runs `aas usage --json` and shows every " + "account's 5-hour and 7-day quota — used, left, and when it resets — in the BarShelf popover.\n\n" + "Install aas first:\n\n```\nbrew install open330/tap/aas\n```" + ), + "otpeek": ( + "[OTPeek](https://github.com/jiunbae/otpeek) is a cross-platform OTP authenticator with a " + "shared Rust core and a CLI. This widget shows the codes in your OTPeek vault with a countdown, " + "so a 2FA code is one click away in the menu bar." + ), + "muxa-watch": ( + "[Muxa](https://github.com/Open330/muxa) watches the Claude Code, Codex and Gemini CLI sessions " + "you already run in tmux and tells you which one needs you. This widget puts that list in the " + "BarShelf popover, one row per agent." + ), +} + +# Registry categories as visitors should read them. "Demo" is a gallery shelf +# name; docs/widgets/README.md calls the same widgets "Everyday". +CATEGORY_LABELS = {"Demo": "Everyday"} + + +def category_of(widget: dict) -> str: + category = widget.get("category") or "Other" + return CATEGORY_LABELS.get(category, category) + + +KIND_LABELS = { + "exec": "Command (exec)", + "workflow": "Workflow (no code)", + "script": "TypeScript script", +} + + +def slug_of(widget: dict) -> str: + return widget["id"].rsplit(".", 1)[-1] + + +def preview_of(slug: str) -> pathlib.Path | None: + path = PREVIEWS / f"tile-{PREVIEW_ALIASES.get(slug, slug)}.png" + return path if path.exists() else None + + +def readme_of(slug: str) -> str: + path = ROOT / "widgets" / slug / "README.md" + return path.read_text() if path.exists() else "" + + +def manifest_of(slug: str) -> dict: + path = ROOT / "widgets" / slug / "widget.json" + return json.loads(path.read_text()) if path.exists() else {} + + +def has_page(widget: dict) -> bool: + slug = slug_of(widget) + if preview_of(slug) is None: + return False + return (len(readme_of(slug).split()) >= MIN_README_WORDS + or len(widget["description"].split()) >= MIN_DESCRIPTION_WORDS) + + +def is_bundled(widget: dict) -> bool: + return widget.get("install", {}).get("url", "").startswith(BUNDLED_PREFIX) + + +# --- Markdown ---------------------------------------------------------------- +# Widget READMEs use a small subset: headings, paragraphs, lists, tables, +# fenced code, inline code, links and bold. Anything else renders as plain text. + +def inline(text: str, base: str) -> str: + parts = re.split(r"(`[^`]+`)", text) + out = [] + for part in parts: + if part.startswith("`") and part.endswith("`") and len(part) > 1: + out.append(f"{html.escape(part[1:-1])}") + continue + part = html.escape(part, quote=False) + part = re.sub(r"\*\*(.+?)\*\*", r"\1", part) + + def link(m: re.Match) -> str: + href = html.unescape(m.group(2)) + if not re.match(r"^[a-z]+:|^#", href): + href = f"{base}/{href}" + return f'{m.group(1)}' + + out.append(re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", link, part)) + return "".join(out) + + +def markdown(source: str, base: str) -> str: + lines = source.splitlines() + out: list[str] = [] + para: list[str] = [] + items: list[str] = [] + list_tag = "" + i = 0 + + def flush() -> None: + nonlocal list_tag + if para: + out.append(f"

{inline(' '.join(para), base)}

") + para.clear() + if items: + out.append(f"<{list_tag}>" + "".join(f"
  • {inline(t, base)}
  • " for t in items) + f"") + items.clear() + list_tag = "" + + while i < len(lines): + line = lines[i] + stripped = line.strip() + if stripped.startswith("```"): + flush() + code = [] + i += 1 + while i < len(lines) and not lines[i].strip().startswith("```"): + code.append(lines[i]) + i += 1 + out.append(f"
    {html.escape(chr(10).join(code))}
    ") + elif stripped.startswith("|"): + flush() + rows = [] + while i < len(lines) and lines[i].strip().startswith("|"): + cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] + if not all(re.fullmatch(r":?-+:?", c) for c in cells): + rows.append(cells) + i += 1 + i -= 1 + head, *rest = rows + out.append("" + "".join(f"" for c in head) + + "" + + "".join("" + "".join(f"" for c in r) + "" for r in rest) + + "
    {inline(c, base)}
    {inline(c, base)}
    ") + elif m := re.match(r"^(#{1,6})\s+(.*)", stripped): + flush() + level = len(m.group(1)) + if level > 1: # the page renders its own h1 + tag = "h2" if level == 2 else "h3" + out.append(f"<{tag}>{inline(m.group(2), base)}") + elif m := re.match(r"^([-*]|\d+\.)\s+(.*)", stripped): + if para: + flush() + tag = "ol" if m.group(1)[0].isdigit() else "ul" + if items and tag != list_tag: + flush() + list_tag = tag + items.append(m.group(2)) + elif not stripped: + flush() + elif items and line.startswith((" ", "\t")): + items[-1] += " " + stripped + elif stripped.startswith(("![", "<")): + flush() # images and raw HTML have no place in the page body + else: + if items: + flush() + para.append(stripped) + i += 1 + flush() + return "\n".join(out) + + +# --- Pages ------------------------------------------------------------------- + +CSS = """ +:root{--ink:#111315;--muted:#676d72;--paper:#f5f3ef;--panel:#fff;--line:rgba(17,19,21,.12); +--accent:#d85f4b;--max:1040px;--font:-apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif; +--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;color-scheme:light} +*{box-sizing:border-box} +body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--font);line-height:1.6;-webkit-font-smoothing:antialiased} +a{color:inherit} +img{display:block;max-width:100%;height:auto} +:focus-visible{outline:2px solid var(--accent);outline-offset:3px} +.wrap{width:min(var(--max),calc(100% - 32px));margin:0 auto} +.nav{position:sticky;top:0;z-index:5;background:rgba(245,243,239,.92);backdrop-filter:blur(12px);border-bottom:1px solid var(--line)} +.nav-inner{display:flex;align-items:center;gap:18px;min-height:60px} +.brand{display:flex;align-items:center;gap:10px;font-weight:700;text-decoration:none} +.brand img{width:28px;height:28px;border-radius:7px} +.nav a.link{color:var(--muted);text-decoration:none;font-size:15px} +.spacer{flex:1} +.btn{display:inline-flex;align-items:center;min-height:44px;padding:0 18px;border-radius:10px;font-weight:600;text-decoration:none;border:1px solid transparent} +.btn.dark{background:var(--ink);color:#fff} +.btn.line{border-color:rgba(17,19,21,.22)} +.crumbs{margin:28px 0 0;font-size:14px;color:var(--muted)} +.crumbs a{text-decoration:none} +h1{font-size:clamp(34px,5vw,52px);line-height:1.05;margin:14px 0 12px;letter-spacing:0} +h2{font-size:24px;margin:40px 0 10px} +h3{font-size:18px;margin:28px 0 8px} +.lead{font-size:clamp(17px,2vw,20px);color:#2c3033;max-width:720px;margin:0} +.head{display:grid;grid-template-columns:1.1fr .9fr;gap:40px;align-items:start;padding-bottom:12px} +.shot{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:18px} +.facts{display:grid;grid-template-columns:max-content 1fr;gap:8px 18px;margin:24px 0 0;font-size:15px} +.facts dt{color:var(--muted)} +.facts dd{margin:0;overflow-wrap:anywhere} +.head>*{min-width:0} +.actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:26px} +.body{max-width:760px} +code{font-family:var(--mono);font-size:.92em;background:rgba(17,19,21,.06);padding:1px 5px;border-radius:5px} +pre{background:#111315;color:#f1efe9;padding:16px 18px;border-radius:12px;overflow-x:auto} +pre code{background:none;padding:0;color:inherit} +.body table{border-collapse:collapse;width:100%;font-size:15px;margin:14px 0;display:block;overflow-x:auto} +.body th,.body td{border-bottom:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top} +.body th{color:var(--muted);font-weight:600} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin:18px 0 8px} +.card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:18px;display:flex;flex-direction:column;gap:10px;scroll-margin-top:80px} +.card h3{margin:0;font-size:18px} +.card p{margin:0;color:#2c3033;font-size:15px} +.card .meta{color:var(--muted);font-size:13px} +.card .more{margin-top:auto;font-weight:600;font-size:14px} +.card img{border-radius:10px;border:1px solid var(--line)} +footer{margin-top:72px;border-top:1px solid var(--line);padding:28px 0 40px;color:var(--muted);font-size:14px} +footer .wrap{display:flex;flex-wrap:wrap;gap:18px;align-items:center} +footer a{text-decoration:none} +@media (max-width:820px){.head{grid-template-columns:1fr}.nav a.link{display:none}} +""" + + +def plain(text: str) -> str: + return re.sub(r"[`*]", "", text).strip() + + +def summary(text: str, limit: int = 160) -> str: + text = plain(text) + if len(text) <= limit: + return text + cut = text[:limit].rsplit(" ", 1)[0].rstrip(",;:—- ") + return cut + "…" + + +def document(*, title: str, description: str, path: str, image: str, + body: str, breadcrumbs: list[tuple[str, str]]) -> str: + url = f"{SITE_URL}{path}" + crumbs_ld = json.dumps({ + "@context": "https://schema.org", + "@type": "BreadcrumbList", + "itemListElement": [ + {"@type": "ListItem", "position": n, "name": name, "item": f"{SITE_URL}{href}"} + for n, (name, href) in enumerate(breadcrumbs, 1) + ], + }, ensure_ascii=False) + e = html.escape + return f""" + + + + + {e(title)} + + + + + + + + + + + + + + + + + + +
    +{body} +
    + + + +""" + + +def permissions_of(widget: dict) -> str: + perms = widget.get("permissions") or {} + parts = [] + for key, value in perms.items(): + if value is True: + parts.append(key) + elif isinstance(value, list) and value: + parts.append(f"{key}: " + ", ".join(f"{html.escape(str(v))}" for v in value)) + return "; ".join(parts) if parts else "None" + + +def install_block(widget: dict) -> str: + url = widget.get("install", {}).get("url", "") + if is_bundled(widget): + return ( + "

    Install

    \n" + "

    Included with BarShelf. Open the popover, go to the Gallery, and add it. " + "BarShelf asks you to approve the permissions listed above first.

    \n" + "
    brew install --cask open330/tap/barshelf
    " + ) + deep_link = f"barshelf://install?url={quote(url, safe='')}" + return ( + "

    Install

    \n" + f"

    With BarShelf running, install it in one click, " + "or from the terminal:

    \n" + f"
    barshelf install {html.escape(url)}
    \n" + "

    Don't have BarShelf yet?

    \n" + "
    brew install --cask open330/tap/barshelf
    " + ) + + +def widget_page(widget: dict, image_url: str) -> str: + slug = slug_of(widget) + name = widget["name"] + manifest = manifest_of(slug) + e = html.escape + kind = manifest.get("entry", {}).get("kind") or widget.get("kind", "") + facts = [ + ("Category", e(category_of(widget))), + ("Type", e(KIND_LABELS.get(kind, kind or "—"))), + ("Requires", e(widget.get("requires") or "Nothing beyond BarShelf")), + ("Permissions", permissions_of(widget)), + ] + if manifest.get("statusItem"): + facts.append(("Menu bar", "Can show its value live in the menu bar")) + if interval := manifest.get("refresh", {}).get("interval"): + facts.append(("Refresh", f"Every {interval} s while visible" if interval < 120 + else f"Every {interval // 60} min while visible")) + source = widget.get("install", {}).get("url") or widget.get("homepage", REPO_URL) + facts.append(("Source", f'{e(source.replace("https://", ""))}')) + facts_html = "".join(f"
    {k}
    {v}
    " for k, v in facts) + + readme_base = f"{REPO_URL}/blob/main/widgets/{slug}" + readme = markdown(readme_of(slug) or INTROS.get(slug, ""), readme_base) + headline = TITLES.get(slug) + title = f"{headline} — {name} · BarShelf" if headline else f"{name} widget for the macOS menu bar · BarShelf" + body = f"""

    BarShelf › Widgets › {e(name)}

    +
    +
    +

    {e(name)}

    +

    {inline(widget["description"], readme_base)}

    +
    {facts_html}
    + +
    +
    {e(name)} widget, rendered by BarShelf
    +
    +
    +{readme} +
    +{install_block(widget)} +
    +
    """ + return document( + title=title, + description=summary(widget["description"]), + path=f"/widgets/{slug}/", + image=image_url, + body=body, + breadcrumbs=[("BarShelf", "/"), ("Widgets", "/widgets/"), (name, f"/widgets/{slug}/")], + ) + + +def index_page(widgets: list[dict], paged: set[str]) -> str: + e = html.escape + groups: dict[str, list[dict]] = {} + for w in widgets: + groups.setdefault(category_of(w), []).append(w) + sections = [] + for category in sorted(groups): + cards = [] + for w in sorted(groups[category], key=lambda w: w["name"].lower()): + slug = slug_of(w) + thumb = (f'' if slug in paged else "") + link = (f'Details →' if slug in paged else + f'Source →') + requires = f'Requires {e(w["requires"])}' if w.get("requires") else "" + cards.append(f'
    {thumb}

    {e(w["name"])}

    ' + f'

    {inline(w["description"], REPO_URL)}

    {requires}{link}
    ') + sections.append(f'

    {e(category)}

    \n
    {"".join(cards)}
    ') + body = f"""

    BarShelf › Widgets

    +

    BarShelf widgets

    +

    {len(widgets)} native macOS menu bar widgets, from Claude Code and Codex usage to OTP codes, + GitHub reviews and Mac sensors. Each one runs behind the same BarShelf icon, and any command-line tool can + become one more.

    + +{chr(10).join(sections)}""" + return document( + title="macOS menu bar widgets — BarShelf widget gallery", + description=summary(f"{len(widgets)} native macOS menu bar widgets for BarShelf: Claude Code and Codex " + "usage, OTP codes, GitHub reviews, Mac sensors, calendar, weather and more."), + path="/widgets/", + image=f"{SITE_URL}/shots/macos-menubar-popover-crop.jpg", + body=body, + breadcrumbs=[("BarShelf", "/"), ("Widgets", "/widgets/")], + ) + + +def sitemap(paths: list[str]) -> str: + urls = "".join(f" \n {SITE_URL}{p}\n \n" for p in paths) + return ('\n' + '\n' + urls + "\n") + + +def build(out: pathlib.Path) -> list[str]: + registry = json.loads((ROOT / "registry" / "index.json").read_text()) + widgets = registry.get("widgets", registry) + paged = {slug_of(w) for w in widgets if has_page(w)} + out_widgets = out / "widgets" + if out_widgets.exists(): + shutil.rmtree(out_widgets) + out_widgets.mkdir(parents=True) + for w in widgets: + slug = slug_of(w) + if slug not in paged: + continue + page_dir = out_widgets / slug + page_dir.mkdir() + shutil.copyfile(preview_of(slug), page_dir / "preview.png") + (page_dir / "index.html").write_text( + widget_page(w, f"{SITE_URL}/widgets/{slug}/preview.png")) + (out_widgets / "index.html").write_text(index_page(widgets, paged)) + paths = ["/", "/widgets/"] + [f"/widgets/{slug_of(w)}/" for w in widgets if slug_of(w) in paged] + (out / "sitemap.xml").write_text(sitemap(paths)) + return paths + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__.strip().splitlines()[2].strip(), file=sys.stderr) + return 2 + paths = build(pathlib.Path(sys.argv[1])) + print(f"ok: {len(paths) - 2} widget pages, sitemap with {len(paths)} URLs") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/index.html b/site/index.html index 3b3e747..be3c99e 100644 --- a/site/index.html +++ b/site/index.html @@ -155,6 +155,7 @@ .btn.primary { background: #fff; color: #111315; } .btn.dark { background: var(--ink); color: #fff; } .btn.line { border-color: rgba(255, 255, 255, 0.28); color: #fff; background: rgba(255, 255, 255, 0.08); } + .gallery-more { margin: 28px 0 0; } .btn.light-line { border-color: var(--line-strong); background: transparent; color: var(--ink); } .hero { @@ -624,6 +625,7 @@ @@ -785,6 +787,7 @@

    시계부터 사용량까지, 팝오버 하나에.

    Battery widget with charge meter, real render
    battery
    Custom k8s pods list widget, real render
    your script here
    +