cleanup markdown
This commit is contained in:
@@ -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<T extends FieldValues>(params: {
|
||||
),
|
||||
preview: params.renderPreview
|
||||
? (source) => params.renderPreview?.(source) ?? <></>
|
||||
: undefined,
|
||||
: (source) => <ClientMdx source={source} fallback={source} />,
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -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<MDXRemoteSerializeResult | null>(null)
|
||||
|
||||
@@ -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<ReturnType<typeof servTrpc.blog.bySlug>>;
|
||||
let post: Awaited<ReturnType<typeof servTrpc.blog.metadataBySlug>>;
|
||||
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 (
|
||||
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold">{post.title}</h1>
|
||||
{post.date && (
|
||||
<h1 className="text-3xl font-bold">{title}</h1>
|
||||
{date && (
|
||||
<time className="text-muted-foreground text-sm">
|
||||
{new Date(post.date).toLocaleDateString("en-US", {
|
||||
{new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
)}
|
||||
{post.tags.length > 0 && (
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{post.tags.map((tag) => (
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<article className="prose dark:prose-invert max-w-none">
|
||||
<MDXRemote source={post.content} components={mdxComponents} />
|
||||
<MDXRemote source={parsed.content} components={mdxComponents} />
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Markdown key={crypto.randomUUID()}>
|
||||
{part.text}
|
||||
</Markdown>
|
||||
<ClientMdx key={i} source={part.text} fallback={part.text} />
|
||||
)
|
||||
}
|
||||
if (part.type === 'tool-scheduleMeeting') {
|
||||
|
||||
@@ -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<CvCategoryData['cvEntry']>
|
||||
|
||||
@@ -30,7 +28,7 @@ export default function CvEntry({ entry, className, position = 0 }: {
|
||||
<CardContent className="text-sm lg:text-base">
|
||||
<AnimatePopUp position={position + 0.2}>
|
||||
<article className="prose prose-zinc dark:prose-invert max-w-none">
|
||||
<Markdown rehypePlugins={[rehypeHighlight, rehypeRaw]}>{entry.description}</Markdown>
|
||||
<ClientMdx source={entry.description} fallback={entry.description} />
|
||||
</article>
|
||||
</AnimatePopUp>
|
||||
</CardContent> :
|
||||
|
||||
@@ -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 && (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
||||
<AnimatePopUp once position={i + 1.4} duration={10}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{project.description}</Markdown>
|
||||
<ClientMdx source={project.description} fallback={project.description} />
|
||||
</AnimatePopUp>
|
||||
</div>
|
||||
)}
|
||||
|
||||
79
src/components/ClientMdx.tsx
Normal file
79
src/components/ClientMdx.tsx
Normal file
@@ -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<MdxModule["default"] | null>(null);
|
||||
const [error, setError] = useState<Error | null>(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) : <p>{source}</p>;
|
||||
}
|
||||
|
||||
if (!Content) return <>{fallback}</>;
|
||||
|
||||
return (
|
||||
<MDXProvider components={components}>
|
||||
<Content components={components} />
|
||||
</MDXProvider>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user