diff --git a/bun.lock b/bun.lock index e07d3eb..bd934e1 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,8 @@ "@fortawesome/react-fontawesome": "^3.3.1", "@gsap/react": "^2.1.2", "@hookform/resolvers": "^5.4.0", + "@mdx-js/mdx": "^3.1.1", + "@mdx-js/react": "^3.1.1", "@neondatabase/serverless": "^1.1.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -79,7 +81,6 @@ "react-day-picker": "^10.0.1", "react-dom": "^19.2.6", "react-hook-form": "^7.77.0", - "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.2", "recharts": "3.8.1", "rehype-highlight": "^7.0.2", diff --git a/package.json b/package.json index 53f4af1..7a192fd 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "@fortawesome/react-fontawesome": "^3.3.1", "@gsap/react": "^2.1.2", "@hookform/resolvers": "^5.4.0", + "@mdx-js/mdx": "^3.1.1", + "@mdx-js/react": "^3.1.1", "@neondatabase/serverless": "^1.1.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -94,7 +96,6 @@ "react-day-picker": "^10.0.1", "react-dom": "^19.2.6", "react-hook-form": "^7.77.0", - "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.2", "recharts": "3.8.1", "rehype-highlight": "^7.0.2", diff --git a/src/app/_components/Form/Fields/MdeFormField.tsx b/src/app/_components/Form/Fields/MdeFormField.tsx index 9a4b695..2048bf8 100644 --- a/src/app/_components/Form/Fields/MdeFormField.tsx +++ b/src/app/_components/Form/Fields/MdeFormField.tsx @@ -8,6 +8,7 @@ import type { Control, FieldValues, Path } from "react-hook-form"; import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form"; import { Button } from "~/components/ui/button"; import { cn } from "~/lib/utils"; +import { ClientMdx } from "~/components/ClientMdx"; import { InternalLinkTextarea, type AutocompleteTriggerConfig, @@ -81,7 +82,7 @@ export default function MdeFormField(params: { ), preview: params.renderPreview ? (source) => params.renderPreview?.(source) ?? <> - : undefined, + : (source) => , }} /> diff --git a/src/app/admin/blog/_components/BlogMdxEditorPreview.tsx b/src/app/admin/blog/_components/BlogMdxEditorPreview.tsx index 1693fac..aec9627 100644 --- a/src/app/admin/blog/_components/BlogMdxEditorPreview.tsx +++ b/src/app/admin/blog/_components/BlogMdxEditorPreview.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import { MDXRemote } from 'next-mdx-remote' import { serialize } from 'next-mdx-remote/serialize' import type { MDXRemoteSerializeResult } from 'next-mdx-remote' -import { mdxComponents } from '~/app/blog/_components/mdx-components' +import { mdxComponents } from '~/components/mdx-components' export default function BlogMdxEditorPreview(params: { source: string }) { const [compiled, setCompiled] = useState(null) diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index f31a390..402f72a 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -1,9 +1,10 @@ import { notFound } from "next/navigation"; import { MDXRemote } from "next-mdx-remote/rsc"; import { TRPCError } from "@trpc/server"; +import matter from "gray-matter"; import { servTrpc } from "~/app/_trpc/ServerClient"; import { Badge } from "~/components/ui/badge"; -import { mdxComponents } from "../_components/mdx-components"; +import { mdxComponents } from "~/components/mdx-components"; type Props = { params: Promise<{ slug: string }>; @@ -12,37 +13,47 @@ type Props = { export default async function BlogPostPage({ params }: Props) { const { slug } = await params; - let post: Awaited>; + let post: Awaited>; try { - post = await servTrpc.blog.bySlug(slug); + post = await servTrpc.blog.metadataBySlug(slug); } catch (e) { if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound(); throw e; } + const response = await fetch(post.fileUrl, { next: { revalidate: 3600 } }); + if (!response.ok) notFound(); + + const parsed = matter(await response.text()); + const tags = Array.isArray(parsed.data.tags) + ? parsed.data.tags.map((tag) => String(tag).trim()).filter(Boolean) + : post.tags; + const title = typeof parsed.data.title === "string" ? parsed.data.title : post.title; + const date = typeof parsed.data.date === "string" ? parsed.data.date : post.date; + return (
-

{post.title}

