refactor: streamline MDX handling and enhance content collections

- Replaced the custom Mdx component with MDXContent from @content-collections/mdx/react for improved MDX processing.
- Updated content handling in various legal and blog pages to directly use the transformed body from content collections.
- Introduced estimated reading time calculation in the posts collection for better user experience.
- Added a new mdx.css file for consistent styling across MDX components.
- Removed the obsolete shared mdx-component file to reduce redundancy.
This commit is contained in:
javayhu 2025-03-30 13:27:30 +08:00
parent c3a774e1cb
commit 08fee6d1ec
13 changed files with 60 additions and 411 deletions

View File

@ -1,19 +1,11 @@
import { DEFAULT_LOCALE, LOCALES } from "@/i18n/routing";
import { defineCollection, defineConfig } from "@content-collections/core";
import { compileMDX } from "@content-collections/mdx";
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypePrettyCode, { Options } from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import { codeImport } from 'remark-code-import';
import remarkGfm from 'remark-gfm';
import { createHighlighter } from 'shiki';
import path from "path";
import { LOCALES, DEFAULT_LOCALE } from "@/i18n/routing";
import { visit } from 'unist-util-visit';
import {
createMetaSchema,
createDocSchema,
createMetaSchema,
transformMDX,
} from '@fumadocs/content-collections/configuration';
import path from "path";
/**
* Content Collections documentation
@ -123,10 +115,12 @@ export const posts = defineCollection({
published: z.boolean().default(true),
categories: z.array(z.string()),
author: z.string(),
locale: z.enum(LOCALES as [string, ...string[]]).optional()
locale: z.enum(LOCALES as [string, ...string[]]).optional(),
estimatedTime: z.number().optional() // Reading time in minutes
}),
transform: async (data, context) => {
const body = await compileWithCodeCopy(context, data);
// Use Fumadocs transformMDX for consistent MDX processing
const transformedData = await transformMDX(data, context);
// Determine the locale from the file path or use the provided locale
const pathParts = data._meta.path.split(path.sep);
@ -166,6 +160,11 @@ export const posts = defineCollection({
const slugParamsParts = slugPath.split(path.sep).slice(1);
const slugAsParams = slugParamsParts.join('/');
// Calculate estimated reading time
const wordCount = data.content.split(/\s+/).length;
const wordsPerMinute = 200; // Average reading speed: 200 words per minute
const estimatedTime = Math.max(Math.ceil(wordCount / wordsPerMinute), 1);
return {
...data,
locale,
@ -173,10 +172,9 @@ export const posts = defineCollection({
categories: blogCategories,
slug: `/${slugPath}`,
slugAsParams,
body: {
raw: data.content,
code: body
}
estimatedTime,
body: transformedData.body, // Use processed MDX content directly
toc: transformedData.toc
};
}
});
@ -206,7 +204,8 @@ export const pages = defineCollection({
locale: z.enum(LOCALES as [string, ...string[]]).optional()
}),
transform: async (data, context) => {
const body = await compileWithCodeCopy(context, data);
// Use Fumadocs transformMDX for consistent MDX processing
const transformedData = await transformMDX(data, context);
// Determine the locale from the file path or use the provided locale
const pathParts = data._meta.path.split(path.sep);
@ -230,10 +229,8 @@ export const pages = defineCollection({
locale,
slug: `/${slugPath}`,
slugAsParams,
body: {
raw: data.content,
code: body
}
body: transformedData.body,
toc: transformedData.toc
};
}
});
@ -264,7 +261,8 @@ export const releases = defineCollection({
locale: z.enum(LOCALES as [string, ...string[]]).optional()
}),
transform: async (data, context) => {
const body = await compileWithCodeCopy(context, data);
// Use Fumadocs transformMDX for consistent MDX processing
const transformedData = await transformMDX(data, context);
// Determine the locale from the file path or use the provided locale
const pathParts = data._meta.path.split(path.sep);
@ -288,80 +286,12 @@ export const releases = defineCollection({
locale,
slug: `/${slugPath}`,
slugAsParams,
body: {
raw: data.content,
code: body
}
body: transformedData.body,
toc: transformedData.toc
};
}
});
const prettyCodeOptions: Options = {
theme: 'github-dark',
getHighlighter: (options) =>
createHighlighter({
...options
}),
onVisitLine(node) {
// Prevent lines from collapsing in `display: grid` mode, and allow empty
// lines to be copy/pasted
if (node.children.length === 0) {
node.children = [{ type: 'text', value: ' ' }];
}
},
onVisitHighlightedLine(node) {
if (!node.properties.className) {
node.properties.className = [];
}
node.properties.className.push('line--highlighted');
},
onVisitHighlightedChars(node) {
if (!node.properties.className) {
node.properties.className = [];
}
node.properties.className = ['word--highlighted'];
}
};
const compileWithCodeCopy = async (
context: any,
data: any,
options: {
remarkPlugins?: any[];
rehypePlugins?: any[];
} = {}
) => {
return await compileMDX(context, data, {
...options,
remarkPlugins: [
remarkGfm,
codeImport,
...(options.remarkPlugins || [])
],
rehypePlugins: [
rehypeSlug,
[rehypePrettyCode, prettyCodeOptions],
// add __rawString__ to pre element
() => (tree) => {
visit(tree, (node) => {
if (node?.type === "element" && node?.tagName === "pre") {
const [codeEl] = node.children;
if (codeEl.tagName !== "code") return;
// add __rawString__ as a property that will be passed to the React component
if (!node.properties) {
node.properties = {};
}
node.properties.__rawString__ = codeEl.children?.[0]?.value;
}
});
},
rehypeAutolinkHeadings,
...(options.rehypePlugins || [])
]
});
};
export default defineConfig({
collections: [docs, metas, authors, categories, posts, pages, releases]
});

View File

@ -50,7 +50,7 @@ export default async function CookiePolicyPage(props: NextPageProps) {
title={page.title}
description={page.description}
date={page.date}
content={page.body.code}
content={page.body}
/>
);
}

View File

@ -1,6 +1,8 @@
import Container from '@/components/container';
import { PropsWithChildren } from 'react';
import '@/styles/mdx.css';
export default function LegalLayout({ children }: PropsWithChildren) {
return (
<Container className="py-16 px-4">

View File

@ -50,7 +50,7 @@ export default async function PrivacyPolicyPage(props: NextPageProps) {
title={page.title}
description={page.description}
date={page.date}
content={page.body.code}
content={page.body}
/>
);
}

View File

@ -1,12 +1,12 @@
import { CustomPage } from '@/components/page/custom-page';
import { constructMetadata } from '@/lib/metadata';
import { getCustomPage } from '@/lib/page/get-custom-page';
import { getBaseUrlWithLocale } from '@/lib/urls/get-base-url';
import type { NextPageProps } from '@/types/next-page-props';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { constructMetadata } from '@/lib/metadata';
import { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
export async function generateMetadata({
params,
@ -50,7 +50,7 @@ export default async function TermsOfServicePage(props: NextPageProps) {
title={page.title}
description={page.description}
date={page.date}
content={page.body.code}
content={page.body}
/>
);
}

View File

@ -8,6 +8,8 @@ import { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import '@/styles/mdx.css';
export async function generateMetadata({
params,
}: {
@ -60,7 +62,7 @@ export default async function ChangelogPage(props: NextPageProps) {
description={release.description}
date={release.date}
version={release.version}
content={release.body.code}
content={release.body}
/>
))}
</div>

View File

@ -1,10 +1,9 @@
import AllPostsButton from '@/components/blog/all-posts-button';
import { BlogToc } from '@/components/blog/blog-toc';
import { Mdx } from '@/components/shared/mdx-component';
import { LocaleLink } from '@/i18n/navigation';
import { getTableOfContents } from '@/lib/blog/toc';
import { getBaseUrlWithLocale } from '@/lib/urls/get-base-url';
import { estimateReadingTime, getLocaleDate } from '@/lib/utils';
import { getLocaleDate } from '@/lib/utils';
import type { NextPageProps } from '@/types/next-page-props';
import { allPosts } from 'content-collections';
import { CalendarIcon, ClockIcon } from 'lucide-react';
@ -15,6 +14,10 @@ import { notFound } from 'next/navigation';
import { constructMetadata } from '@/lib/metadata';
import { Locale } from 'next-intl';
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
import { MDXContent } from '@content-collections/mdx/react';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import '@/styles/mdx.css';
/**
* Gets the blog post from the params
@ -134,7 +137,7 @@ export default async function BlogPostPage(props: NextPageProps) {
<div className="flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground leading-none my-auto">
{estimateReadingTime(post.body.raw)}
{post.estimatedTime ? `${post.estimatedTime} min read` : 'Quick read'}
</span>
</div>
</div>
@ -147,7 +150,12 @@ export default async function BlogPostPage(props: NextPageProps) {
</div>
{/* blog post content */}
<Mdx code={post.body.code} />
<div className="max-w-none prose prose-slate dark:prose-invert prose-img:rounded-lg">
<MDXContent
code={post.body}
components={defaultMdxComponents}
/>
</div>
<div className="flex items-center justify-start my-16">
<AllPostsButton />

