diff --git a/apps/blog/public/img/logo-dark.svg b/apps/blog/public/img/logo-dark.svg
new file mode 100644
index 0000000000..8db99f452c
--- /dev/null
+++ b/apps/blog/public/img/logo-dark.svg
@@ -0,0 +1,22 @@
+
diff --git a/apps/blog/public/img/logo-white.svg b/apps/blog/public/img/logo-white.svg
new file mode 100644
index 0000000000..5ce03bcc02
--- /dev/null
+++ b/apps/blog/public/img/logo-white.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/apps/blog/public/img/logo.svg b/apps/blog/public/img/logo.svg
new file mode 100644
index 0000000000..9f3600bdff
--- /dev/null
+++ b/apps/blog/public/img/logo.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/blog/source.config.ts b/apps/blog/source.config.ts
index 40e49f29eb..d36921e97e 100644
--- a/apps/blog/source.config.ts
+++ b/apps/blog/source.config.ts
@@ -13,6 +13,15 @@ import lastModified from 'fumadocs-mdx/plugins/last-modified';
import { z } from 'zod';
import convert from 'npm-to-yarn';
+const blogPostTypes = [
+ 'release',
+ 'user-story',
+ 'tutorial',
+ 'community',
+ 'postgres',
+ 'changelog',
+] as const;
+
export const blogPosts = defineCollections({
type: 'doc',
dir: 'content/blog',
@@ -21,6 +30,9 @@ export const blogPosts = defineCollections({
date: z.coerce.date(),
heroImagePath: z.string().optional(),
metaImagePath: z.string().optional(),
+ type: z.enum(blogPostTypes).optional(),
+ tags: z.array(z.string()).optional(),
+ featured: z.boolean().optional(),
}),
postprocess: {
includeProcessedMarkdown: true,
diff --git a/apps/blog/src/app/(blog)/[slug]/page.tsx b/apps/blog/src/app/(blog)/[slug]/page.tsx
index 66c459ae90..6cb051ab01 100644
--- a/apps/blog/src/app/(blog)/[slug]/page.tsx
+++ b/apps/blog/src/app/(blog)/[slug]/page.tsx
@@ -1,10 +1,10 @@
import { notFound } from 'next/navigation';
-import Link from 'next/link';
-import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
-import { getMDXComponents } from '@/mdx-components';
import { createRelativeLink } from 'fumadocs-ui/mdx';
+import { BlogPostHeader } from '@/components/blog/blog-post-header';
+import { BlogPostTOC } from '@/components/blog/blog-post-toc';
+import { normalizePostMeta } from '@/lib/blog-data';
import { blog } from '@/lib/source';
-import Image from 'next/image';
+import { getMDXComponents } from '@/mdx-components';
export default async function Page(props: {
params: Promise<{ slug: string }>;
@@ -13,107 +13,40 @@ export default async function Page(props: {
const page = blog.getPage([params.slug]);
if (!page) notFound();
+
+ const post = normalizePostMeta(page);
const MDX = page.data.body;
- const formatDate = (value: unknown) => {
- const date =
- value instanceof Date ? value : new Date((value as string) ?? '');
- if (Number.isNaN(date.getTime())) return '';
- return date.toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- });
- };
- const getHeroImageSrc = () => {
- const data = page.data as any;
- const rel =
- (data.heroImagePath as string | undefined) ??
- (data.metaImagePath as string | undefined);
- if (rel) {
- if (rel.startsWith('/')) return rel;
- const base = page.url.startsWith('/') ? page.url : `/${page.url}`;
- const baseClean = base.endsWith('/') ? base.slice(0, -1) : base;
- const relClean = rel.replace(/^\.\//, '').replace(/^\/+/, '');
- return `${baseClean}/${relClean}`;
- }
- const absolute =
- (data.heroImageUrl as string | undefined) ??
- (data.metaImageUrl as string | undefined);
- return absolute ?? null;
- };
- const heroSrc = getHeroImageSrc();
return (
- <>
- {/* Hero image */}
- {heroSrc ? (
-
-
-
+
- ) : null}
-
- {/* Title + meta */}
-
+
- {/* Body */}
-
-
-
-
+
-
-
- {/* Newsletter CTA */}
-
-
-
- Don’t miss the next post!
-
-
- Sign up for the Prisma Newsletter to stay up to date with the latest
- releases and posts.
-
-
- Sign up
-
-
- >
+
);
}
diff --git a/apps/blog/src/app/(blog)/layout.tsx b/apps/blog/src/app/(blog)/layout.tsx
index ec90473df7..3e3e8dc93b 100644
--- a/apps/blog/src/app/(blog)/layout.tsx
+++ b/apps/blog/src/app/(blog)/layout.tsx
@@ -1,23 +1,5 @@
-import { HomeLayout } from 'fumadocs-ui/layouts/home';
-import { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
-export function baseOptions(): BaseLayoutProps {
- return {
- nav: {
- title: 'My App',
- },
- links: [
- {
- url: '/docs',
- text: 'Docs',
- },
- {
- url: '/blog',
- text: 'Blog',
- },
- ],
- };
-}
+import { BlogShell } from '@/components/blog/blog-shell';
-export default function Layout({ children, }: { children: React.ReactNode; }) {
- return {children};
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return {children};
}
diff --git a/apps/blog/src/app/(blog)/page.tsx b/apps/blog/src/app/(blog)/page.tsx
index 6c24bbf845..2950f80273 100644
--- a/apps/blog/src/app/(blog)/page.tsx
+++ b/apps/blog/src/app/(blog)/page.tsx
@@ -1,97 +1,27 @@
-import { Suspense } from 'react';
-import { blog } from '@/lib/source';
-import { BlogGrid } from '@/components/BlogGrid';
+import { Suspense } from "react";
+import { BlogGrid } from "@/components/BlogGrid";
+import { getBlogPosts } from "@/lib/blog-data";
export default function BlogHome() {
- const posts = blog.getPages().sort((a, b) => {
- const aTime =
- a.data.date instanceof Date
- ? a.data.date.getTime()
- : new Date((a.data.date as unknown as string) ?? '').getTime();
- const bTime =
- b.data.date instanceof Date
- ? b.data.date.getTime()
- : new Date((b.data.date as unknown as string) ?? '').getTime();
- return bTime - aTime;
- });
+ const posts = getBlogPosts();
- const formatDate = (value: unknown) => {
- const date =
- value instanceof Date ? value : new Date((value as string) ?? '');
- if (Number.isNaN(date.getTime())) return '';
- return date.toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- });
- };
- const getPrimaryAuthor = (post: (typeof posts)[number]) => {
- const data = post.data as any;
- const authors = Array.isArray(data.authors) ? data.authors : [];
- return authors.length > 0 ? authors[0] : null;
- };
- const getCardImageSrc = (post: (typeof posts)[number]) => {
- const data = post.data as any;
- const rel =
- (data.heroImagePath as string | undefined) ??
- (data.metaImagePath as string | undefined);
- if (rel) {
- // If frontmatter already provides an absolute path, use it directly
- if (rel.startsWith('/')) {
- return rel;
- }
- const base = post.url.startsWith('/') ? post.url : `/${post.url}`;
- const baseClean = base.endsWith('/') ? base.slice(0, -1) : base;
- const relClean = rel.replace(/^\.\//, '').replace(/^\/+/, '');
- return `${baseClean}/${relClean}`;
- }
- const absolute =
- (data.heroImageUrl as string | undefined) ??
- (data.metaImageUrl as string | undefined);
- return absolute ?? null;
- };
- const items = posts.map((post) => {
- const data = post.data as any;
- return {
- url: post.url,
- title: data.title as string,
- date: data.date ? new Date(data.date).toISOString() : '',
- description: (data.description as string) ?? '',
- author: getPrimaryAuthor(post),
- imageSrc: getCardImageSrc(post),
- imageAlt: (data.heroImageAlt as string) ?? (data.title as string),
- seriesTitle: data.series?.title ?? null,
- };
- });
return (
-
- Prisma Blog
-
- Guides, announcements and articles about Prisma, databases and the data
- access layer.
-
-
- {/* Category pills (static "Show all" to match layout) */}
-
-
- Show all
-
-
-
- {/* Grid with pagination */}
+
- {items.slice(0, 12).map((post) => (
-
- ))}
+
+
+
+ Blog
+
+
+ Loading posts...
+
+
}
>
-
+
);
diff --git a/apps/blog/src/app/global.css b/apps/blog/src/app/global.css
index b9f9e62842..bf06c9862c 100644
--- a/apps/blog/src/app/global.css
+++ b/apps/blog/src/app/global.css
@@ -1,7 +1,16 @@
-@import "tailwindcss";
+@import 'tailwindcss';
@import '@prisma-docs/eclipse/styles/globals.css';
@import 'fumadocs-ui/css/shadcn.css';
@import 'fumadocs-ui/css/preset.css';
@import 'fumadocs-openapi/css/preset.css';
@import '@prisma-docs/ui/styles';
+@layer base {
+ html {
+ @apply scroll-smooth;
+ }
+
+ body {
+ @apply min-h-screen bg-background text-foreground antialiased;
+ }
+}
diff --git a/apps/blog/src/app/layout.tsx b/apps/blog/src/app/layout.tsx
index 3f0c7dbe77..d68b632d6a 100644
--- a/apps/blog/src/app/layout.tsx
+++ b/apps/blog/src/app/layout.tsx
@@ -17,11 +17,11 @@ export default function Layout({ children }: LayoutProps<'/'>) {
return (
-
+
{children}
diff --git a/apps/blog/src/components/BlogGrid.tsx b/apps/blog/src/components/BlogGrid.tsx
index 5c74969b9a..9a8e7c5783 100644
--- a/apps/blog/src/components/BlogGrid.tsx
+++ b/apps/blog/src/components/BlogGrid.tsx
@@ -1,150 +1,253 @@
'use client';
-import { useMemo } from 'react';
-import Image from 'next/image';
-import Link from 'next/link';
+import { useEffect, useMemo } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
-import { Button } from '@prisma-docs/eclipse';
-
-type BlogCardItem = {
- url: string;
- title: string;
- date: string; // ISO string
- description?: string | null;
- author?: string | null;
- imageSrc?: string | null;
- imageAlt?: string | null;
- seriesTitle?: string | null;
-};
+import { Grid2x2, List } from 'lucide-react';
+import { BlogCompactItem } from '@/components/blog/blog-compact-item';
+import { BlogFeaturedCard } from '@/components/blog/blog-featured-card';
+import { BlogFilters } from '@/components/blog/blog-filters';
+import { BlogHero } from '@/components/blog/blog-hero';
+import { BlogListItem } from '@/components/blog/blog-list-item';
+import { BlogSearch } from '@/components/blog/blog-search';
+import {
+ type BlogPostFilterType,
+ type BlogPostItem,
+ filterPosts,
+ getFeaturedPost,
+ isBlogPostType,
+ paginatePosts,
+} from '@/lib/blog-data';
function parsePage(value: string | null): number {
- const n = parseInt(value ?? '1', 10);
- return Number.isNaN(n) || n < 1 ? 1 : n;
+ const parsed = parseInt(value ?? '1', 10);
+ return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed;
+}
+
+function parseType(value: string | null): BlogPostFilterType {
+ if (!value || value === 'all') return 'all';
+ return isBlogPostType(value) ? value : 'all';
+}
+
+function parseView(value: string | null): 'cards' | 'compact' {
+ return value === 'compact' ? 'compact' : 'cards';
}
export function BlogGrid({
items,
pageSize = 12,
}: {
- items: BlogCardItem[];
+ items: BlogPostItem[];
pageSize?: number;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
- const rawPage = useMemo(
- () => parsePage(searchParams.get('page')),
- [searchParams],
+
+ const query = searchParams.get('q') ?? '';
+ const selectedType = parseType(searchParams.get('type'));
+ const view = parseView(searchParams.get('view'));
+ const rawPage = parsePage(searchParams.get('page'));
+ const nextView = view === 'cards' ? 'compact' : 'cards';
+ const ToggleIcon = nextView === 'compact' ? List : Grid2x2;
+ const toggleLabel = nextView === 'compact' ? 'List view' : 'Cards view';
+
+ const filteredByType = useMemo(
+ () => filterPosts(items, { query, type: selectedType }),
+ [items, query, selectedType],
+ );
+ const filteredByQuery = useMemo(
+ () => filterPosts(items, { query, type: 'all' }),
+ [items, query],
);
- const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
- const currentPage = Math.min(rawPage, totalPages);
-
- const visibleItems = useMemo(
- () =>
- items.slice(
- (currentPage - 1) * pageSize,
- currentPage * pageSize,
- ),
- [items, currentPage, pageSize],
+
+ const featured = useMemo(() => {
+ if (rawPage !== 1) return null;
+ return getFeaturedPost(filteredByType);
+ }, [filteredByType, rawPage]);
+
+ const feedSource = useMemo(() => {
+ if (!featured) return filteredByType;
+ return filteredByType.filter((post) => post.id !== featured.id);
+ }, [featured, filteredByType]);
+
+ const pagination = useMemo(
+ () => paginatePosts(feedSource, rawPage, pageSize),
+ [feedSource, rawPage, pageSize],
);
- const setPage = (newPage: number) => {
+ useEffect(() => {
+ if (pagination.currentPage === rawPage) return;
+
const params = new URLSearchParams(searchParams.toString());
- if (newPage <= 1) {
- params.delete('page');
- } else {
- params.set('page', String(newPage));
- }
- const query = params.toString();
- router.replace(query ? `${pathname}?${query}` : pathname);
- window.scrollTo({ top: 0, behavior: 'smooth' });
- };
+ if (pagination.currentPage <= 1) params.delete('page');
+ else params.set('page', String(pagination.currentPage));
+
+ const queryString = params.toString();
+ router.replace(queryString ? `${pathname}?${queryString}` : pathname);
+ }, [pagination.currentPage, pathname, rawPage, router, searchParams]);
+
+ const counts = useMemo(() => {
+ const nextCounts: Record = {
+ all: filteredByQuery.length,
+ release: 0,
+ 'user-story': 0,
+ community: 0,
+ tutorial: 0,
+ postgres: 0,
+ changelog: 0,
+ };
- const formatDate = (iso: string) => {
- if (!iso) return '';
- const date = new Date(iso);
- if (Number.isNaN(date.getTime())) return '';
- return date.toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
+ filteredByQuery.forEach((post) => {
+ nextCounts[post.type] += 1;
});
+
+ return nextCounts;
+ }, [filteredByQuery]);
+
+ const replaceParams = (
+ updates: Partial<{
+ q: string;
+ type: BlogPostFilterType;
+ view: 'cards' | 'compact';
+ page: number;
+ }>,
+ options?: { resetPage?: boolean; scrollToTop?: boolean },
+ ) => {
+ const params = new URLSearchParams(
+ typeof window !== 'undefined' ? window.location.search : searchParams.toString(),
+ );
+ const resetPage = options?.resetPage ?? false;
+ const scrollToTop = options?.scrollToTop ?? false;
+
+ if (updates.q !== undefined) {
+ const next = updates.q.trim();
+ if (next) params.set('q', next);
+ else params.delete('q');
+ }
+
+ if (updates.type !== undefined) {
+ if (updates.type === 'all') params.delete('type');
+ else params.set('type', updates.type);
+ }
+
+ if (updates.view !== undefined) {
+ if (updates.view === 'cards') params.delete('view');
+ else params.set('view', updates.view);
+ }
+
+ if (updates.page !== undefined) {
+ if (updates.page <= 1) params.delete('page');
+ else params.set('page', String(updates.page));
+ }
+
+ if (resetPage) params.delete('page');
+
+ const queryString = params.toString();
+ router.replace(queryString ? `${pathname}?${queryString}` : pathname);
+
+ if (scrollToTop) {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }
};
+ const hasResults = filteredByType.length > 0;
+
return (
- <>
-
- {visibleItems.map((post) => (
-
+
+
+
+
replaceParams({ type }, { resetPage: true })}
+ selectedType={selectedType}
+ />
+
+
+ replaceParams(
+ {
+ q: nextQuery,
+ },
+ { resetPage: true },
+ )
+ }
+ query={query}
+ />
+
+
- {totalPages > 1 ? (
+ {featured ?
: null}
+
+ {hasResults ? (
+
+ {view === 'cards'
+ ? pagination.items.map((post) => )
+ : pagination.items.map((post) => )}
+
+ ) : (
+
+
No posts matched this filter.
+
+
+ )}
+
+ {pagination.totalPages > 1 ? (
) : null}
- >
+
);
}
-
diff --git a/apps/blog/src/components/CompaniesUsingPrisma.tsx b/apps/blog/src/components/CompaniesUsingPrisma.tsx
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/apps/blog/src/components/blog/blog-compact-item.tsx b/apps/blog/src/components/blog/blog-compact-item.tsx
new file mode 100644
index 0000000000..4fdcaa3c81
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-compact-item.tsx
@@ -0,0 +1,38 @@
+import Link from 'next/link';
+import type { BlogPostItem } from '@/lib/blog-data';
+import { BLOG_TYPE_LABELS } from '@/lib/blog-data';
+
+export function BlogCompactItem({ post }: { post: BlogPostItem }) {
+ return (
+
+
+
+ {BLOG_TYPE_LABELS[post.type]}
+
+ {post.dateLabel}
+
+
+ {post.title}
+
+
+
+ {getInitials(post.author)}
+
+ {post.author}
+
+
+ );
+}
+
+function getInitials(author: string): string {
+ const values = author
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() ?? '');
+
+ return values.join('') || 'PT';
+}
diff --git a/apps/blog/src/components/blog/blog-featured-card.tsx b/apps/blog/src/components/blog/blog-featured-card.tsx
new file mode 100644
index 0000000000..fddd06f044
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-featured-card.tsx
@@ -0,0 +1,64 @@
+import Image from 'next/image';
+import Link from 'next/link';
+import type { BlogPostItem } from '@/lib/blog-data';
+import { BLOG_TYPE_LABELS } from '@/lib/blog-data';
+
+export function BlogFeaturedCard({ post }: { post: BlogPostItem }) {
+ return (
+
+
+ {post.imageSrc ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {BLOG_TYPE_LABELS[post.type]}
+
+ {post.dateLabel}
+
+
+
+ {post.title}
+
+
+ {post.summary ?
{post.summary}
: null}
+
+
+
+ {getInitials(post.author)}
+
+ {post.author}
+
+
+
+ );
+}
+
+function getInitials(author: string): string {
+ const values = author
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() ?? '');
+
+ return values.join('') || 'PT';
+}
diff --git a/apps/blog/src/components/blog/blog-filters.tsx b/apps/blog/src/components/blog/blog-filters.tsx
new file mode 100644
index 0000000000..84f8b393d1
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-filters.tsx
@@ -0,0 +1,43 @@
+import {
+ BLOG_TYPE_LABELS,
+ BLOG_TYPE_ORDER,
+ type BlogPostFilterType,
+} from '@/lib/blog-data';
+
+type Counts = Record;
+
+export function BlogFilters({
+ selectedType,
+ onTypeChange,
+ counts,
+}: {
+ selectedType: BlogPostFilterType;
+ onTypeChange: (type: BlogPostFilterType) => void;
+ counts: Counts;
+}) {
+ const options: BlogPostFilterType[] = ['all', ...BLOG_TYPE_ORDER];
+
+ return (
+
+
+ {options.map((type) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blog/src/components/blog/blog-footer.tsx b/apps/blog/src/components/blog/blog-footer.tsx
new file mode 100644
index 0000000000..06bd8af12a
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-footer.tsx
@@ -0,0 +1,5 @@
+import { PrismaSiteFooter } from "@prisma-docs/ui/components/prisma-site-footer";
+
+export function BlogFooter() {
+ return ;
+}
diff --git a/apps/blog/src/components/blog/blog-hero.tsx b/apps/blog/src/components/blog/blog-hero.tsx
new file mode 100644
index 0000000000..ce48a0dea5
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-hero.tsx
@@ -0,0 +1,9 @@
+export function BlogHero() {
+ return (
+
+ );
+}
diff --git a/apps/blog/src/components/blog/blog-list-item.tsx b/apps/blog/src/components/blog/blog-list-item.tsx
new file mode 100644
index 0000000000..29ccd3a26b
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-list-item.tsx
@@ -0,0 +1,61 @@
+import Image from 'next/image';
+import Link from 'next/link';
+import type { BlogPostItem } from '@/lib/blog-data';
+import { BLOG_TYPE_LABELS } from '@/lib/blog-data';
+
+export function BlogListItem({ post }: { post: BlogPostItem }) {
+ return (
+
+
+
+
+ {BLOG_TYPE_LABELS[post.type]}
+
+ {post.dateLabel}
+
+
+
+ {post.title}
+
+
+
+
+ {getInitials(post.author)}
+
+ {post.author}
+
+
+
+
+ {post.imageSrc ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function getInitials(author: string): string {
+ const values = author
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() ?? '');
+
+ return values.join('') || 'PT';
+}
diff --git a/apps/blog/src/components/blog/blog-nav.tsx b/apps/blog/src/components/blog/blog-nav.tsx
new file mode 100644
index 0000000000..6abd9f3049
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-nav.tsx
@@ -0,0 +1,5 @@
+import { PrismaSiteNav } from "@prisma-docs/ui/components/prisma-site-nav";
+
+export function BlogNav() {
+ return ;
+}
diff --git a/apps/blog/src/components/blog/blog-post-header.tsx b/apps/blog/src/components/blog/blog-post-header.tsx
new file mode 100644
index 0000000000..45bdec3d43
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-post-header.tsx
@@ -0,0 +1,65 @@
+import { BLOG_TYPE_LABELS, type BlogPostType } from '@/lib/blog-data';
+
+export function BlogPostHeader({
+ title,
+ description,
+ author,
+ dateLabel,
+ type,
+ tags,
+}: {
+ title: string;
+ description?: string;
+ author: string;
+ dateLabel: string;
+ type: BlogPostType;
+ tags: string[];
+}) {
+ const displayTags = [BLOG_TYPE_LABELS[type], ...tags.filter((tag) => tag !== type)].slice(0, 6);
+
+ return (
+
+
+ {title}
+
+
+
+
+ {getInitials(author)}
+
+ {author}
+ {dateLabel ? • : null}
+ {dateLabel ? {dateLabel} : null}
+
+
+ {description ? {description}
: null}
+
+
+ {displayTags.map((tag) => (
+
+ {formatTag(tag)}
+
+ ))}
+
+
+ );
+}
+
+function getInitials(author: string): string {
+ const values = author
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() ?? '');
+
+ return values.join('') || 'PT';
+}
+
+function formatTag(tag: string): string {
+ return tag
+ .replace(/-/g, ' ')
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
+}
diff --git a/apps/blog/src/components/blog/blog-post-toc.tsx b/apps/blog/src/components/blog/blog-post-toc.tsx
new file mode 100644
index 0000000000..74a80c11ca
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-post-toc.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+
+type TocItem = {
+ title?: string;
+ url?: string;
+ depth?: number;
+ items?: TocItem[];
+};
+
+export function BlogPostTOC({
+ items,
+ className,
+}: {
+ items: TocItem[];
+ className?: string;
+}) {
+ const flatItems = useMemo(() => flattenItems(items), [items]);
+ const [active, setActive] = useState('');
+
+ useEffect(() => {
+ if (flatItems.length === 0) return;
+
+ const applyHash = () => {
+ const hash = window.location.hash || '';
+ if (hash) setActive(hash);
+ };
+
+ applyHash();
+
+ const targets = flatItems
+ .map((item) => document.getElementById(item.url.replace(/^#/, '')))
+ .filter((node): node is HTMLElement => node instanceof HTMLElement);
+
+ if (targets.length === 0) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const visible = entries
+ .filter((entry) => entry.isIntersecting)
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
+
+ if (visible.length === 0) return;
+
+ const first = visible[0];
+ if (!first.target.id) return;
+ setActive(`#${first.target.id}`);
+ },
+ {
+ rootMargin: '-22% 0px -62% 0px',
+ threshold: [0, 1],
+ },
+ );
+
+ targets.forEach((target) => observer.observe(target));
+ window.addEventListener('hashchange', applyHash);
+
+ return () => {
+ observer.disconnect();
+ window.removeEventListener('hashchange', applyHash);
+ };
+ }, [flatItems]);
+
+ if (flatItems.length === 0) return null;
+
+ return (
+
+ );
+}
+
+function flattenItems(items: TocItem[]): Array<{ title: string; url: string; depth: number }> {
+ const result: Array<{ title: string; url: string; depth: number }> = [];
+
+ const walk = (entry: TocItem[]) => {
+ entry.forEach((item) => {
+ if (item.title && item.url) {
+ result.push({
+ title: item.title,
+ url: item.url,
+ depth: item.depth ?? 2,
+ });
+ }
+
+ if (Array.isArray(item.items) && item.items.length > 0) {
+ walk(item.items);
+ }
+ });
+ };
+
+ walk(items);
+ return result;
+}
diff --git a/apps/blog/src/components/blog/blog-search.tsx b/apps/blog/src/components/blog/blog-search.tsx
new file mode 100644
index 0000000000..80f8dc7000
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-search.tsx
@@ -0,0 +1,27 @@
+import { Search } from 'lucide-react';
+
+export function BlogSearch({
+ query,
+ onQueryChange,
+}: {
+ query: string;
+ onQueryChange: (value: string) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/blog/src/components/blog/blog-shell.tsx b/apps/blog/src/components/blog/blog-shell.tsx
new file mode 100644
index 0000000000..7862b164ac
--- /dev/null
+++ b/apps/blog/src/components/blog/blog-shell.tsx
@@ -0,0 +1,18 @@
+import { BlogFooter } from '@/components/blog/blog-footer';
+import { BlogNav } from '@/components/blog/blog-nav';
+
+export function BlogShell({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/apps/blog/src/lib/blog-data.ts b/apps/blog/src/lib/blog-data.ts
new file mode 100644
index 0000000000..d19681a52f
--- /dev/null
+++ b/apps/blog/src/lib/blog-data.ts
@@ -0,0 +1,250 @@
+import { blog as blogSource } from '@/lib/source';
+
+export const BLOG_TYPE_ORDER = [
+ 'release',
+ 'user-story',
+ 'community',
+ 'tutorial',
+ 'postgres',
+ 'changelog',
+] as const;
+
+export type BlogPostType = (typeof BLOG_TYPE_ORDER)[number];
+
+export type BlogPostFilterType = BlogPostType | 'all';
+
+export const BLOG_TYPE_LABELS: Record = {
+ all: 'All',
+ 'user-story': 'User Story',
+ release: 'Release',
+ community: 'Community',
+ tutorial: 'Tutorial',
+ postgres: 'Postgres',
+ changelog: 'Changelog',
+};
+
+type SourcePage = ReturnType[number];
+
+export type BlogPostItem = {
+ id: string;
+ slug: string;
+ url: string;
+ title: string;
+ dateISO: string;
+ dateLabel: string;
+ dateValue: number;
+ description: string;
+ summary: string;
+ author: string;
+ authors: string[];
+ imageSrc: string | null;
+ imageAlt: string;
+ type: BlogPostType;
+ tags: string[];
+ featured: boolean;
+};
+
+export function isBlogPostType(value: unknown): value is BlogPostType {
+ return typeof value === 'string' && BLOG_TYPE_ORDER.includes(value as BlogPostType);
+}
+
+export function inferPostType(slug: string, title: string): BlogPostType {
+ const text = `${slug} ${title}`.toLowerCase();
+
+ if (
+ text.includes('changelog') ||
+ text.includes("what's new") ||
+ text.includes('whats-new') ||
+ text.includes('wnip')
+ ) {
+ return 'changelog';
+ }
+
+ if (
+ text.includes('customer') ||
+ text.includes('customer-story') ||
+ text.includes('success-story') ||
+ text.includes('case-study') ||
+ text.includes('case study')
+ ) {
+ return 'user-story';
+ }
+
+ if (text.includes('postgres')) {
+ return 'postgres';
+ }
+
+ if (
+ text.includes('tutorial') ||
+ text.includes('guide') ||
+ text.includes('how to') ||
+ text.includes('fullstack') ||
+ text.includes('build ')
+ ) {
+ return 'tutorial';
+ }
+
+ if (
+ text.includes('release') ||
+ text.includes('announcing') ||
+ /orm-\d/.test(text) ||
+ /prisma-\d/.test(text)
+ ) {
+ return 'release';
+ }
+
+ return 'community';
+}
+
+function toDate(value: unknown): Date | null {
+ const parsed = value instanceof Date ? value : new Date((value as string) ?? '');
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+function formatDate(date: Date | null): string {
+ if (!date) return '';
+
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+}
+
+function getImageSrc(page: SourcePage): string | null {
+ const data = page.data as unknown as Record;
+ const relRaw =
+ (data.heroImagePath as string | undefined) ?? (data.metaImagePath as string | undefined);
+
+ if (relRaw) {
+ if (relRaw.startsWith('/')) return relRaw;
+
+ const base = page.url.startsWith('/') ? page.url : `/${page.url}`;
+ const baseClean = base.endsWith('/') ? base.slice(0, -1) : base;
+ const relClean = relRaw.replace(/^\.\//, '').replace(/^\/+/, '');
+ return `${baseClean}/${relClean}`;
+ }
+
+ const absoluteRaw =
+ (data.heroImageUrl as string | undefined) ?? (data.metaImageUrl as string | undefined);
+ return absoluteRaw ?? null;
+}
+
+function sanitizeText(value: unknown): string {
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
+}
+
+function getSummary(data: Record): string {
+ const description = sanitizeText(data.description);
+ if (description) return description;
+
+ const metaDescription = sanitizeText(data.metaDescription);
+ if (metaDescription) return metaDescription;
+
+ const excerpt = sanitizeText(data.excerpt);
+ if (excerpt) return excerpt;
+
+ return '';
+}
+
+function unique(values: string[]): string[] {
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
+}
+
+export function normalizePostMeta(page: SourcePage): BlogPostItem {
+ const data = page.data as unknown as Record;
+ const authors = Array.isArray(data.authors) ? data.authors.map((value) => String(value)) : [];
+ const date = toDate(data.date);
+ const title = sanitizeText(data.title);
+ const slug = page.slugs.join('/');
+ const rawType = data.type;
+ const type = isBlogPostType(rawType) ? rawType : inferPostType(slug, title);
+ const explicitTags = Array.isArray(data.tags)
+ ? data.tags.map((value) => String(value).trim().toLowerCase())
+ : [];
+
+ return {
+ id: page.url,
+ slug,
+ url: page.url,
+ title,
+ dateISO: date ? date.toISOString() : '',
+ dateLabel: formatDate(date),
+ dateValue: date ? date.getTime() : 0,
+ description: getSummary(data),
+ summary: getSummary(data),
+ author: authors[0] ?? 'Prisma Team',
+ authors,
+ imageSrc: getImageSrc(page),
+ imageAlt: sanitizeText(data.heroImageAlt) || title,
+ type,
+ tags: unique([type, ...explicitTags]),
+ featured: Boolean(data.featured),
+ };
+}
+
+export function getBlogPosts(): BlogPostItem[] {
+ const pages = blogSource.getPages();
+
+ return pages.map(normalizePostMeta).sort((a, b) => b.dateValue - a.dateValue);
+}
+
+export function filterPosts(
+ posts: BlogPostItem[],
+ options: {
+ query?: string;
+ type?: BlogPostFilterType;
+ },
+): BlogPostItem[] {
+ const query = options.query?.trim().toLowerCase() ?? '';
+ const filterType = options.type ?? 'all';
+
+ return posts.filter((post) => {
+ const matchesType = filterType === 'all' ? true : post.type === filterType;
+ if (!matchesType) return false;
+
+ if (!query) return true;
+
+ const haystack = [
+ post.title,
+ post.description,
+ post.summary,
+ post.author,
+ ...post.tags,
+ BLOG_TYPE_LABELS[post.type],
+ ]
+ .join(' ')
+ .toLowerCase();
+
+ return haystack.includes(query);
+ });
+}
+
+export function paginatePosts(
+ posts: BlogPostItem[],
+ page: number,
+ pageSize: number,
+): {
+ items: BlogPostItem[];
+ currentPage: number;
+ totalPages: number;
+ totalItems: number;
+} {
+ const totalItems = posts.length;
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
+ const currentPage = Math.min(Math.max(1, page), totalPages);
+ const start = (currentPage - 1) * pageSize;
+ const items = posts.slice(start, start + pageSize);
+
+ return {
+ items,
+ currentPage,
+ totalPages,
+ totalItems,
+ };
+}
+
+export function getFeaturedPost(posts: BlogPostItem[]): BlogPostItem | null {
+ if (posts.length === 0) return null;
+ return posts.find((post) => post.featured) ?? posts[0];
+}
diff --git a/apps/docs/src/app/(docs)/v6/layout.tsx b/apps/docs/src/app/(docs)/v6/layout.tsx
index fea68729f1..372264de5c 100644
--- a/apps/docs/src/app/(docs)/v6/layout.tsx
+++ b/apps/docs/src/app/(docs)/v6/layout.tsx
@@ -3,7 +3,7 @@ import { VersionSwitcher } from "@/components/version-switcher";
import type { LinkItemType } from "fumadocs-ui/layouts/shared";
import { DocsLayout } from "@/components/layout/notebook";
import { sourceV6 } from "@/lib/source";
-import { DiscordIcon } from "@/components/icons/discord";
+import { DiscordIcon } from "@prisma-docs/ui/components/discord";
export default async function Layout({
children,
@@ -82,4 +82,3 @@ export default async function Layout({
);
}
-
diff --git a/apps/docs/src/components/ai-chat-sidebar.tsx b/apps/docs/src/components/ai-chat-sidebar.tsx
index 737c3cb1fc..2b67d735e8 100644
--- a/apps/docs/src/components/ai-chat-sidebar.tsx
+++ b/apps/docs/src/components/ai-chat-sidebar.tsx
@@ -86,7 +86,7 @@ const SourcesDisplay = ({
href={source.source_url}
target="_blank"
rel="noopener noreferrer"
- className="flex items-center gap-1.5 px-2 py-1 text-xs rounded-md bg-fd-muted hover:bg-fd-accent transition-colors max-w-[200px] group"
+ className="flex items-center gap-1.5 px-2 py-1 text-xs rounded-md bg-fd-muted hover:bg-fd-accent transition-colors max-w-50 group"
>
{source.title || source.subtitle}
@@ -132,13 +132,13 @@ const FeedbackButtons = ({
"h-7 w-7",
currentReaction === "upvote" &&
"text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950",
- disabled && "opacity-50 cursor-not-allowed"
+ disabled && "opacity-50 cursor-not-allowed",
)}
>
@@ -159,13 +159,13 @@ const FeedbackButtons = ({
"h-7 w-7",
currentReaction === "downvote" &&
"text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950",
- disabled && "opacity-50 cursor-not-allowed"
+ disabled && "opacity-50 cursor-not-allowed",
)}
>
@@ -296,7 +296,7 @@ const ChatInner = ({
}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
- "disabled:opacity-50"
+ "disabled:opacity-50",
)}
>
@@ -312,7 +312,7 @@ const ChatInner = ({
{...props}
onClick={onClose}
className={cn(
- buttonVariants({ variant: "ghost", size: "icon-sm" })
+ buttonVariants({ variant: "ghost", size: "icon-sm" }),
)}
>
@@ -429,7 +429,9 @@ const ChatInner = ({
- Something went wrong
+
+ Something went wrong
+
{error}
@@ -523,14 +525,18 @@ export const AIChatSidebar = ({
onClick={() => setIsOpen(!isOpen)}
className={cn(
buttonVariants({ variant: "outline" }),
- "hidden shrink-0 shadow-none md:inline-flex items-center gap-2 h-8 group cursor-pointer"
+ "hidden shrink-0 shadow-none md:inline-flex items-center gap-2 h-8 group cursor-pointer",
)}
>
Ask AI
- {isMac ? "⌘" : "Ctrl"}
- I
+
+ {isMac ? "⌘" : "Ctrl"}
+
+
+ I
+
@@ -542,7 +548,7 @@ export const AIChatSidebar = ({
"inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-[500px]",
"translate-x-full data-[state=open]:translate-x-0",
"pointer-events-none data-[state=open]:pointer-events-auto",
- "hidden md:flex"
+ "hidden md:flex",
)}
data-state={isOpen ? "open" : "closed"}
>
@@ -551,7 +557,7 @@ export const AIChatSidebar = ({
onClose={() => setIsOpen(false)}
/>
,
- document.body
+ document.body,
)}
@@ -563,7 +569,7 @@ export const AIChatSidebar = ({