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..d96f23e --- /dev/null +++ b/scripts/build-widget-pages.py @@ -0,0 +1,516 @@ +#!/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, bold and italics. 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) + part = re.sub(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{columns:3 280px;column-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;break-inside:avoid;margin:0 0 16px} +.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}" + # An external widget's intro reads better than its terse registry line, so + # it leads and the registry line moves into the body. + lead, body_md = widget["description"], readme_of(slug) + if not body_md and slug in INTROS: + lead, _, rest = INTROS[slug].partition("\n\n") + body_md = widget["description"] + "\n\n" + rest + readme = markdown(body_md, 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(lead, 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
    +