diff --git a/src/components/Navbar.astro b/src/components/Navbar.astro
index 324bba8..fcd5aa1 100644
--- a/src/components/Navbar.astro
+++ b/src/components/Navbar.astro
@@ -12,6 +12,11 @@ interface Props {
}
const { user = null } = Astro.props;
+
+// The navbar is shared by every route, so the search box pre-fills from the
+// current ?q= and submits back to the catalog. That keeps search working from
+// pages that have no catalog of their own (a plugin or publisher page).
+const searchValue = Astro.url.searchParams.get('q') ?? '';
---
@@ -30,7 +35,12 @@ const { user = null } = Astro.props;
-
+
+
+
+
+
+
+
{
if (event.key === 'Escape') closeMenu();
});
+
+ // --- Search box ---
+ const searchInput = document.querySelector('#search');
+ const clearButton = document.querySelector('[data-search-clear]');
+
+ function syncClearButton() {
+ if (!searchInput || !clearButton) return;
+ clearButton.classList.toggle('hidden', searchInput.value.length === 0);
+ }
+
+ searchInput?.addEventListener('input', syncClearButton);
+
+ clearButton?.addEventListener('click', () => {
+ if (!searchInput) return;
+ searchInput.value = '';
+ syncClearButton();
+ // A ?q= search is answered server-side, so the grid only holds the matching
+ // subset and restoring everything needs a fresh request. Without one the full
+ // catalog is already in the DOM, so let the page's own filter reset it in place.
+ if (new URLSearchParams(location.search).has('q')) {
+ location.href = '/';
+ } else {
+ searchInput.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ });
+
+ syncClearButton();
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 833c750..1534f85 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -113,6 +113,27 @@ export async function listPlugins(db: D1Database): Promise {
return (results ?? []).map(toPlugin);
}
+/**
+ * Case-insensitive search across the fields a visitor can see on a card: name,
+ * description and category. The term is bound, never interpolated, so the `%`
+ * wildcards are the only pattern syntax that reaches SQL. `COALESCE` keeps rows
+ * with a NULL description matchable.
+ */
+export async function searchPlugins(db: D1Database, query: string): Promise {
+ const term = `%${query.toLowerCase()}%`;
+ const { results } = await db
+ .prepare(
+ `SELECT ${PLUGIN_COLUMNS} ${PLUGIN_SOURCE}
+ WHERE LOWER(p.name) LIKE ?1
+ OR LOWER(COALESCE(p.description, '')) LIKE ?1
+ OR LOWER(p.category) LIKE ?1
+ ORDER BY p.downloads DESC, p.name ASC`,
+ )
+ .bind(term)
+ .all();
+ return (results ?? []).map(toPlugin);
+}
+
export async function getPlugin(db: D1Database, id: string): Promise {
const row = await db
.prepare(`SELECT ${PLUGIN_COLUMNS} ${PLUGIN_SOURCE} WHERE p.id = ?`)
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 4bedd9b..4443861 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -3,7 +3,7 @@ import Layout from '../layouts/Layout.astro';
import CategoryFilter from '../components/CategoryFilter.astro';
import Card from '../components/Card.astro';
import Icon from '../components/Icon.astro';
-import { getDB, listPlugins, type Plugin } from '../lib/db';
+import { getDB, listPlugins, searchPlugins, type Plugin } from '../lib/db';
import { EMPTY_STATE, SITE, orderCategories } from '../lib/site';
export const prerender = false;
@@ -16,10 +16,17 @@ if (legacyId) {
return Astro.redirect(`/plugin/${encodeURIComponent(legacyId)}`, 301);
}
+// A ?q= search is answered by D1 so the results are real at first paint (and the
+// grid is populated before the client script runs). The navbar pre-fills the input
+// with the same value, so the box and the results never disagree on load.
+const searchParam = Astro.url.searchParams.get('q')?.trim() ?? '';
+const isSearching = searchParam.length > 0;
+
let plugins: Plugin[] = [];
let dbError: string | null = null;
try {
- plugins = await listPlugins(getDB(Astro.locals));
+ const db = getDB(Astro.locals);
+ plugins = isSearching ? await searchPlugins(db, searchParam) : await listPlugins(db);
} catch (error) {
dbError = error instanceof Error ? error.message : String(error);
}
@@ -99,7 +106,7 @@ const total = plugins.length;
{dbError}
- ) : total === 0 ? (
+ ) : total === 0 && !isSearching ? (
-
+
{total}
{total === 1 ? ' extension' : ' extensions'}
+ {isSearching && for “{searchParam}” }
Sorted live from D1
@@ -309,12 +317,12 @@ const total = plugins.length;
}
});
- // --- Deep links: ?q= pre-filters the grid ---
- const params = new URLSearchParams(location.search);
- const q = params.get('q');
- if (q && searchInput) {
- searchInput.value = q;
- state.query = q.trim().toLowerCase();
+ // --- Initial query ---
+ // The navbar input is pre-filled server-side from ?q=, so adopt whatever it
+ // holds (trimmed, lowercased) as the starting query and reconcile the grid with
+ // it. This keeps the box and the server-rendered results in agreement on load.
+ if (searchInput && searchInput.value.trim()) {
+ state.query = searchInput.value.trim().toLowerCase();
applyFilters();
}
diff --git a/tests/search.test.ts b/tests/search.test.ts
new file mode 100644
index 0000000..207521d
--- /dev/null
+++ b/tests/search.test.ts
@@ -0,0 +1,104 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+import { searchPlugins, type PluginRow } from '../src/lib/db.ts';
+
+// --- Minimal D1 stand-in ----------------------------------------------------
+// D1 is an external boundary, so it is faked here; the code under test
+// (`searchPlugins`) is real. The fake records the SQL and the bound values so the
+// test can assert on how the query is built — most importantly that the search
+// term is *bound*, never spliced into the statement.
+
+interface Capture {
+ sql: string;
+ bindings: unknown[];
+}
+
+function fakeDb(rows: PluginRow[], capture: Capture): D1Database {
+ const statement = (sql: string) => ({
+ bind: (...values: unknown[]) => {
+ capture.sql = sql;
+ capture.bindings = values;
+ return {
+ all: async () => ({ results: rows }),
+ first: async () => rows[0] ?? null,
+ };
+ },
+ all: async () => ({ results: rows }),
+ first: async () => rows[0] ?? null,
+ });
+ return { prepare: (sql: string) => statement(sql) } as unknown as D1Database;
+}
+
+function row(overrides: Partial
= {}): PluginRow {
+ return {
+ id: 'nkit',
+ name: 'NKit',
+ version: '3.2.0',
+ min_core_version: '3.9.0',
+ publisher_id: 'm5devs',
+ publisher_name: 'M5 Dev',
+ publisher_verified: 1,
+ category: 'Nintendo',
+ description: 'Disc Restoration Suite',
+ readme_markdown: null,
+ routes_json: null,
+ platforms_json: null,
+ download_url: 'https://example.test/nkit.orplug',
+ sha256: 'abc',
+ icon: null,
+ downloads: 0,
+ created_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+ };
+}
+
+test('searchPlugins binds the lowercased term wrapped in wildcards', async () => {
+ const capture: Capture = { sql: '', bindings: [] };
+ await searchPlugins(fakeDb([row()], capture), 'NKit');
+
+ assert.deepEqual(capture.bindings, ['%nkit%']);
+});
+
+test('searchPlugins matches name, description and category case-insensitively', async () => {
+ const capture: Capture = { sql: '', bindings: [] };
+ await searchPlugins(fakeDb([], capture), 'x');
+
+ const sql = capture.sql.toLowerCase();
+ assert.match(sql, /lower\(p\.name\) like \?1/);
+ assert.match(sql, /lower\(coalesce\(p\.description, ''\)\) like \?1/);
+ assert.match(sql, /lower\(p\.category\) like \?1/);
+});
+
+test('searchPlugins reuses the shared plugin source so publisher name/verified resolve', async () => {
+ const capture: Capture = { sql: '', bindings: [] };
+ await searchPlugins(fakeDb([], capture), 'x');
+
+ assert.match(capture.sql, /LEFT JOIN publishers pub ON pub\.id = p\.publisher_id/i);
+});
+
+test('searchPlugins never splices the raw term into the SQL text', async () => {
+ const capture: Capture = { sql: '', bindings: [] };
+ const payload = "'; DROP TABLE plugins; --";
+ await searchPlugins(fakeDb([], capture), payload);
+
+ assert.equal(capture.sql.includes('DROP TABLE'), false);
+ assert.deepEqual(capture.bindings, [`%${payload.toLowerCase()}%`]);
+});
+
+test('searchPlugins maps rows through toPlugin (publisher name + verified flag)', async () => {
+ const capture: Capture = { sql: '', bindings: [] };
+ const [plugin] = await searchPlugins(fakeDb([row()], capture), 'nkit');
+
+ assert.equal(plugin.id, 'nkit');
+ assert.equal(plugin.name, 'NKit');
+ assert.equal(plugin.publisherName, 'M5 Dev');
+ assert.equal(plugin.publisherVerified, true);
+});
+
+test('searchPlugins returns an empty array when nothing matches', async () => {
+ const capture: Capture = { sql: '', bindings: [] };
+ const plugins = await searchPlugins(fakeDb([], capture), 'zzzznomatch');
+
+ assert.deepEqual(plugins, []);
+});