aboutsummaryrefslogtreecommitdiff
path: root/website/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-21 17:24:17 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-21 17:27:10 +0300
commit2eeea9a0cd8665bbe0e6c80f08603425498757e5 (patch)
tree83f3f572fafbadd1d40593415ccb335d36373eb6 /website/src
parent734bf3c09d292012b45b93a4813ccdd0cf26d08a (diff)
feat: add plugin marketplace
Diffstat (limited to 'website/src')
-rw-r--r--website/src/components/MarketingLayout.astro6
-rw-r--r--website/src/components/SiteTitle.astro2
-rw-r--r--website/src/content.config.ts11
-rw-r--r--website/src/content/blog/herdr-0-6-3.md (renamed from website/src/content/releases/v0-6-3.md)5
-rw-r--r--website/src/content/docs/marketplace.mdx54
-rw-r--r--website/src/content/docs/plugins.mdx15
-rw-r--r--website/src/data/plugins-fixture.json139
-rw-r--r--website/src/pages/compare.astro6
-rw-r--r--website/src/pages/plugins.astro601
-rw-r--r--website/src/pages/releases/[...slug].astro89
-rw-r--r--website/src/pages/releases/index.astro42
11 files changed, 791 insertions, 179 deletions
diff --git a/website/src/components/MarketingLayout.astro b/website/src/components/MarketingLayout.astro
index a4df022f..381daf03 100644
--- a/website/src/components/MarketingLayout.astro
+++ b/website/src/components/MarketingLayout.astro
@@ -58,7 +58,7 @@ const ogImageUrl = new URL(ogImage, 'https://herdr.dev').toString();
document.documentElement.setAttribute("data-palette", saved || system);
})();
</script>
- <link rel="stylesheet" href="/css/style.css?v=21" />
+ <link rel="stylesheet" href="/css/style.css?v=39" />
</head>
<body>
<header class="site-header">
@@ -76,7 +76,7 @@ const ogImageUrl = new URL(ogImage, 'https://herdr.dev').toString();
<div class="nav-menu-panel">
<a href="/">Home</a>
<a href="/compare/">Compare</a>
- <a href="/releases/">Releases</a>
+ <a href="/plugins/">Plugins</a>
<a href="/blog/">Blog</a>
<a href="/docs/quick-start/">Quick start</a>
<a href="/docs/">Docs</a>
@@ -88,7 +88,7 @@ const ogImageUrl = new URL(ogImage, 'https://herdr.dev').toString();
<div class="nav-links">
<a href="/">Home</a>
<a href="/compare/">Compare</a>
- <a href="/releases/">Releases</a>
+ <a href="/plugins/">Plugins</a>
<a href="/blog/">Blog</a>
<a href="/docs/quick-start/">Quick start</a>
<a href="/docs/">Docs</a>
diff --git a/website/src/components/SiteTitle.astro b/website/src/components/SiteTitle.astro
index d35da5d6..f6a3b7d0 100644
--- a/website/src/components/SiteTitle.astro
+++ b/website/src/components/SiteTitle.astro
@@ -11,7 +11,7 @@ const docsHref = isPreview ? '/docs/preview/' : '/docs/';
</a>
<nav class="title-nav" aria-label="Primary">
<a href={docsHref}>Docs</a>
- <a href="/releases/">Releases</a>
+ <a href="/plugins/">Plugins</a>
<a href="/blog/">Blog</a>
</nav>
</div>
diff --git a/website/src/content.config.ts b/website/src/content.config.ts
index ba0c83f8..bb0f2d8a 100644
--- a/website/src/content.config.ts
+++ b/website/src/content.config.ts
@@ -21,15 +21,4 @@ export const collections = {
ogImage: z.string().optional(),
}),
}),
- releases: defineCollection({
- loader: glob({ pattern: '*.md', base: './src/content/releases' }),
- schema: z.object({
- title: z.string(),
- version: z.string(),
- path: z.string(),
- description: z.string(),
- date: z.coerce.date(),
- draft: z.boolean().default(false),
- }),
- }),
};
diff --git a/website/src/content/releases/v0-6-3.md b/website/src/content/blog/herdr-0-6-3.md
index 23514cc6..97bd2b28 100644
--- a/website/src/content/releases/v0-6-3.md
+++ b/website/src/content/blog/herdr-0-6-3.md
@@ -1,9 +1,8 @@
---
-title: "session persistence, safer updates, and workspace navigation"
-version: "0.6.3"
-path: "0.6.3/session-persistence-safer-updates-and-workspace-navigation"
+title: "Herdr 0.6.3: session persistence, safer updates, and workspace navigation"
description: "This one adds new persistence paths for updates, server restarts, and larger sessions."
date: 2026-05-27
+draft: false
---
## Persistence for the hard parts
diff --git a/website/src/content/docs/marketplace.mdx b/website/src/content/docs/marketplace.mdx
index f07b8f87..09a27b2d 100644
--- a/website/src/content/docs/marketplace.mdx
+++ b/website/src/content/docs/marketplace.mdx
@@ -1,39 +1,53 @@
---
title: Marketplace
-description: Publish and share Herdr plugins, and get listed when the marketplace launches.
+description: Discover community Herdr plugins on GitHub, and get your own plugin listed.
---
-The Herdr plugin marketplace is a discoverable index of installable plugins. It
-is not live yet. This page is how to be ready for it.
+The Herdr plugin marketplace is a discoverable index of community plugins.
+Browse it at [herdr.dev/plugins](/plugins/). It is an automatic index of public
+GitHub repositories, not a reviewed catalog.
-## Share a plugin today
+## Browse plugins
+
+The [marketplace](/plugins/) lists every public repository tagged with the
+GitHub topic `herdr-plugin`. Search by name, owner, description, or language,
+and sort by popularity, recent activity, or newest. Each listing links straight
+to its source repository on GitHub.
+
+Discovery is automatic and unreviewed. A listing means a repository tagged
+itself, not that Herdr vetted it, so the
+[trust guidance](/docs/plugins/#trust-and-security) applies before you install
+anything.
+
+## Install a plugin
The marketplace adds discovery on top of installation; it does not replace it.
-Anyone can already install your plugin straight from GitHub:
+Install any plugin straight from GitHub:
```bash
herdr plugin install owner/repo[/subdir...]
```
Publish a normal public GitHub repository with a `herdr-plugin.toml` manifest at
-its root, or in a subdirectory, and that command works now. See
+its root, or in a subdirectory, and that command works. See
[Plugins](/docs/plugins/) for the manifest and authoring reference.
-## Get listed when it launches
+## Get your plugin listed
-If you want your plugin to appear in the website marketplace when discovery
-launches, add the GitHub topic `herdr-plugin` to your repository now. That
-topic is the signal the future index will use for discovery, so tagging a
-public plugin today means it can be listed when the marketplace goes live.
+Add the GitHub topic `herdr-plugin` to a public repository. That topic is the
+only signal the index uses, so tagging a public plugin is all it takes. The
+index refreshes automatically every 30 minutes, so a newly tagged repository
+shows up shortly after, and one that drops the topic disappears on the next
+refresh.
-## What a listing will show
+## What a listing shows
-A marketplace card will surface your plugin's `id`, `name`, `description`,
-declared `platforms`, and a link back to the source repository. Getting that
-manifest metadata right today is what makes your listing accurate later. Declare
-`platforms` honestly, because the index will use it to show where a plugin runs.
+Each card shows GitHub repository metadata: the repository name and owner, its
+description, star count, primary language, and the time it was last pushed, with
+a link back to the source. The index reads this from GitHub's repository search,
+so keeping your repository description and topics accurate is what makes your
+listing useful.
-Plugins stay ordinary GitHub repositories. The marketplace is a discovery layer
-on top of the same `herdr plugin install` path you can use now. A listing makes a
-plugin easier to find; it does not mean Herdr has reviewed it, so the
-[trust guidance](/docs/plugins/#trust-and-security) still applies.
+The index does not parse `herdr-plugin.toml` yet, so manifest fields like the
+plugin `id`, declared `platforms`, and `min_herdr_version` are not shown in v1.
+Forks and archived repositories are excluded from the list.
diff --git a/website/src/content/docs/plugins.mdx b/website/src/content/docs/plugins.mdx
index 9f069fdf..9f57ff47 100644
--- a/website/src/content/docs/plugins.mdx
+++ b/website/src/content/docs/plugins.mdx
@@ -304,10 +304,11 @@ state should own their files or database.
## Marketplace
-A plugin marketplace is coming. Plugins are already shareable today: publish a
-GitHub repository with `herdr-plugin.toml`, then share
-`herdr plugin install owner/repo[/subdir]`.
-
-If you want the plugin to appear in the website marketplace when discovery
-launches, add the GitHub topic `herdr-plugin` to the repository now. See
-[Marketplace](/docs/marketplace/) for how publishing and discovery will work.
+Community plugins are discoverable in the [marketplace](/plugins/), an automatic
+index of public GitHub repositories tagged with the topic `herdr-plugin`.
+Plugins stay ordinary GitHub repositories: publish one with `herdr-plugin.toml`,
+then share `herdr plugin install owner/repo[/subdir]`.
+
+To get a plugin listed, add the GitHub topic `herdr-plugin` to its public
+repository. The index refreshes every 30 minutes. See
+[Marketplace](/docs/marketplace/) for how discovery works.
diff --git a/website/src/data/plugins-fixture.json b/website/src/data/plugins-fixture.json
new file mode 100644
index 00000000..27a9c265
--- /dev/null
+++ b/website/src/data/plugins-fixture.json
@@ -0,0 +1,139 @@
+{
+ "schemaVersion": 1,
+ "generatedAt": "2026-06-20T12:00:00.000Z",
+ "source": {
+ "provider": "github",
+ "query": "topic:herdr-plugin",
+ "totalCount": 7,
+ "collectedCount": 7,
+ "truncated": false
+ },
+ "plugins": [
+ {
+ "id": 123456789,
+ "fullName": "ogulcancelik/herdr-plugin-github-start",
+ "owner": "ogulcancelik",
+ "name": "herdr-plugin-github-start",
+ "description": "Start Codex or Claude from a GitHub issue, PR, or discussion without leaving the terminal.",
+ "url": "https://github.com/ogulcancelik/herdr-plugin-github-start",
+ "stars": 42,
+ "forks": 3,
+ "openIssues": 1,
+ "language": "TypeScript",
+ "topics": ["herdr-plugin", "github", "agents"],
+ "createdAt": "2026-05-01T00:00:00Z",
+ "updatedAt": "2026-06-19T19:58:26Z",
+ "pushedAt": "2026-06-19T19:58:26Z",
+ "archived": false,
+ "fork": false
+ },
+ {
+ "id": 223456789,
+ "fullName": "ogulcancelik/herdr-plugin-examples",
+ "owner": "ogulcancelik",
+ "name": "herdr-plugin-examples",
+ "description": "Cookbook of example Herdr plugins: telegram notify, link preview, dev layout bootstrap.",
+ "url": "https://github.com/ogulcancelik/herdr-plugin-examples",
+ "stars": 28,
+ "forks": 6,
+ "openIssues": 0,
+ "language": "JavaScript",
+ "topics": ["herdr-plugin", "examples"],
+ "createdAt": "2026-04-12T00:00:00Z",
+ "updatedAt": "2026-06-18T08:10:00Z",
+ "pushedAt": "2026-06-18T08:10:00Z",
+ "archived": false,
+ "fork": false
+ },
+ {
+ "id": 323456789,
+ "fullName": "lindqvist/herdr-board",
+ "owner": "lindqvist",
+ "name": "herdr-board",
+ "description": "A live project board pane for Herdr that tracks agent tasks across worktrees.",
+ "url": "https://github.com/lindqvist/herdr-board",
+ "stars": 19,
+ "forks": 1,
+ "openIssues": 4,
+ "language": "Rust",
+ "topics": ["herdr-plugin", "pane", "kanban"],
+ "createdAt": "2026-05-20T00:00:00Z",
+ "updatedAt": "2026-06-17T14:02:00Z",
+ "pushedAt": "2026-06-17T14:02:00Z",
+ "archived": false,
+ "fork": false
+ },
+ {
+ "id": 423456789,
+ "fullName": "mira-dev/herdr-layout-presets",
+ "owner": "mira-dev",
+ "name": "herdr-layout-presets",
+ "description": "Apply opinionated split-pane layouts per project with one keybinding.",
+ "url": "https://github.com/mira-dev/herdr-layout-presets",
+ "stars": 11,
+ "forks": 0,
+ "openIssues": 2,
+ "language": "Lua",
+ "topics": ["herdr-plugin", "layout"],
+ "createdAt": "2026-06-01T00:00:00Z",
+ "updatedAt": "2026-06-15T09:30:00Z",
+ "pushedAt": "2026-06-15T09:30:00Z",
+ "archived": false,
+ "fork": false
+ },
+ {
+ "id": 523456789,
+ "fullName": "okabe/herdr-jira-bridge",
+ "owner": "okabe",
+ "name": "herdr-jira-bridge",
+ "description": "Open Jira tickets as Herdr workspaces and push agent summaries back as comments.",
+ "url": "https://github.com/okabe/herdr-jira-bridge",
+ "stars": 7,
+ "forks": 2,
+ "openIssues": 0,
+ "language": "Python",
+ "topics": ["herdr-plugin", "jira", "integration"],
+ "createdAt": "2026-05-28T00:00:00Z",
+ "updatedAt": "2026-06-10T11:45:00Z",
+ "pushedAt": "2026-06-10T11:45:00Z",
+ "archived": false,
+ "fork": false
+ },
+ {
+ "id": 623456789,
+ "fullName": "noor/herdr-notify",
+ "owner": "noor",
+ "name": "herdr-notify",
+ "description": "Desktop and mobile notifications when an agent finishes, blocks, or needs input.",
+ "url": "https://github.com/noor/herdr-notify",
+ "stars": 4,
+ "forks": 0,
+ "openIssues": 1,
+ "language": "Go",
+ "topics": ["herdr-plugin", "notifications"],
+ "createdAt": "2026-06-05T00:00:00Z",
+ "updatedAt": "2026-06-08T16:20:00Z",
+ "pushedAt": "2026-06-08T16:20:00Z",
+ "archived": false,
+ "fork": false
+ },
+ {
+ "id": 723456789,
+ "fullName": "small-tools/herdr-snippet-runner",
+ "owner": "small-tools",
+ "name": "herdr-snippet-runner",
+ "description": null,
+ "url": "https://github.com/small-tools/herdr-snippet-runner",
+ "stars": 0,
+ "forks": 0,
+ "openIssues": 0,
+ "language": null,
+ "topics": ["herdr-plugin"],
+ "createdAt": "2026-06-14T00:00:00Z",
+ "updatedAt": "2026-06-14T10:00:00Z",
+ "pushedAt": "2026-06-14T10:00:00Z",
+ "archived": false,
+ "fork": false
+ }
+ ]
+}
diff --git a/website/src/pages/compare.astro b/website/src/pages/compare.astro
index 0825ea68..f120ac59 100644
--- a/website/src/pages/compare.astro
+++ b/website/src/pages/compare.astro
@@ -45,7 +45,7 @@
document.documentElement.setAttribute("data-palette", saved || system);
})();
</script>
- <link rel="stylesheet" href="/css/style.css?v=21" />
+ <link rel="stylesheet" href="/css/style.css?v=39" />
</head>
<body>
<header class="site-header">
@@ -63,7 +63,7 @@
<div class="nav-menu-panel">
<a href="/">Home</a>
<a href="/compare/" aria-current="page">Compare</a>
- <a href="/releases/">Releases</a>
+ <a href="/plugins/">Plugins</a>
<a href="/blog/">Blog</a>
<a href="/docs/quick-start/">Quick start</a>
<a href="/docs/">Docs</a>
@@ -75,7 +75,7 @@
<div class="nav-links">
<a href="/">Home</a>
<a href="/compare/" aria-current="page">Compare</a>
- <a href="/releases/">Releases</a>
+ <a href="/plugins/">Plugins</a>
<a href="/blog/">Blog</a>
<a href="/docs/quick-start/">Quick start</a>
<a href="/docs/">Docs</a>
diff --git a/website/src/pages/plugins.astro b/website/src/pages/plugins.astro
new file mode 100644
index 00000000..6e96a132
--- /dev/null
+++ b/website/src/pages/plugins.astro
@@ -0,0 +1,601 @@
+---
+import MarketingLayout from '../components/MarketingLayout.astro';
+import fixture from '../data/plugins-fixture.json';
+
+const snapshotUrl = 'https://assets.herdr.dev/plugins/index.json';
+
+// Seed the page server-side from the public R2 snapshot so `bun run dev` and
+// the production build both render real data immediately. If the live fetch
+// fails, dev falls back to a local fixture for offline design; production seeds
+// empty and lets the client fetch fill it (never fake data).
+let liveData = null;
+try {
+ const res = await fetch(snapshotUrl);
+ if (res.ok) liveData = await res.json();
+} catch {
+ // ignore: handled by the fallbacks below
+}
+const initialData = liveData ?? (import.meta.env.DEV ? fixture : null);
+const initialIsFixture = !liveData && import.meta.env.DEV;
+const apiUrl = import.meta.env.DEV ? null : snapshotUrl;
+---
+
+<MarketingLayout
+ title="Herdr plugins: community plugin marketplace"
+ description="Discover community Herdr plugins published on GitHub. An automatic index of public repositories tagged with the herdr-plugin topic."
+ canonicalPath="/plugins/"
+>
+ <main class="plugins-page">
+ <section class="section plugins-hero">
+ <p class="section-kicker"># marketplace</p>
+ <h2>Plugins built on Herdr.</h2>
+ <p class="plugins-lede">
+ Community plugins published on GitHub. Install any of them with
+ <code>herdr plugin install</code>, or publish your own and get listed.
+ </p>
+ <p class="plugins-trust">
+ <span class="status-dot dot-idle"></span>
+ Listings are discovered automatically from the GitHub topic
+ <code>herdr-plugin</code>. They are not reviewed by Herdr, so install at
+ your own discretion. <a href="/docs/plugins/#trust-and-security">Trust guidance</a>.
+ </p>
+ </section>
+
+ <section class="section plugins-controls">
+ <div class="plugins-search-wrap">
+ <span class="plugins-search-icon" aria-hidden="true">/</span>
+ <input
+ id="plugins-search"
+ type="search"
+ class="plugins-search"
+ placeholder="search plugins, owners, languages…"
+ autocomplete="off"
+ spellcheck="false"
+ aria-label="Search plugins"
+ />
+ </div>
+ <label class="plugins-sort-wrap">
+ <span class="plugins-sort-label">sort</span>
+ <select id="plugins-sort" class="plugins-sort" aria-label="Sort plugins">
+ <option value="popular">Popular</option>
+ <option value="recent">Recently updated</option>
+ <option value="newest">Newest</option>
+ <option value="name">Name</option>
+ </select>
+ </label>
+ <p id="plugins-meta" class="plugins-meta" aria-live="polite"></p>
+ </section>
+
+ <section class="section plugins-results">
+ <div id="plugins-grid" class="plugins-grid" role="list"></div>
+ <div id="plugins-status" class="plugins-status">Loading plugins…</div>
+ </section>
+
+ <section class="section plugins-publish">
+ <div class="plugins-publish-card">
+ <p class="section-kicker"># publish</p>
+ <h3>Want your plugin listed?</h3>
+ <p>
+ Add the GitHub topic <code>herdr-plugin</code> to a public repository
+ with a <code>herdr-plugin.toml</code> manifest. The index picks it up
+ on the next refresh. No submission, no review queue.
+ </p>
+ <div class="plugins-publish-actions">
+ <a class="button button-primary" href="/docs/marketplace/">How publishing works</a>
+ <a class="button button-secondary" href="/docs/plugins/">Author a plugin</a>
+ </div>
+ </div>
+ </section>
+ </main>
+
+ <script define:vars={{ initialData, initialIsFixture, apiUrl }}>
+ const grid = document.getElementById("plugins-grid");
+ const statusEl = document.getElementById("plugins-status");
+ const searchInput = document.getElementById("plugins-search");
+ const sortSelect = document.getElementById("plugins-sort");
+ const metaEl = document.getElementById("plugins-meta");
+
+ const LANG_COLORS = {
+ TypeScript: "#3178c6",
+ JavaScript: "#f1e05a",
+ Rust: "#dea584",
+ Python: "#3572A5",
+ Go: "#00ADD8",
+ Lua: "#000080",
+ Shell: "#89e051",
+ "C++": "#f34b7d",
+ Ruby: "#701516",
+ };
+
+ let plugins = [];
+ let usingFallback = false;
+
+ function timeAgo(iso) {
+ if (!iso) return null;
+ const then = new Date(iso).getTime();
+ if (Number.isNaN(then)) return null;
+ const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
+ const units = [
+ ["y", 31536000],
+ ["mo", 2592000],
+ ["w", 604800],
+ ["d", 86400],
+ ["h", 3600],
+ ["m", 60],
+ ];
+ for (const [label, size] of units) {
+ const v = Math.floor(secs / size);
+ if (v >= 1) return v + label + " ago";
+ }
+ return "just now";
+ }
+
+ function isGithubUrl(url) {
+ return typeof url === "string" && /^https:\/\/github\.com\/[^/]+\/[^/]+/.test(url);
+ }
+
+ function el(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text != null) node.textContent = text;
+ return node;
+ }
+
+ function buildCard(p) {
+ const url = isGithubUrl(p.url) ? p.url : null;
+ const card = el(url ? "a" : "div", "plugin-card");
+ card.setAttribute("role", "listitem");
+ if (url) {
+ card.href = url;
+ card.target = "_blank";
+ card.rel = "noopener noreferrer";
+ }
+
+ const head = el("div", "plugin-card-head");
+ const title = el("div", "plugin-title");
+ title.appendChild(el("span", "plugin-owner", (p.owner || "?") + "/"));
+ title.appendChild(el("span", "plugin-name", p.name || "unknown"));
+ head.appendChild(title);
+ head.appendChild(el("span", "plugin-go", "↗"));
+ card.appendChild(head);
+
+ const desc = el(
+ "p",
+ "plugin-desc" + (p.description ? "" : " is-empty"),
+ p.description || "No description provided.",
+ );
+ card.appendChild(desc);
+
+ const meta = el("div", "plugin-meta-row");
+
+ const stars = el("span", "plugin-stat");
+ stars.appendChild(el("span", "plugin-star", "★"));
+ stars.appendChild(el("span", null, String(p.stars ?? 0)));
+ meta.appendChild(stars);
+
+ if (p.language) {
+ const lang = el("span", "plugin-stat");
+ const dot = el("span", "plugin-lang-dot");
+ dot.style.background = LANG_COLORS[p.language] || "var(--muted)";
+ lang.appendChild(dot);
+ lang.appendChild(el("span", null, p.language));
+ meta.appendChild(lang);
+ }
+
+ const updated = timeAgo(p.pushedAt);
+ if (updated) meta.appendChild(el("span", "plugin-stat plugin-updated", "updated " + updated));
+
+ card.appendChild(meta);
+ return card;
+ }
+
+ function matches(p, q) {
+ if (!q) return true;
+ const hay = [p.fullName, p.owner, p.name, p.description, p.language, ...(p.topics || [])]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return q.split(/\s+/).every((term) => hay.includes(term));
+ }
+
+ function sortPlugins(list, mode) {
+ const by = [...list];
+ const t = (v) => new Date(v || 0).getTime() || 0;
+ if (mode === "recent") by.sort((a, b) => t(b.pushedAt) - t(a.pushedAt));
+ else if (mode === "newest") by.sort((a, b) => t(b.createdAt) - t(a.createdAt));
+ else if (mode === "name") by.sort((a, b) => (a.fullName || "").localeCompare(b.fullName || ""));
+ else
+ by.sort(
+ (a, b) => (b.stars ?? 0) - (a.stars ?? 0) || t(b.pushedAt) - t(a.pushedAt),
+ );
+ return by;
+ }
+
+ function render() {
+ const q = searchInput.value.trim().toLowerCase();
+ const visible = sortPlugins(plugins.filter((p) => matches(p, q)), sortSelect.value);
+
+ grid.replaceChildren();
+ for (const p of visible) grid.appendChild(buildCard(p));
+
+ if (visible.length === 0) {
+ statusEl.textContent = q
+ ? "No plugins match your search."
+ : "No plugins are listed yet.";
+ statusEl.hidden = false;
+ } else {
+ statusEl.hidden = true;
+ }
+ }
+
+ function setMeta(generatedAt) {
+ const parts = [];
+ parts.push(plugins.length + (plugins.length === 1 ? " plugin" : " plugins"));
+ const ago = timeAgo(generatedAt);
+ if (ago) parts.push("updated " + ago);
+ if (usingFallback) parts.push("preview data");
+ metaEl.textContent = parts.join(" · ");
+ }
+
+ function apply(data) {
+ plugins = Array.isArray(data && data.plugins) ? data.plugins : [];
+ setMeta(data && data.generatedAt);
+ render();
+ }
+
+ function showError() {
+ plugins = [];
+ metaEl.textContent = "";
+ grid.replaceChildren();
+ statusEl.textContent =
+ "The plugin marketplace is warming up. Check back in a few minutes.";
+ statusEl.hidden = false;
+ }
+
+ async function load() {
+ // Render the server-seeded data immediately (live in prod and dev; fixture
+ // only as a dev offline fallback).
+ if (initialData) {
+ usingFallback = initialIsFixture;
+ apply(initialData);
+ }
+ // Refresh from the same-origin function in production for the freshest snapshot.
+ if (apiUrl) {
+ try {
+ const res = await fetch(apiUrl, { headers: { accept: "application/json" } });
+ if (!res.ok) throw new Error("status " + res.status);
+ usingFallback = false;
+ apply(await res.json());
+ } catch (err) {
+ if (!initialData) showError();
+ }
+ } else if (!initialData) {
+ showError();
+ }
+ }
+
+ searchInput.addEventListener("input", render);
+ sortSelect.addEventListener("change", render);
+ load();
+ </script>
+
+ <style is:global>
+ .plugins-page {
+ padding-bottom: 5rem;
+ }
+
+ /* Reuse .section only for max-width centering, not its 5rem padding + top divider. */
+ .plugins-page .section {
+ padding-block: 0;
+ border-top: none;
+ }
+
+ .plugins-page .plugins-hero {
+ padding-top: 3rem;
+ }
+
+ .plugins-hero h2 {
+ margin: 0;
+ color: var(--ink);
+ font-family: var(--mono);
+ font-weight: 400;
+ letter-spacing: -0.04em;
+ line-height: 1.05;
+ font-size: clamp(2rem, 4vw, 3rem);
+ }
+
+ .plugins-lede {
+ max-width: 640px;
+ margin: 1.1rem 0 0;
+ color: var(--ink-soft);
+ font-size: clamp(1rem, 1.6vw, 1.15rem);
+ line-height: 1.6;
+ }
+
+ .plugins-lede code,
+ .plugins-trust code,
+ .plugins-publish-card code {
+ font-family: var(--mono);
+ font-size: 0.85em;
+ background: var(--bg-elevated);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 0.1em 0.35em;
+ white-space: nowrap;
+ }
+
+ .plugins-trust {
+ max-width: 640px;
+ margin: 1.1rem 0 0;
+ color: var(--muted);
+ font-size: 0.85rem;
+ line-height: 1.55;
+ }
+
+ .plugins-trust .status-dot {
+ display: inline-block;
+ margin-right: 0.4rem;
+ vertical-align: middle;
+ }
+
+ .plugins-trust a {
+ color: var(--accent);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+
+ /* Controls */
+ .plugins-page .plugins-controls {
+ position: relative;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+ margin-top: 2.5rem;
+ padding-bottom: 1.75rem;
+ }
+
+ /* Divider bound to the content box (padding box), so it aligns with the
+ search row and the cards instead of bleeding past them. */
+ .plugins-controls::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-top: 1px solid var(--line);
+ }
+
+ .plugins-search-wrap {
+ position: relative;
+ flex: 1 1 18rem;
+ display: flex;
+ align-items: center;
+ }
+
+ .plugins-search-icon {
+ position: absolute;
+ left: 0.7rem;
+ color: var(--muted-2);
+ font-family: var(--mono);
+ font-size: 0.9rem;
+ pointer-events: none;
+ }
+
+ .plugins-search {
+ width: 100%;
+ padding: 0.6rem 0.8rem 0.6rem 1.7rem;
+ background: var(--bg-elevated);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: 0.85rem;
+ }
+
+ .plugins-search:focus {
+ outline: none;
+ border-color: var(--accent);
+ }
+
+ .plugins-sort-wrap {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .plugins-sort-label {
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ text-transform: lowercase;
+ letter-spacing: 0.06em;
+ }
+
+ .plugins-sort {
+ padding: 0.55rem 0.7rem;
+ background: var(--bg-elevated);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: 0.82rem;
+ cursor: pointer;
+ }
+
+ .plugins-sort:focus {
+ outline: none;
+ border-color: var(--accent);
+ }
+
+ .plugins-meta {
+ margin: 0;
+ margin-left: auto;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ letter-spacing: 0.04em;
+ }
+
+ /* Grid */
+ .plugins-results {
+ margin-top: 2rem;
+ }
+
+ .plugins-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
+ gap: 0.85rem;
+ }
+
+ .plugin-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.7rem;
+ padding: 1.25rem;
+ background: var(--bg-elevated);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ transition: border-color 0.12s ease, transform 0.12s ease;
+ min-height: 8.5rem;
+ }
+
+ a.plugin-card:hover {
+ border-color: var(--accent);
+ transform: translateY(-2px);
+ }
+
+ .plugin-card-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.5rem;
+ }
+
+ .plugin-title {
+ font-family: var(--mono);
+ font-size: 0.95rem;
+ line-height: 1.3;
+ word-break: break-word;
+ }
+
+ .plugin-owner {
+ color: var(--muted);
+ }
+
+ .plugin-name {
+ color: var(--ink);
+ font-weight: 600;
+ }
+
+ .plugin-go {
+ color: var(--muted-2);
+ font-family: var(--mono);
+ flex: 0 0 auto;
+ transition: color 0.12s ease;
+ }
+
+ a.plugin-card:hover .plugin-go {
+ color: var(--accent);
+ }
+
+ .plugin-desc {
+ margin: 0;
+ color: var(--ink-soft);
+ font-size: 0.88rem;
+ line-height: 1.5;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .plugin-desc.is-empty {
+ color: var(--muted-2);
+ font-style: italic;
+ }
+
+ .plugin-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.9rem;
+ margin-top: auto;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ }
+
+ .plugin-stat {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ }
+
+ .plugin-star {
+ color: var(--yellow, #d4a72c);
+ }
+
+ .plugin-lang-dot {
+ width: 0.6rem;
+ height: 0.6rem;
+ border-radius: 50%;
+ flex: 0 0 auto;
+ }
+
+ .plugin-updated {
+ margin-left: auto;
+ color: var(--muted-2);
+ }
+
+ .plugins-status {
+ padding: 3rem 1rem;
+ text-align: center;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 0.85rem;
+ }
+
+ /* Publish */
+ .plugins-publish {
+ margin-top: 3.5rem;
+ }
+
+ .plugins-publish-card {
+ padding: 2rem;
+ background: var(--bg-elevated);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ }
+
+ .plugins-publish-card h3 {
+ margin: 0.25rem 0 0;
+ color: var(--ink);
+ font-family: var(--mono);
+ font-weight: 400;
+ font-size: 1.4rem;
+ letter-spacing: -0.02em;
+ }
+
+ .plugins-publish-card p {
+ max-width: 620px;
+ margin: 0.8rem 0 0;
+ color: var(--ink-soft);
+ line-height: 1.6;
+ }
+
+ .plugins-publish-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ margin-top: 1.5rem;
+ }
+
+ @media (max-width: 38rem) {
+ .plugins-meta {
+ margin-left: 0;
+ width: 100%;
+ }
+ }
+ </style>
+</MarketingLayout>
diff --git a/website/src/pages/releases/[...slug].astro b/website/src/pages/releases/[...slug].astro
deleted file mode 100644
index 420d4b9e..00000000
--- a/website/src/pages/releases/[...slug].astro
+++ /dev/null
@@ -1,89 +0,0 @@
----
-import { getCollection, render } from 'astro:content';
-import MarketingLayout from '../../components/MarketingLayout.astro';
-
-export async function getStaticPaths() {
- const releases = await getCollection('releases');
- return releases
- .filter((release) => !release.data.draft)
- .map((release) => {
- const slug = release.data.path;
- return {
- params: { slug },
- props: { release, slug },
- };
- });
-}
-
-const { release, slug } = Astro.props;
-const { Content, headings } = await render(release);
-const sectionHeadings = headings.filter((heading) => heading.depth === 2);
----
-
-<MarketingLayout
- title={`${release.data.version}: ${release.data.title} — Herdr releases`}
- description={release.data.description}
- canonicalPath={`/releases/${slug}/`}
- ogType="article"
->
- <main class="content-shell">
- <div class="article-layout release-layout section">
- <aside class="article-toc" aria-label="Release sections">
- <p>Sections</p>
- <nav>
- {sectionHeadings.map((heading) => (
- <a href={`#${heading.slug}`} data-heading-link={heading.slug}>{heading.text}</a>
- ))}
- </nav>
- </aside>
-
- <article class="article-shell release-shell">
- <header class="article-header release-header">
- <p class="post-meta">
- <a href="/releases/">Releases</a>
- <span>{release.data.version}</span>
- <time datetime={release.data.date.toISOString()}>{release.data.date.toLocaleDateString('en', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- })}</time>
- </p>
- <h1>{release.data.title}</h1>
- <p>{release.data.description}</p>
- </header>
- <div class="article-prose release-prose">
- <Content />
- </div>
- </article>
- </div>
- </main>
-
- <script>
- const headings = Array.from(document.querySelectorAll('.article-prose h2[id]'));
- const links = new Map(
- Array.from(document.querySelectorAll('[data-heading-link]')).map((link) => [
- link.getAttribute('data-heading-link'),
- link,
- ]),
- );
-
- function setActive(id) {
- for (const link of links.values()) link.removeAttribute('aria-current');
- links.get(id)?.setAttribute('aria-current', 'true');
- }
-
- if (headings.length > 0 && links.size > 0) {
- setActive(headings[0].id);
- const observer = new IntersectionObserver(
- (entries) => {
- const visible = entries
- .filter((entry) => entry.isIntersecting)
- .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
- if (visible?.target instanceof HTMLElement) setActive(visible.target.id);
- },
- { rootMargin: '-18% 0px -68% 0px', threshold: 0 },
- );
- for (const heading of headings) observer.observe(heading);
- }
- </script>
-</MarketingLayout>
diff --git a/website/src/pages/releases/index.astro b/website/src/pages/releases/index.astro
deleted file mode 100644
index 48263ab9..00000000
--- a/website/src/pages/releases/index.astro
+++ /dev/null
@@ -1,42 +0,0 @@
----
-import { getCollection } from 'astro:content';
-import MarketingLayout from '../../components/MarketingLayout.astro';
-
-const releases = (await getCollection('releases'))
- .filter((release) => !release.data.draft)
- .map((release) => ({ ...release, slug: release.data.path }))
- .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
----
-
-<MarketingLayout
- title="Herdr releases"
- description="Product-focused release notes for Herdr."
- canonicalPath="/releases/"
->
- <main class="content-shell">
- <section class="page-hero page-hero-compact">
- <p class="eyebrow"><span class="status-dot dot-working"></span>Releases</p>
- <h1>What changed in Herdr.</h1>
- <p class="hero-lede">
- See what the herd has been working on.
- </p>
- </section>
-
- <section class="section section-narrow" aria-label="Releases">
- <div class="post-list">
- {releases.map((release) => (
- <article class="post-card">
- <a class="post-card-link" href={`/releases/${release.slug}/`}>
- <div>
- <p class="release-card-version">{release.data.version}</p>
- <h2>{release.data.title}</h2>
- <p>{release.data.description}</p>
- </div>
- <span aria-hidden="true">-></span>
- </a>
- </article>
- ))}
- </div>
- </section>
- </main>
-</MarketingLayout>