Skip to content

Commit 7f71f56

Browse files
committed
feat: search headings
1 parent 8faaea3 commit 7f71f56

File tree

1 file changed

+117
-17
lines changed

1 file changed

+117
-17
lines changed

routes/api/search.ts

Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ interface RawIndexItem {
2323
type: SearchResult["type"];
2424
keywords?: string[];
2525
body?: string;
26+
headings?: string[];
27+
code?: string[];
2628
}
2729

2830
interface GitTreeItem {
@@ -47,6 +49,8 @@ function ensureOrama() {
4749
title: "string",
4850
excerpt: "string",
4951
body: "string",
52+
headings: "string",
53+
code: "string",
5054
type: "string",
5155
url: "string",
5256
label: "string",
@@ -89,6 +93,9 @@ async function buildIndex(): Promise<SearchResult[]> {
8993
console.warn("Failed to build docs index:", err);
9094
}
9195

96+
const mdHeadingRegex = /^#{1,6}\s+(.*)$/gm;
97+
const codeFenceRegex = /```(?:[a-zA-Z0-9_-]+)?\n([\s\S]*?)\n```/g;
98+
9299
try {
93100
for await (const entry of Deno.readDir("static/content/blog")) {
94101
if (!entry.isFile) continue;
@@ -112,8 +119,45 @@ async function buildIndex(): Promise<SearchResult[]> {
112119
console.warn("Failed to read blog post:", entry.name, e);
113120
}
114121
}
115-
} catch (_e) {
116-
// no blog dir - ignore
122+
} catch {
123+
// ignore
124+
}
125+
try {
126+
for await (const file of Deno.readDir("content/docs")) {
127+
if (!file.isFile || !file.name.endsWith(".md")) continue;
128+
try {
129+
const path = `content/docs/${file.name}`;
130+
const text = await Deno.readTextFile(path);
131+
const titleMatch = text.match(/^#\s+(.*)$/m);
132+
const title = titleMatch
133+
? titleMatch[1].trim()
134+
: file.name.replace(/\.md$/, "");
135+
const url = `/docs/${file.name.replace(/\.md$/, "")}`;
136+
const excerpt = text.split(/\n\n/)[1] || text.slice(0, 200);
137+
const headings: string[] = [];
138+
const code: string[] = [];
139+
let m;
140+
while ((m = mdHeadingRegex.exec(text)) !== null) {
141+
headings.push(m[1].trim());
142+
}
143+
while ((m = codeFenceRegex.exec(text)) !== null) {
144+
code.push(m[1].trim());
145+
}
146+
items.push({
147+
title,
148+
url,
149+
excerpt,
150+
type: "doc",
151+
body: text,
152+
headings,
153+
code,
154+
});
155+
} catch {
156+
// ignore
157+
}
158+
}
159+
} catch {
160+
// ignore
117161
}
118162
try {
119163
const OWNER = "tryandromeda";
@@ -223,6 +267,8 @@ async function buildIndex(): Promise<SearchResult[]> {
223267
title: it.title,
224268
excerpt: it.excerpt || "",
225269
body: it.body || "",
270+
headings: (it.headings || []).join(" "),
271+
code: (it.code || []).join(" "),
226272
type: it.type,
227273
url: it.url,
228274
label: (() => {
@@ -394,27 +440,81 @@ export const handler = {
394440
const hits =
395441
(oramaRes as unknown as { hits: Array<unknown> }).hits;
396442
const mapped: SearchResult[] = hits.map((h) => {
397-
const hit = h as unknown as {
398-
id?: string;
399-
score?: number;
400-
document?: Record<string, unknown>;
401-
};
402-
const doc = hit.document || {};
403-
const title = (doc.title as string) || (doc.name as string) ||
404-
"";
405-
const urlVal = (doc.url as string) || hit.id || "";
406-
const excerpt = (doc.excerpt as string) || "";
407-
const typeVal = (doc.type as string) ||
408-
(doc.label as string) || "doc";
409-
const labelVal = (doc.label as string) || "Docs";
443+
const hitObj = h as unknown as Record<string, unknown>;
444+
const doc = (hitObj.document as Record<string, unknown>) ||
445+
{};
446+
447+
const title = typeof doc.title === "string"
448+
? doc.title
449+
: (typeof doc.name === "string" ? doc.name : "");
450+
const urlVal = typeof doc.url === "string"
451+
? doc.url
452+
: (typeof hitObj.id === "string" ? hitObj.id : "");
453+
const excerpt = typeof doc.excerpt === "string"
454+
? doc.excerpt
455+
: "";
456+
const typeVal = typeof doc.type === "string"
457+
? doc.type
458+
: (typeof doc.label === "string" ? doc.label : "doc");
459+
const labelVal = typeof doc.label === "string"
460+
? doc.label
461+
: "Docs";
462+
463+
const highlights: string[] = [];
464+
const matches = Array.isArray(hitObj.matches)
465+
? hitObj.matches as unknown[]
466+
: undefined;
467+
if (matches) {
468+
for (const m of matches) {
469+
if (!m || typeof m !== "object") continue;
470+
const mo = m as Record<string, unknown>;
471+
const frag = typeof mo.match === "string"
472+
? mo.match
473+
: (typeof mo.fragment === "string"
474+
? mo.fragment
475+
: undefined);
476+
if (typeof frag === "string") {
477+
highlights.push(frag.slice(0, 300));
478+
continue;
479+
}
480+
if (typeof mo.field === "string") {
481+
const f = mo.field;
482+
const fv = doc[f];
483+
if (typeof fv === "string") {
484+
highlights.push(fv.slice(0, 300));
485+
}
486+
}
487+
}
488+
} else if (Array.isArray(hitObj.highlights)) {
489+
const hh = hitObj.highlights as unknown[];
490+
for (const item of hh.slice(0, 3)) {
491+
if (typeof item === "string") highlights.push(item);
492+
}
493+
} else {
494+
const raw = rawIndexCache &&
495+
rawIndexCache.find((r) => r.url === urlVal);
496+
if (raw && raw.body) {
497+
const bodyLower = raw.body.toLowerCase();
498+
const qLower = q.toLowerCase();
499+
const pos = bodyLower.indexOf(qLower);
500+
if (pos >= 0) {
501+
highlights.push(makeSnippet(raw.body, pos, 80));
502+
} else if (raw.headings && raw.headings.length) {
503+
highlights.push(raw.headings.slice(0, 3).join(" — "));
504+
}
505+
}
506+
}
507+
410508
return {
411509
title,
412510
url: urlVal,
413511
excerpt,
414512
type: (typeVal as string) as SearchResult["type"],
415513
label: labelVal,
416-
score: typeof hit.score === "number" ? hit.score : 0,
417-
highlights: [],
514+
score: typeof hitObj.score === "number"
515+
? (hitObj.score as number)
516+
: 0,
517+
highlights,
418518
};
419519
});
420520

0 commit comments

Comments
 (0)