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
62 changes: 43 additions & 19 deletions apps/blog/src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createMixedbreadSearchAPI } from "fumadocs-core/search/mixedbread";
import Mixedbread from "@mixedbread/sdk";
import { type BlogSearchResult } from "../../../lib/search-types";

// `generated_metadata` is stored as a generic object, but blog search always
// indexes the frontmatter fields below for each post.
export type GeneratedMetadata = {
title: string;
slug: string;
Expand All @@ -16,6 +18,7 @@ export type GeneratedMetadata = {
excerpt: string;
};
export const dynamic = "force-dynamic";

const mixedbreadApiKey = process.env.MIXEDBREAD_API_KEY;
if (!mixedbreadApiKey) {
throw new Error("MIXEDBREAD_API_KEY environment variable is required");
Expand All @@ -26,26 +29,47 @@ export const { GET } = createMixedbreadSearchAPI({
client,
storeIdentifier: "blog-search",
topK: 20,
// Mixedbread can return multiple chunk hits for the same post. We normalize
// each hit into the blog search UI shape, then sort and dedupe the results.
transform: (results, _query) => {
return results.flatMap((item) => {
const metadata = item.generated_metadata as unknown as GeneratedMetadata;
const slug = (metadata?.slug ?? "").replace(/^\/+/, "");
const title = metadata?.metaTitle ?? metadata?.title ?? "Untitled";
const seenUrls = new Set<string>();

return results
.flatMap((item) => {
// Mixedbread types `generated_metadata` loosely, so we narrow it to the
// frontmatter shape we index for blog posts.
const metadata = item.generated_metadata as unknown as GeneratedMetadata;
const slug = (metadata?.slug ?? "").replace(/^\/+/, "");
const title = metadata?.metaTitle ?? metadata?.title ?? "Untitled";
Comment on lines +41 to +43
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Guard date before sorting to avoid runtime crashes.

Because generated_metadata is loosely typed, metadata.date can be missing; then Line 63 can throw when calling localeCompare on undefined.

Proposed fix
-        const metadata = item.generated_metadata as unknown as GeneratedMetadata;
+        const metadata = item.generated_metadata as Partial<GeneratedMetadata> | undefined;
         const slug = (metadata?.slug ?? "").replace(/^\/+/, "");
         const title = metadata?.metaTitle ?? metadata?.title ?? "Untitled";
+        const date = typeof metadata?.date === "string" ? metadata.date : "";
         const formattedUrl = slug ? `/${slug}` : "#";
         const base = `${item.file_id}-${item.chunk_index}`;
@@
-            date: metadata.date,
+            date,
           },
         ];
@@
-      .sort((a, b) => b.date.localeCompare(a.date))
+      .sort((a, b) => b.date.localeCompare(a.date))

Also applies to: 56-57, 63-63

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/blog/src/app/api/search/route.ts` around lines 41 - 43, The sort
comparator is calling localeCompare on metadata.date which may be undefined;
update the comparator and any direct date comparisons (and the other comparisons
at the same spots) to guard and normalize the value first — e.g., compute
safeDateA = String(a.generated_metadata?.date ?? "") and safeDateB =
String(b.generated_metadata?.date ?? "") (or use metadata?.date ?? "" when you
already have metadata variable) and call safeDateA.localeCompare(safeDateB);
similarly ensure any other uses of metadata.date (and other loosely-typed
generated_metadata fields referenced around the same code) are null-coalesced to
a safe default before calling string methods.

const formattedUrl = slug ? `/${slug}` : "#";
const base = `${item.file_id}-${item.chunk_index}`;

const chunkResults: BlogSearchResult[] = [
{
id: `${base}-page`,
type: "page",
content: title,
url: formattedUrl,
description: metadata?.metaDescription ?? "",
heroImagePath: metadata?.heroImagePath ?? "",
tags: metadata?.tags ?? [],
date: metadata.date,
},
];
return chunkResults;
})
// Dates are indexed as `YYYY-MM-DD`, so string comparison sorts them
// chronologically without converting them to `Date` objects.
.sort((a, b) => b.date.localeCompare(a.date))
// Keep only the first hit for each post URL after sorting so duplicate
// chunk matches collapse into one search result.
.filter((item) => {
if (seenUrls.has(item.url)) {
return false;
}

const formattedUrl = slug ? `/${slug}` : "#";
const base = `${item.file_id}-${item.chunk_index}`;
const chunkResults: BlogSearchResult[] = [
{
id: `${base}-page`,
type: "page",
content: title,
url: formattedUrl,
description: metadata?.metaDescription ?? "",
heroImagePath: metadata?.heroImagePath ?? "",
tags: metadata?.tags ?? [],
},
];
return chunkResults;
});
seenUrls.add(item.url);
return true;
});
},
});
1 change: 1 addition & 0 deletions apps/blog/src/lib/search-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export type BlogSearchResult = {
description: string;
heroImagePath: string;
tags: string[];
date: string;
};
Loading