- {post.date && ( +

{title}

+ {date && ( )} - {post.tags.length > 0 && ( + {tags.length > 0 && (
- {post.tags.map((tag) => ( + {tags.map((tag) => ( {tag} ))}
)}
- +
); diff --git a/src/app/chat/_components/AssistantMessage.tsx b/src/app/chat/_components/AssistantMessage.tsx index 7283bfe..c1850fb 100644 --- a/src/app/chat/_components/AssistantMessage.tsx +++ b/src/app/chat/_components/AssistantMessage.tsx @@ -1,6 +1,5 @@ import type { UIMessage } from "ai"; -import Markdown from "react-markdown"; -import { cn } from "~/lib/utils"; +import { ClientMdx } from "~/components/ClientMdx"; export const AssistantMessage = (props: { message: UIMessage }) => { let message = props.message; @@ -16,9 +15,7 @@ export const AssistantMessage = (props: { message: UIMessage }) => { {message.parts.map((part, i) => { if (part.type === 'text') { return ( - - {part.text} - + ) } if (part.type === 'tool-scheduleMeeting') { diff --git a/src/app/cv/_components/CvEntry.tsx b/src/app/cv/_components/CvEntry.tsx index 8119585..ccdd9ed 100644 --- a/src/app/cv/_components/CvEntry.tsx +++ b/src/app/cv/_components/CvEntry.tsx @@ -1,13 +1,11 @@ import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card" import { cn } from "~/lib/utils" -import Markdown from 'react-markdown' import { format } from 'date-fns' -import rehypeHighlight from 'rehype-highlight' -import rehypeRaw from 'rehype-raw' import type { ArrayElement } from "type-fest" import AnimateTextIn from "~/app/_components/Animated/AnimateIn" import AnimatePopUp from "~/app/_components/Animated/AnimatePopUp" import type { CvCategoryData } from "./CvCategory" +import { ClientMdx } from "~/components/ClientMdx" export type CvEntryData = ArrayElement @@ -30,7 +28,7 @@ export default function CvEntry({ entry, className, position = 0 }: {
- {entry.description} +
: diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 532c789..eca31da 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -4,14 +4,13 @@ import { trpc } from "~/app/_trpc/Client"; import * as Card from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; import { StackBadge } from "~/components/StackBadge"; -import Markdown from "react-markdown"; import { ScrollArea } from "~/components/ui/scroll-area"; import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle"; import AnimateTextIn from "../_components/Animated/AnimateIn"; import { useTimeLine } from "../_providers/GsapProvicer"; import AnimatePopUp from "../_components/Animated/AnimatePopUp"; import { Button } from "~/components/ui/button"; -import remarkGfm from "remark-gfm" +import { ClientMdx } from "~/components/ClientMdx"; export default function ProjectsPage() { const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery(); @@ -63,7 +62,7 @@ export default function ProjectsPage() { {project.description && (
- {project.description} +
)} diff --git a/src/components/ClientMdx.tsx b/src/components/ClientMdx.tsx new file mode 100644 index 0000000..21dc477 --- /dev/null +++ b/src/components/ClientMdx.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { evaluate } from "@mdx-js/mdx"; +import { MDXProvider, useMDXComponents } from "@mdx-js/react"; +import type { MDXComponents } from "mdx/types"; +import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import * as runtime from "react/jsx-runtime"; +import rehypeHighlight from "rehype-highlight"; +import remarkGfm from "remark-gfm"; +import { mdxComponents } from "~/components/mdx-components"; + +type MdxModule = { + default: ComponentType<{ components?: MDXComponents }>; +}; + +type ClientMdxProps = { + source: string; + components?: MDXComponents; + format?: "md" | "mdx"; + fallback?: ReactNode; + errorFallback?: (error: Error) => ReactNode; +}; + +export function ClientMdx({ + source, + components = mdxComponents, + format = "md", + fallback = null, + errorFallback, +}: ClientMdxProps) { + const [Content, setContent] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const trimmed = source.trim(); + + if (!trimmed) { + setContent(null); + setError(null); + return; + } + + void evaluate(trimmed, { + ...runtime, + baseUrl: import.meta.url, + format, + useMDXComponents, + rehypePlugins: [rehypeHighlight], + remarkPlugins: [remarkGfm], + }) + .then((mod) => { + if (cancelled) return; + setContent(() => (mod as MdxModule).default); + setError(null); + }) + .catch((nextError: unknown) => { + if (cancelled) return; + setContent(null); + setError(nextError instanceof Error ? nextError : new Error("Failed to render MDX")); + }); + + return () => { + cancelled = true; + }; + }, [format, source]); + + if (error) { + return errorFallback ? errorFallback(error) :

{source}

; + } + + if (!Content) return <>{fallback}; + + return ( + + + + ); +} diff --git a/src/app/blog/_components/mdx-components.tsx b/src/components/mdx-components.tsx similarity index 96% rename from src/app/blog/_components/mdx-components.tsx rename to src/components/mdx-components.tsx index 3d8d2ee..2dedce9 100644 --- a/src/app/blog/_components/mdx-components.tsx +++ b/src/components/mdx-components.tsx @@ -1,5 +1,10 @@ import Link from "next/link"; -import { Children, isValidElement, type ComponentPropsWithoutRef, type ReactNode } from "react"; +import { + Children, + isValidElement, + type ComponentPropsWithoutRef, + type ReactNode, +} from "react"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { cn } from "~/lib/utils"; diff --git a/src/server/routers/blog.ts b/src/server/routers/blog.ts index 67f54a7..e41b667 100644 --- a/src/server/routers/blog.ts +++ b/src/server/routers/blog.ts @@ -305,6 +305,25 @@ export const blogRouter = router({ }; }), + metadataBySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => { + const post = await db.query.blogPost.findFirst({ + where(fields, operators) { + return operators.eq(fields.slug, slug); + }, + }); + + if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` }); + + return { + slug: post.slug, + title: post.title, + date: post.date, + description: post.description, + tags: post.tags ?? [], + fileUrl: post.fileUrl, + }; + }), + syncFromUploadThing: publicProcedure.mutation(async () => { await assertAdmin();