navigate.st: Theseus-home-style landing — search + quick links + install CTA

Adds a unified /api/search/<q> endpoint to the gateway and rebuilds
navigate.st's search box + featured cards around the new "6 curated names,
everything else via search" IA.

Gateway (Argus/src/gateway/public-gateway.mjs): /api/search/<q> iterates the
cached full-index Map and scores every match — label-exact (100) beats
label-substring (70) beats h-record content substring (50). Content search
strips HTML tags before matching and returns a ±40/60-char snippet around
each hit. Results sorted by score then name, capped at 50, exposes a
`truncated` flag. Same 30-second cache the record picker + /api/labels/
already use — no extra fetch on the hot path. s3/ip content search awaits
Helios v1 (deferred by design).

Client (navigate.st/index.html): submit now routes three ways —
  <label>.<tld…>   → /bns/<name> straight, no API call
  everything else  → /api/search/<q>, render ranked panel
    ‣ 0 hits            → "not on chain — Register at Sirius · Search the web"
    ‣ 1 label-exact hit → auto-redirect (fast path preserved)
    ‣ otherwise         → results panel with match-type badge, snippet, mark-
                          highlighted query term
The old bare-label /api/labels/ path is retired from navigate.st (the endpoint
stays live — Sirius registration availability still uses it).

Featured cards on the main page are now the six user-selected names:
hello.bch, coinspectrum.x, silentmode.x, theseus.x, game.x, deviant.x.
Three are already on chain (hello.bch / coinspectrum.x / game.x) and render
as normal cards; three are not yet registered (silentmode.x / theseus.x /
deviant.x) and render as dashed "coming soon" pending cards — non-clickable,
faded, prevents 404s until they're actually minted via Sirius. The old 8-name
Quick Links grid is gone; "everything else via search" is now the story.

Deployed. Live search verified with poutakidis (1 hit → auto-redirect),
deviant (3 hits including a content match), xyzzynomatch (empty-state CTA).
Gateway rollback point at /opt/bns-gateway/public-gateway.mjs.bak.1787246702.
This commit is contained in:
Local Dev 2026-08-20 20:44:09 +02:00
parent 8228bda939
commit f77c6558ee

View file

@ -294,6 +294,62 @@ const server = http.createServer(async (req, res) => {
return;
}
// --- unified search: label + content ---
// GET /api/search/<q> -> { query, count, results: [{ name, tld, matchType, score, snippet, records }] }
// Scores: label-exact (100) > label-substring (70) > h-record content (50).
// Content search covers on-chain inline HTML only; Sia/ip sites await Helios v1.
// Same cached index as /api/labels/ and /api/name/ — no extra fetch cost on the hot path.
if (req.url.startsWith("/api/search/")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("content-type", "application/json");
const q = decodeURIComponent(req.url.slice("/api/search/".length).split("?")[0]).toLowerCase().trim();
if (!q || q.length < 2 || q.length > 64) {
res.writeHead(400);
res.end(JSON.stringify({ error: "query must be 2-64 chars" }));
return;
}
try {
const idx = await getIndex();
const results = [];
for (const [key, entry] of idx) {
const dot = key.indexOf(".");
const label = dot < 0 ? key : key.slice(0, dot);
const tld = dot < 0 ? "bch" : key.slice(dot + 1);
const displayName = dot < 0 ? key + ".bch" : key;
const records = entry?.records ?? {};
let matchType = null, score = 0, snippet = null;
if (label === q) { matchType = "label-exact"; score = 100; }
else if (label.includes(q)) { matchType = "label-substring"; score = 70; }
// Content search over inline HTML (h record). Strip tags first so a
// query like "poutakidis" doesn't accidentally match "class=poutakidis".
if (typeof records.h === "string" && records.h) {
const text = records.h.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
const i = text.toLowerCase().indexOf(q);
if (i >= 0) {
if (!matchType) { matchType = "content"; score = 50; }
const start = Math.max(0, i - 40);
const end = Math.min(text.length, i + q.length + 60);
snippet = (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
}
}
if (matchType) {
results.push({ name: displayName, tld, matchType, score, snippet,
records: entry?.records ?? null, category: entry?.category ?? null });
}
}
results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
const capped = results.slice(0, 50);
res.writeHead(200);
res.end(JSON.stringify({ query: q, count: capped.length, truncated: results.length > 50, results: capped }));
} catch (e) {
res.writeHead(500); res.end(JSON.stringify({ error: e.message }));
}
return;
}
// --- subdomain form: <flattened>.bch.<SUB_BASE> ---
if (hostHeader.endsWith("." + SUB_BASE)) {
const inner = hostHeader.slice(0, -("." + SUB_BASE).length); // "<flattened>.bch" or "<flattened>"