Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-walls-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds structured categories and FTS5 full-text search to the marketplace. Plugins can be assigned up to 3 categories, browsed by category, and searched with FTS5 ranking. Includes 12 seed categories and FTS5 input sanitization.
24 changes: 23 additions & 1 deletion packages/admin/src/components/MarketplaceBrowse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import {
Warning,
ArrowsClockwise,
} from "@phosphor-icons/react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import * as React from "react";

import {
CAPABILITY_LABELS,
fetchCategories,
searchMarketplace,
type MarketplacePluginSummary,
type MarketplaceSearchOpts,
Expand Down Expand Up @@ -51,6 +52,7 @@ export function MarketplaceBrowse({ installedPluginIds = new Set() }: Marketplac
const [searchQuery, setSearchQuery] = React.useState("");
const [sort, setSort] = React.useState<SortOption>("installs");
const [capability, setCapability] = React.useState<string>("");
const [category, setCategory] = React.useState<string>("");
const [debouncedQuery, setDebouncedQuery] = React.useState("");

// Debounce search input
Expand All @@ -59,9 +61,16 @@ export function MarketplaceBrowse({ installedPluginIds = new Set() }: Marketplac
return () => clearTimeout(timer);
}, [searchQuery]);

const { data: categories } = useQuery({
queryKey: ["marketplace", "categories"],
queryFn: fetchCategories,
staleTime: 5 * 60 * 1000,
});

const searchOpts: MarketplaceSearchOpts = {
q: debouncedQuery || undefined,
capability: capability || undefined,
category: category || undefined,
sort,
limit: 20,
};
Expand Down Expand Up @@ -109,6 +118,19 @@ export function MarketplaceBrowse({ installedPluginIds = new Set() }: Marketplac
</option>
))}
</select>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="rounded-md border bg-kumo-base px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-kumo-ring"
aria-label="Filter by category"
>
<option value="">All categories</option>
{categories?.map((cat) => (
<option key={cat.slug} value={cat.slug}>
{cat.name}
</option>
))}
</select>
<select
value={sort}
onChange={(e) => {
Expand Down
23 changes: 23 additions & 0 deletions packages/admin/src/lib/api/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,20 @@ export interface MarketplaceSearchResult {
export interface MarketplaceSearchOpts {
q?: string;
capability?: string;
category?: string;
sort?: "installs" | "updated" | "created" | "name";
cursor?: string;
limit?: number;
}

export interface MarketplaceCategory {
id: string;
slug: string;
name: string;
description?: string;
icon?: string;
}

/** Update check result per plugin */
export interface PluginUpdateInfo {
pluginId: string;
Expand Down Expand Up @@ -119,6 +128,7 @@ export async function searchMarketplace(
const params = new URLSearchParams();
if (opts.q) params.set("q", opts.q);
if (opts.capability) params.set("capability", opts.capability);
if (opts.category) params.set("category", opts.category);
if (opts.sort) params.set("sort", opts.sort);
if (opts.cursor) params.set("cursor", opts.cursor);
if (opts.limit) params.set("limit", String(opts.limit));
Expand All @@ -129,6 +139,19 @@ export async function searchMarketplace(
return parseApiResponse<MarketplaceSearchResult>(response, "Marketplace search failed");
}

/**
* Fetch available marketplace categories.
* Proxied through /_emdash/api/admin/plugins/marketplace/categories
*/
export async function fetchCategories(): Promise<MarketplaceCategory[]> {
const response = await apiFetch(`${MARKETPLACE_BASE}/categories`);
const data = await parseApiResponse<{ items: MarketplaceCategory[] }>(
response,
"Failed to fetch categories",
);
return data.items;
}

/**
* Get full plugin detail.
* Proxied through /_emdash/api/admin/plugins/marketplace/:id
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export {
handleMarketplaceUpdateCheck,
handleMarketplaceSearch,
handleMarketplaceGetPlugin,
handleMarketplaceGetCategories,
handleThemeSearch,
handleThemeGetDetail,
loadBundleFromR2,
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/api/handlers/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,35 @@ export async function handleMarketplaceGetPlugin(
}
}

export async function handleMarketplaceGetCategories(
marketplaceUrl: string | undefined,
): Promise<ApiResult<unknown>> {
const client = getClient(marketplaceUrl);
if (!client) {
return {
success: false,
error: { code: "MARKETPLACE_NOT_CONFIGURED", message: "Marketplace is not configured" },
};
}

try {
const categories = await client.getCategories();
return { success: true, data: { items: categories } };
} catch (err) {
if (err instanceof MarketplaceUnavailableError) {
return {
success: false,
error: { code: "MARKETPLACE_UNAVAILABLE", message: "Marketplace is unavailable" },
};
}
console.error("Failed to get marketplace categories:", err);
return {
success: false,
error: { code: "GET_CATEGORIES_FAILED", message: "Failed to get categories" },
};
}
}

// ── Theme proxy handlers ──────────────────────────────────────────

export async function handleThemeSearch(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Marketplace categories proxy endpoint
*
* GET /_emdash/api/admin/plugins/marketplace/categories - List marketplace categories
*/

import type { APIRoute } from "astro";

import { requirePerm } from "#api/authorize.js";
import { apiError, unwrapResult } from "#api/error.js";
import { handleMarketplaceGetCategories } from "#api/index.js";

export const prerender = false;

export const GET: APIRoute = async ({ locals }) => {
const { emdash, user } = locals;

if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
}

const denied = requirePerm(user, "plugins:read");
if (denied) return denied;

const result = await handleMarketplaceGetCategories(emdash.config.marketplace);
return unwrapResult(result);
};
17 changes: 17 additions & 0 deletions packages/core/src/plugins/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ export interface MarketplaceSearchResult {
nextCursor?: string;
}

export interface MarketplaceCategory {
id: string;
slug: string;
name: string;
description?: string;
icon?: string;
}

// ── Theme types ───────────────────────────────────────────────────

export interface MarketplaceThemeSummary {
Expand Down Expand Up @@ -170,6 +178,9 @@ export interface MarketplaceClient {
/** Fire-and-forget install stat (never throws) */
reportInstall(id: string, version: string): Promise<void>;

/** List available categories */
getCategories(): Promise<MarketplaceCategory[]>;

/** Search theme listings */
searchThemes(
query?: string,
Expand Down Expand Up @@ -271,6 +282,12 @@ class MarketplaceClientImpl implements MarketplaceClient {
}
}

async getCategories(): Promise<MarketplaceCategory[]> {
const url = `${this.baseUrl}/api/v1/categories`;
const data = await this.fetchJson<{ items: MarketplaceCategory[] }>(url);
return data.items;
}

async reportInstall(id: string, version: string): Promise<void> {
// Generate a stable site hash from the site origin (best-effort, non-identifying)
const siteHash = await generateSiteHash(this.siteOrigin);
Expand Down
28 changes: 28 additions & 0 deletions packages/core/tests/unit/plugins/marketplace-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,34 @@ describe("MarketplaceClient", () => {
});
});

describe("getCategories", () => {
it("returns categories from the marketplace", async () => {
const categories = [
{
id: "cat_seo",
slug: "seo",
name: "SEO & Metadata",
description: "Search engine optimization",
},
{ id: "cat_forms", slug: "forms", name: "Forms & Input", description: "Form builders" },
];
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ items: categories }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);

const result = await client.getCategories();

expect(result).toEqual(categories);
expect(fetchSpy).toHaveBeenCalledWith(
`${BASE_URL}/api/v1/categories`,
expect.objectContaining({ headers: { Accept: "application/json" } }),
);
});
});

describe("trailing slash handling", () => {
it("strips trailing slashes from base URL", async () => {
const clientWithSlash = createMarketplaceClient("https://example.com/");
Expand Down
109 changes: 109 additions & 0 deletions packages/marketplace/migrations/0000_initial.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
-- Initial marketplace schema
-- This migration represents the schema as deployed at marketplace launch.

CREATE TABLE IF NOT EXISTS authors (
id TEXT PRIMARY KEY,
github_id TEXT UNIQUE,
name TEXT NOT NULL,
email TEXT,
avatar_url TEXT,
verified INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
author_id TEXT NOT NULL REFERENCES authors(id),
repository_url TEXT,
homepage_url TEXT,
license TEXT,
capabilities TEXT NOT NULL,
keywords TEXT,
has_icon INTEGER DEFAULT 0,
install_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_plugins_author ON plugins(author_id);

CREATE TABLE IF NOT EXISTS plugin_versions (
id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL REFERENCES plugins(id),
version TEXT NOT NULL,
min_emdash_version TEXT,
bundle_key TEXT NOT NULL,
bundle_size INTEGER NOT NULL,
checksum TEXT NOT NULL,
changelog TEXT,
readme TEXT,
has_icon INTEGER DEFAULT 0,
screenshot_count INTEGER DEFAULT 0,
capabilities TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
workflow_id TEXT,
audit_id TEXT,
audit_verdict TEXT,
image_audit_id TEXT,
image_audit_verdict TEXT,
published_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(plugin_id, version)
);
CREATE INDEX IF NOT EXISTS idx_plugin_versions_plugin ON plugin_versions(plugin_id);
CREATE INDEX IF NOT EXISTS idx_plugin_versions_plugin_status ON plugin_versions(plugin_id, status);

CREATE TABLE IF NOT EXISTS plugin_audits (
id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
version TEXT NOT NULL,
verdict TEXT NOT NULL,
risk_score INTEGER NOT NULL,
summary TEXT NOT NULL,
findings TEXT NOT NULL,
model TEXT NOT NULL,
duration_ms INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (plugin_id) REFERENCES plugins(id)
);
CREATE INDEX IF NOT EXISTS idx_plugin_audits_plugin_version ON plugin_audits(plugin_id, version);

CREATE TABLE IF NOT EXISTS plugin_image_audits (
id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
version TEXT NOT NULL,
verdict TEXT NOT NULL,
findings TEXT NOT NULL,
model TEXT NOT NULL,
duration_ms INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (plugin_id) REFERENCES plugins(id)
);
CREATE INDEX IF NOT EXISTS idx_plugin_image_audits_pv ON plugin_image_audits(plugin_id, version);

CREATE TABLE IF NOT EXISTS installs (
plugin_id TEXT NOT NULL REFERENCES plugins(id),
site_hash TEXT NOT NULL,
version TEXT NOT NULL,
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (plugin_id, site_hash)
);
CREATE INDEX IF NOT EXISTS idx_installs_plugin ON installs(plugin_id);

CREATE TABLE IF NOT EXISTS themes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
author_id TEXT NOT NULL REFERENCES authors(id),
preview_url TEXT NOT NULL,
demo_url TEXT,
repository_url TEXT,
homepage_url TEXT,
license TEXT,
keywords TEXT,
has_thumbnail INTEGER DEFAULT 0,
screenshot_count INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_themes_author ON themes(author_id);
Loading
Loading