Skip to content

Commit 7bb96f6

Browse files
carlamhartington
andauthored
feat: DR-7744 Support page (#7699)
* feat(site): add support page and search Launch the site support page with searchable support resources and the related site config, data, and styling updates needed to support it. Made-with: Cursor * fix(site): fix failing build --------- Co-authored-by: Mike Hartington <mikehartington@gmail.com>
1 parent 9eebb4a commit 7bb96f6

20 files changed

Lines changed: 3301 additions & 1437 deletions

File tree

apps/blog/src/components/search.tsx

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,7 @@ import { SearchIcon } from "lucide-react";
2222
import { Badge, Spinner } from "@prisma/eclipse";
2323
import { BlogSearchResult } from "../lib/search-types";
2424

25-
export function CustomSearchDialogIcon({
26-
isLoading,
27-
}: {
28-
isLoading: boolean;
29-
}) {
25+
export function CustomSearchDialogIcon({ isLoading }: { isLoading: boolean }) {
3026
return (
3127
<>
3228
{isLoading ? (
@@ -45,13 +41,12 @@ type SearchResultItemProps = Parameters<
4541
function isBlogSearchResult(value: unknown): value is BlogSearchResult {
4642
if (!value || typeof value !== "object") return false;
4743
const candidate = value as Partial<BlogSearchResult>;
48-
return typeof candidate.url === "string" && typeof candidate.content === "string";
44+
return (
45+
typeof candidate.url === "string" && typeof candidate.content === "string"
46+
);
4947
}
5048

51-
function SearchResultItem({
52-
item,
53-
onClick,
54-
}: SearchResultItemProps): ReactNode {
49+
function SearchResultItem({ item, onClick }: SearchResultItemProps): ReactNode {
5550
if (!isBlogSearchResult(item)) return null;
5651
const post = item;
5752

@@ -98,20 +93,20 @@ function SearchResultItem({
9893
);
9994
}
10095

101-
102-
103-
10496
export default function CustomSearchDialog(props: SharedProps) {
10597
const { search, setSearch, query } = useDocsSearch({
106-
type: 'fetch',
107-
api: withBlogBasePath('/api/search'),
98+
type: "fetch",
99+
api: withBlogBasePath("/api/search"),
108100
delayMs: 500,
109101
});
110102

111-
112-
113103
return (
114-
<SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
104+
<SearchDialog
105+
search={search}
106+
onSearchChange={setSearch}
107+
isLoading={query.isLoading}
108+
{...props}
109+
>
115110
<SearchDialogOverlay />
116111
<SearchDialogContent>
117112
<SearchDialogHeader>
@@ -123,8 +118,7 @@ export default function CustomSearchDialog(props: SharedProps) {
123118
items={query.data !== "empty" ? query.data : null}
124119
Item={SearchResultItem}
125120
/>
126-
<SearchDialogFooter className="border-t border-fd-border p-2">
127-
</SearchDialogFooter>
121+
<SearchDialogFooter className="border-t border-fd-border p-2"></SearchDialogFooter>
128122
</SearchDialogContent>
129123
</SearchDialog>
130124
);

apps/site/.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Mixedbread API Configuration
2+
# Required for the search functionality to work
3+
# Get your API key from: https://www.mixedbread.ai/
4+
MIXEDBREAD_API_KEY=your_mixedbread_api_key_here
5+
6+
# Base URL for the site (optional, defaults based on environment)
7+
# NEXT_PUBLIC_PRISMA_URL=https://www.prisma.io
8+
9+
# Vercel URL (automatically set in Vercel deployments)
10+
# VERCEL_URL=
11+
12+
# Allowed development origins (optional, defaults to localhost,127.0.0.1,192.168.1.48)
13+
# ALLOWED_DEV_ORIGINS=localhost,127.0.0.1

apps/site/next.config.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const ContentSecurityPolicy = `
5353
http://localhost:3002 http://127.0.0.1:3002
5454
https://www.prisma.io https://prisma.io
5555
https://cdn.sanity.io
56+
https://prisma.io
5657
https://prismalens.vercel.app
5758
https://api.producthunt.com
5859
https://www.google.com
@@ -74,7 +75,8 @@ const ContentSecurityPolicy = `
7475
https://vercel.live https://vercel.com data: blob:
7576
https://td.doubleclick.net
7677
https://raw.githubusercontent.com
77-
https://*.meetupstatic.com;
78+
https://*.meetupstatic.com
79+
https://www.prisma.io;
7880
7981
connect-src 'self'
8082
https://api.github.com

apps/site/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@
1313
},
1414
"dependencies": {
1515
"@base-ui/react": "catalog:",
16+
"@fumadocs/base-ui": "catalog:",
17+
"@mixedbread/sdk": "catalog:",
1618
"@prisma-docs/ui": "workspace:*",
1719
"@prisma/eclipse": "workspace:^",
1820
"@react-three/fiber": "^9.5.0",
1921
"cors": "^2.8.6",
2022
"html-react-parser": "^5.2.17",
23+
"fumadocs-core": "catalog:",
24+
"fumadocs-ui": "catalog:",
2125
"lucide-react": "catalog:",
2226
"next": "catalog:",
2327
"npm-to-yarn": "catalog:",

apps/site/public/og/og-support.png

342 KB
Loading
Lines changed: 157 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,158 @@
1-
export async function GET() {
2-
return Response.json(
3-
{
4-
error: "Search is not configured in the site host zone.",
5-
},
6-
{ status: 404 },
7-
);
1+
import { SiteSearchResult } from "@/components/support/search-types";
2+
import Mixedbread from "@mixedbread/sdk";
3+
import { NextRequest, NextResponse } from "next/server";
4+
5+
type GeneratedMetadata = {
6+
title?: string;
7+
slug?: string;
8+
metaTitle?: string;
9+
metaDescription?: string;
10+
metaImagePath?: string;
11+
heroImagePath?: string;
12+
tags?: string[];
13+
excerpt?: string;
14+
url?: string;
15+
};
16+
17+
export const dynamic = "force-dynamic";
18+
19+
const mixedbreadApiKey = process.env.MIXEDBREAD_API_KEY;
20+
const storeIdentifiers = ["blog-search", "web-search"] as const;
21+
const client = mixedbreadApiKey
22+
? new Mixedbread({ apiKey: mixedbreadApiKey })
23+
: null;
24+
const websiteBaseUrl = "https://prisma.io";
25+
const blogPrefix = "/blog";
26+
const docsPrefix = "/docs";
27+
28+
type MixedbreadSearchChunk = {
29+
id?: string;
30+
file_id?: string;
31+
chunk_index?: number;
32+
text?: string;
33+
generated_metadata?: GeneratedMetadata;
34+
};
35+
36+
function withPrefixedPath(
37+
path: string | undefined,
38+
prefix: string,
39+
): string {
40+
const normalizedPath = (path ?? "")
41+
.replace(/^\/+/, "")
42+
.replace(new RegExp(`^${prefix.replace("/", "")}/`), "");
43+
44+
return normalizedPath ? `${prefix}/${normalizedPath}` : "#";
45+
}
46+
47+
function withBaseAndPrefixedPath(
48+
path: string | undefined,
49+
prefix: string,
50+
): string {
51+
const prefixedPath = withPrefixedPath(path, prefix);
52+
return prefixedPath === "#"
53+
? prefixedPath
54+
: new URL(prefixedPath, websiteBaseUrl).toString();
55+
}
56+
57+
function normalizeBlogUrl(slug?: string): string {
58+
return withBaseAndPrefixedPath(slug, blogPrefix);
59+
}
60+
61+
function normalizeBlogImagePath(imagePath?: string): string {
62+
if (!imagePath) return "";
63+
if (/^https?:\/\//.test(imagePath)) return imagePath;
64+
const blogPath = withBaseAndPrefixedPath(imagePath, blogPrefix);
65+
return blogPath === "#" ? "" : blogPath;
66+
}
67+
68+
function normalizeDocsUrl(url?: string): string {
69+
return withBaseAndPrefixedPath(url, docsPrefix);
70+
}
71+
72+
function getDocsImagePath(normalizedDocsUrl: string): string {
73+
if (!normalizedDocsUrl || normalizedDocsUrl === "#") return "";
74+
const docsUrl = new URL(normalizedDocsUrl, websiteBaseUrl);
75+
const docsPath = docsUrl.pathname.replace(/^\/docs\//, "").replace(/^\/+/, "");
76+
return `https://www.prisma.io/docs/og/${docsPath}/image.png`;
77+
}
78+
79+
function transformResult(item: MixedbreadSearchChunk): SiteSearchResult | null {
80+
const metadata = item.generated_metadata;
81+
if (!metadata) return null;
82+
83+
const source: SiteSearchResult["source"] | null = metadata.slug
84+
? "blog"
85+
: metadata.url
86+
? "docs"
87+
: null;
88+
if (!source) return null;
89+
90+
const base =
91+
item.id ?? `${item.file_id ?? "unknown"}-${item.chunk_index ?? "0"}`;
92+
93+
const normalizedUrl =
94+
source === "blog"
95+
? normalizeBlogUrl(metadata.slug)
96+
: normalizeDocsUrl(metadata.url);
97+
98+
return {
99+
id: `${base}-page`,
100+
type: "page",
101+
source,
102+
content:
103+
source === "blog"
104+
? metadata.metaTitle ?? metadata.title ?? item.text ?? "Untitled"
105+
: metadata.title ?? item.text ?? "Untitled",
106+
url: normalizedUrl,
107+
description:
108+
source === "blog"
109+
? metadata.metaDescription ?? metadata.excerpt ?? item.text ?? ""
110+
: metadata.metaDescription ?? item.text ?? "",
111+
heroImagePath:
112+
source === "blog"
113+
? normalizeBlogImagePath(
114+
metadata.heroImagePath ?? metadata.metaImagePath,
115+
)
116+
: getDocsImagePath(normalizedUrl),
117+
tags: source === "blog" ? metadata.tags ?? [] : [],
118+
};
119+
}
120+
121+
function transformResults(results: MixedbreadSearchChunk[]): SiteSearchResult[] {
122+
return results
123+
.map(transformResult)
124+
.filter((item): item is SiteSearchResult => item !== null);
125+
}
126+
127+
export async function GET(request: NextRequest) {
128+
if (!client) {
129+
console.error("Search API called but Mixedbread is not configured");
130+
console.error("Please set MIXEDBREAD_API_KEY environment variable");
131+
return NextResponse.json([]);
132+
}
133+
134+
const query = request.nextUrl.searchParams.get("query")?.trim() ?? "";
135+
136+
if (!query) {
137+
return NextResponse.json([]);
138+
}
139+
140+
try {
141+
const response = await client.stores.search({
142+
query,
143+
store_identifiers: [...storeIdentifiers],
144+
top_k: 20,
145+
search_options: {
146+
rerank: true,
147+
return_metadata: true,
148+
},
149+
});
150+
151+
return NextResponse.json(
152+
transformResults(response.data as MixedbreadSearchChunk[]),
153+
);
154+
} catch (error) {
155+
console.error("Mixedbread search failed:", error);
156+
return NextResponse.json([]);
157+
}
8158
}

0 commit comments

Comments
 (0)