From 64929f432e1f83bed35ff17cd3be78af4d1253a6 Mon Sep 17 00:00:00 2001
From: openhands
Date: Thu, 5 Nov 2026 22:47:27 +0000
Subject: [PATCH] fix: implement instant real-time and server-side search
filtering on storefront
The search box was dead on every page except the catalog. It lives in the shared
Navbar, but the only handler was in index.astro, so typing on /plugin/*,
/publisher/* or /dashboard did nothing at all. There was also no form, so Enter
never submitted, no clear (X) control, and ?q= was handled client-side only.
Client-side instant filtering (typing narrows the grid, zero matches reveal the
ROMeo empty state) already worked on the homepage and is kept as-is. The changes:
- Navbar: the box is now a real GET form to "/" with name="q", so Enter searches
from any route. It pre-fills from ?q= and gains an X button that appears only
when there is text; clearing re-requests "/" when the grid holds a server-filtered
subset, or resets the filter in place otherwise.
- index.astro: read ?q= and answer it in D1, so results are correct at first paint
with no flash of the unfiltered catalog. The "catalog is empty" state is skipped
while searching, so a zero-match search shows the grid + ROMeo no-results panel
instead of claiming the catalog has no extensions. The result heading names the
term.
- db.ts: add searchPlugins(), a bound LIKE over LOWER(name/description/category)
reusing the shared column list and publisher join.
The term is bound, never interpolated, so the only pattern syntax reaching SQL is
the % wildcard. tests/search.test.ts pins that down along with the matched columns
and the row mapping.
Co-authored-by: openhands
---
src/components/Navbar.astro | 58 ++++++++++++++++++--
src/lib/db.ts | 21 ++++++++
src/pages/index.astro | 28 ++++++----
tests/search.test.ts | 104 ++++++++++++++++++++++++++++++++++++
4 files changed, 198 insertions(+), 13 deletions(-)
create mode 100644 tests/search.test.ts
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, []);
+});