View File

@ -12,7 +12,7 @@ import { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import type { ReactNode } from 'react';
import '@/styles/docs.css';
import '@/styles/mdx.css';
// available languages that will be displayed on UI
// make sure `locale` is consistent with your i18n config

View File

@ -1,9 +1,10 @@
import { Mdx } from '@/components/shared/mdx-component';
import { getLocaleDate } from '@/lib/utils';
import { Separator } from '@radix-ui/react-separator';
import { Badge, CalendarIcon, TagIcon } from 'lucide-react';
import { version } from 'os';
import { Card, CardHeader, CardContent } from '../ui/card';
import { MDXContent } from '@content-collections/mdx/react';
import defaultMdxComponents from 'fumadocs-ui/mdx';
interface CustomPageProps {
title: string;
@ -39,7 +40,9 @@ export function CustomPage({
{/* Content */}
<Card className="mb-8">
<CardContent>
<Mdx code={content} />
<div className="max-w-none prose prose-slate dark:prose-invert prose-img:rounded-lg">
<MDXContent code={content} components={defaultMdxComponents} />
</div>
</CardContent>
</Card>
</div>

View File

@ -1,9 +1,10 @@
import { Mdx } from '@/components/shared/mdx-component';
import { getLocaleDate } from '@/lib/utils';
import { CalendarIcon, TagIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { MDXContent } from '@content-collections/mdx/react';
import defaultMdxComponents from 'fumadocs-ui/mdx';
interface ReleaseCardProps {
title: string;
@ -40,7 +41,9 @@ export function ReleaseCard({
<Separator />
</CardHeader>
<CardContent>
<Mdx code={content} />
<div className="max-w-none prose prose-slate dark:prose-invert prose-img:rounded-lg">
<MDXContent code={content} components={defaultMdxComponents} />
</div>
</CardContent>
</Card>
);

View File

@ -1,262 +0,0 @@
'use client';
import * as React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useMDXComponent } from '@content-collections/mdx/react';
import { Callout } from '@/components/shared/callout';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { CopyButton } from './copy-button';
const components = {
h1: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1
className={cn(
'font-heading mt-2 scroll-m-24 text-4xl font-bold',
className
)}
{...props}
/>
),
h2: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2
className={cn(
'font-heading mb-4 mt-8 scroll-m-24 text-2xl font-semibold leading-8 tracking-tight',
className
)}
{...props}
/>
),
h3: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3
className={cn(
'font-heading mt-8 scroll-m-24 text-xl font-medium tracking-tight',
className
)}
{...props}
/>
),
h4: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h4
className={cn(
'font-heading mt-8 scroll-m-24 text-lg font-medium tracking-tight',
className
)}
{...props}
/>
),
h5: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h5
className={cn(
'mt-8 scroll-m-24 text-base font-medium tracking-tight',
className
)}
{...props}
/>
),
h6: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h6
className={cn(
'mt-8 scroll-m-24 text-sm font-medium tracking-tight',
className
)}
{...props}
/>
),
a: ({ className, ...props }: React.HTMLAttributes<HTMLAnchorElement>) => (
<a
className={cn(
'font-medium text-primary underline underline-offset-4',
className
)}
{...props}
/>
),
p: ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className={cn('text-muted-foreground mt-6 leading-7', className)} {...props} />
),
ul: ({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) => (
<ul className={cn('text-muted-foreground my-6 ml-6 list-disc', className)} {...props} />
),
ol: ({ className, ...props }: React.HTMLAttributes<HTMLOListElement>) => (
<ol className={cn('text-muted-foreground my-6 ml-6 list-decimal', className)} {...props} />
),
li: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
<li className={cn('text-muted-foreground mt-2', className)} {...props} />
),
blockquote: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
<blockquote
className={cn('mt-6 border-l-2 pl-6 italic', className)}
{...props}
/>
),
strong: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
<strong className={cn('text-foreground', className)} {...props} />
),
img: ({
className,
alt,
...props
}: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img className={cn('rounded-md', className)} alt={alt} {...props} />
),
hr: ({ ...props }: React.HTMLAttributes<HTMLHRElement>) => (
<hr className="my-4 md:my-8" {...props} />
),
table: ({ className, ...props }: React.HTMLAttributes<HTMLTableElement>) => (
<div className="my-6 w-full overflow-y-auto rounded-none">
<table
className={cn('w-full overflow-hidden rounded-none', className)}
{...props}
/>
</div>
),
tr: ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr className={cn('m-0 border-t p-0', className)} {...props} />
),
th: ({ className, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => (
<th
className={cn(
'border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right',
className
)}
{...props}
/>
),
td: ({ className, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => (
<td
className={cn(
'text-muted-foreground border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right',
className
)}
{...props}
/>
),
pre: ({
className,
__rawString__,
children,
...props
}: React.HTMLAttributes<HTMLPreElement> & { __rawString__?: string }) => {
const preRef = React.useRef<HTMLPreElement>(null);
const [codeContent, setCodeContent] = React.useState<string>(__rawString__ || '');
// Extract the text content from the pre element after rendering
React.useEffect(() => {
if (preRef.current && !codeContent) {
// Find the code element inside the pre element
const codeElement = preRef.current.querySelector('code');
if (codeElement) {
// Get the text content of the code element
const text = codeElement.textContent || '';
setCodeContent(text);
}
}
}, [codeContent]);
return (
<div className="my-4 group relative w-full overflow-hidden">
<pre
ref={preRef}
className={cn(
"max-h-[650px] overflow-x-auto rounded-lg border bg-zinc-900 py-4 dark:bg-zinc-900",
className,
)}
{...props}
>
{children}
</pre>
{(codeContent || __rawString__) && (
<CopyButton
value={codeContent || __rawString__ || ''}
className="cursor-pointer absolute right-4 top-4 z-20 opacity-70 hover:opacity-100 transition-all"
/>
)}
</div>
);
},
code: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
<code
className={cn(
'relative font-mono text-sm',
className
)}
{...props}
/>
),
Accordion,
AccordionContent: ({
className,
...props
}: React.ComponentProps<typeof AccordionContent>) => (
<AccordionContent className={cn('[&>p]:m-0', className)} {...props} />
),
AccordionItem,
AccordionTrigger,
Callout,
Image: ({ className, ...props }: React.ComponentProps<typeof Image>) => (
<Image
className={cn('my-4 w-full rounded-lg', className)}
{...props}
/>
),
Tabs: ({ className, ...props }: React.ComponentProps<typeof Tabs>) => (
<Tabs className={cn('relative mt-6 w-full', className)} {...props} />
),
TabsList: ({
className,
...props
}: React.ComponentProps<typeof TabsList>) => (
<TabsList className={cn('w-full border-b', className)} {...props} />
),
TabsTrigger: ({
className,
...props
}: React.ComponentProps<typeof TabsTrigger>) => (
<TabsTrigger className={cn('', className)} {...props} />
),
TabsContent: ({
className,
...props
}: React.ComponentProps<typeof TabsContent>) => (
<TabsContent className={cn('p-4', className)} {...props} />
),
Link: ({ className, ...props }: React.ComponentProps<typeof Link>) => (
<Link
className={cn('font-medium underline underline-offset-4', className)}
{...props}
/>
),
LinkedCard: ({ className, ...props }: React.ComponentProps<typeof Link>) => (
<Link
className={cn(
'flex w-full flex-col items-center rounded-xl border bg-card p-6 text-card-foreground shadow-sm transition-colors hover:bg-muted/50 sm:p-10',
className
)}
{...props}
/>
),
};
interface MdxProps {
code: string;
}
/**
* render mdx content with custom components
*/
export function Mdx({ code }: MdxProps) {
const Component = useMDXComponent(code);
return (
<div className="mdx">
<Component components={components} />
</div>
);
}

View File

@ -3,6 +3,10 @@
@custom-variant dark (&:is(.dark *));
/*
* How to Add a Theme Selector to Your Next.js App
* https://ouassim.tech/notes/how-to-add-a-theme-selector-to-your-nextjs-app/
*/
@theme inline {
--font-sans: var(--font-dm-sans);
--font-mono: var(--font-dm-mono);
@ -302,44 +306,3 @@ body {
@apply !rounded-none !shadow-none;
}
}
/* MDX styles for code highlighting */
@layer components {
[data-rehype-pretty-code-figure] code {
@apply grid min-w-full break-words rounded-none border-0 bg-transparent p-0;
counter-reset: line;
box-decoration-break: clone;
}
[data-rehype-pretty-code-figure] [data-line] {
@apply inline-block min-h-[1rem] w-full px-4 py-0.5;
}
[data-rehype-pretty-code-figure] [data-line-numbers] [data-line] {
@apply px-2;
}
[data-rehype-pretty-code-title] {
@apply mt-2 px-4 pt-6 text-sm font-medium text-foreground;
}
[data-rehype-pretty-code-title] + pre {
@apply mt-2;
}
/* Standalone code elements */
code {
@apply relative rounded px-[0.3rem] py-[0.2rem] text-foreground bg-accent;
}
/* Code elements inside pre */
pre code {
@apply bg-transparent;
}
}
@utility line-highlighted {
[data-rehype-pretty-code-figure] & span {
@apply relative;
}
}