

Опубликовано: 20 июля 2024
Внимание: Статья была обновлена более года назад - 20 июля 2024. Информация может быть не актуальной
1Я **люблю** использовать [Next.js](https://extjs.org/)
1<p> 2 Я <strong>люблю</strong> использовать <a href="https://extjs.org/">Next.js</a> 3</p>
projects, перемещаете как угодно - всё сделается за вас, но сначала нужно объяснить кодом компу, что вам нужно 🧑ru.mdx, en.mdx;ru.mdx, en.mdx;rehype-slugrehype-autolink-headingsrehype-mdx-code-props1shared/config/mdx 2├── plugins - наши rehype/remark плагины 3│ ├── rehype-auto-heading.ts 4│ └── rehype-extract-code-props.ts 5├── types 6│ └── get-mdx.type.ts 7├── get-mdx.ts - получение metadata, контента и headlines из mdx 8├── mdx-components.tsx - настройка отображения компонентов mdx 9└── mdx-remote.tsx - общая конфигурация MDXRemote
shared/config/mdx/get-mdx.ts:1'use server'; 2 3import fs from 'fs/promises'; 4import matter from 'gray-matter'; 5import rehypeStringify from 'rehype-stringify'; 6import remarkParse from 'remark-parse'; 7import remarkRehype from 'remark-rehype'; 8import { unified } from 'unified'; 9import { 10 MdxHeadline, 11 ProjectMetadata, 12} from '../../config/mdx/types/get-mdx.type'; 13import { rehypeAutoHeading } from './plugins/rehype-auto-heading'; 14 15interface MdxResponse<T> { 16 metadata: T; 17 content: string; 18 headlines: MdxHeadline[]; 19} 20 21export async function getMdx<T = ProjectMetadata>( 22 filePath: string, 23): Promise<MdxResponse<T>> { 24 const fileContents = await fs.readFile(filePath, 'utf8'); 25 26 // Преобразуем код через gray-matter 27 const matterData = matter(fileContents); 28 const metadata = matterData.data as T; 29 const content = matterData.content; 30 31 // * Get project headlines 32 const headlines: MdxHeadline[] = []; 33 34 await unified() 35 .use(remarkParse) 36 .use(remarkRehype) 37 .use(rehypeAutoHeading, headlines) 38 .use(rehypeStringify) 39 .process(content); 40 41 return { metadata, content, headlines }; 42}
matter(fileContents) и получать контент с метаданными, но для расширяемости и поддерживаемости кода выносим получение всех данных в функцию getMdx().gray-matterfront-matter1import { StackVariants } from '@/shared/constants'; 2 3export interface MdxHeadline { 4 title: string; 5 href: string; 6 nested?: MdxHeadline[]; 7} 8 9export interface ProjectMetadata { 10 // Пишем любые свои типы в зависимости от ваших возможных метаданных 11 // Вот, например, мои типы: 12 note: string; 13 title: string; 14 description: string; 15 tags?: StackVariants[]; 16 publishDate: string; 17} 18 19export interface CategoryMetadata { 20 // Также пишем любые свои типы 21 title: string; 22 link: string; 23}
MDXComponentsData. Вот моя реализация компонентов, можете брать идеи!1import { cn } from '@/shared/lib/react'; 2import { StackVariants } from '@/shared/constants'; 3import { CodeBlock } from '@/shared/ui/client'; 4import { Heading, LinkHover, Code } from '@/shared/ui'; 5import type { MDXComponents } from 'mdx/types'; 6import { ComponentPropsWithoutRef } from 'react'; 7import { getDictionary } from '../i18n/dictionaries'; 8 9interface ExtendedCodeProps extends React.HTMLAttributes<HTMLElement> { 10 filename?: string; 11 githubPath?: string; 12 hideHeader?: boolean; 13} 14 15export const MDXComponentsData: MDXComponents = { 16 code: async (props: ExtendedCodeProps) => { 17 const { children, className, ...rest } = props; 18 const match = /language-(\w+)/.exec(className || ''); 19 const dict = await getDictionary(); 20 21 if (!match) { 22 return ( 23 <Code className='bg-default-200/50 py-0 px-1 h-fit rounded-md -top-0.5 select-text min-w-fit border border-default-200 text-default-700 !leading-5'> 24 {children} 25 </Code> 26 ); 27 } 28 29 return ( 30 <CodeBlock 31 text={String(children)} 32 language={match[1] as StackVariants} 33 fileName={props?.filename} 34 hideHeader={props?.hideHeader} 35 dict={dict.ui} 36 github={{ 37 path: props?.githubPath, 38 }} 39 {...rest} 40 /> 41 ); 42 }, 43 p: ({ children, className }: ComponentPropsWithoutRef<'p'>) => { 44 return ( 45 <p className={cn('text-default-600 my-5 leading-7', className)}> 46 {children} 47 </p> 48 ); 49 }, 50 a: ({ href, children, ...props }: ComponentPropsWithoutRef<'a'>) => { 51 return ( 52 <LinkHover href={href} {...props}> 53 {children} 54 </LinkHover> 55 ); 56 }, 57 h2: ({ children, ...props }: ComponentPropsWithoutRef<'h2'>) => ( 58 <Heading 59 Tag='h2' 60 className='text-xl font-bold -mt-[calc(var(--articles-height-navbar) - 2rem)] mb-6' 61 {...props} 62 > 63 {children} 64 </Heading> 65 ), 66 h3: ({ children, ...props }: ComponentPropsWithoutRef<'h3'>) => ( 67 <Heading 68 Tag='h3' 69 className='text-lg font-semibold -mt-[calc(var(--articles-height-navbar) - 1.5rem)] mb-4' 70 {...props} 71 > 72 {children} 73 </Heading> 74 ), 75 h4: ({ children, ...props }: ComponentPropsWithoutRef<'h4'>) => ( 76 <Heading 77 Tag='h4' 78 className='font-medium -mt-[calc(var(--articles-height-navbar) - 1.5rem)] mb-4' 79 {...props} 80 > 81 {children} 82 </Heading> 83 ), 84 hr: () => <hr className='my-12 border-default-100' />, 85 ul: ({ children, className }: ComponentPropsWithoutRef<'ul'>) => { 86 return ( 87 <ul 88 className={cn( 89 'text-default-600 my-5 leading-7 list-disc marker:text-default-200 list-inside', 90 className, 91 )} 92 > 93 {children} 94 </ul> 95 ); 96 }, 97 ol: ({ children, className }: ComponentPropsWithoutRef<'ol'>) => { 98 return ( 99 <ol 100 className={cn( 101 'text-default-600 my-5 leading-7 list-decimal marker:text-default-500 list-inside', 102 className, 103 )} 104 > 105 {children} 106 </ol> 107 ); 108 }, 109};
MDXRemote, но нам же нужна гибкость? Вдруг мы резко захотим использовать MDXRemote не только в layout для MDX файлов, но и на другой странице?1import { MDXRemoteProps, MDXRemote as MDXRemoteRSC } from 'next-mdx-remote/rsc'; 2import { FC } from 'react'; 3import remarkGfm from 'remark-gfm'; 4import { MDXComponentsData } from './mdx-components'; 5import { rehypeAutoHeading } from './plugins/rehype-auto-heading'; 6import { rehypeExtractCodeProps } from './plugins/rehype-extract-code-props'; 7 8export const MDXRemote: FC<MDXRemoteProps> = ({ 9 source, 10 components, 11 options, 12}) => { 13 return ( 14 <MDXRemoteRSC 15 source={source} 16 components={{ ...MDXComponentsData, ...components }} 17 options={{ 18 mdxOptions: { 19 rehypePlugins: [rehypeExtractCodeProps, rehypeAutoHeading], 20 remarkPlugins: [remarkGfm], 21 }, 22 ...options, 23 }} 24 /> 25 ); 26};
rehypeAutoHeading - плагин добавляет id к h1 - h6 как в rehype-slugrehype-autolink-headings1{ 2 title: string; 3 href: string; 4 nested?: [ 5 { 6 title: string; 7 href: string; 8 } 9 ]; 10}[]
rehypeAutoHeading:1import { toLatin } from '@/shared/lib'; 2import { slug } from 'github-slugger'; 3import { toString } from 'hast-util-to-string'; 4import { visit } from 'unist-util-visit'; 5import { MdxHeadline } from '../types/get-mdx.type'; 6 7export function rehypeAutoHeading(headlines?: MdxHeadline[]) { 8 return (tree: any) => { 9 const stack: { depth: number; headline: MdxHeadline }[] = []; 10 11 visit(tree, 'element', (node) => { 12 if (/h[1-6]/.test(node.tagName)) { 13 const text = toString(node); 14 const id = toLatin(slug(text)); 15 const depth = parseInt(node.tagName.slice(1), 10); 16 const headline = { 17 title: text.endsWith(':') ? text.slice(0, -1) : text, 18 href: `#${id}`, 19 }; 20 21 if (!node.properties) node.properties = {}; 22 node.properties.id = id; 23 24 while (stack.length && stack[stack.length - 1].depth >= depth) { 25 stack.pop(); 26 } 27 28 if (headlines) { 29 if (depth === 1 || stack.length === 0) { 30 headlines.push(headline); 31 stack.push({ depth, headline }); 32 } else { 33 const parent = stack[stack.length - 1].headline; 34 if (!parent.nested) { 35 parent.nested = []; 36 } 37 parent.nested.push(headline); 38 if (depth === 2) { 39 stack.push({ depth, headline }); 40 } 41 } 42 } 43 44 node.children = [ 45 { 46 type: 'element', 47 tagName: 'a', 48 properties: { href: `#${id}`, isTitle: true }, 49 children: [...node.children], 50 }, 51 ]; 52 } 53 }); 54 }; 55}
rehypeExtractCodeProps - плагин для получения всех полей ключ-значение в болоке кода из файла .mdx, также поддерживается boolean тип, если передан только ключ.rehype-mdx-code-props1import { visit } from 'unist-util-visit'; 2 3export function rehypeExtractCodeProps() { 4 return (tree: any) => { 5 visit(tree, 'element', (node) => { 6 if (node.tagName === 'code' && node.data && node.data.meta) { 7 const metaString = node.data.meta.trim(); 8 9 // Извлечение всех пар ключ="значение" 10 const props: { [key: string]: string | boolean } = {}; 11 const regex = /(\w+)="([^"]*)"|(\w+)/g; 12 let match; 13 while ((match = regex.exec(metaString)) !== null) { 14 if (match[2] !== undefined) { 15 // Для ключ="значение" 16 props[match[1]] = match[2]; 17 } else { 18 // Для просто ключа (без значения) 19 props[match[3]] = true; // Присваиваем значение true 20 } 21 } 22 23 // Добавление извлеченных атрибутов в свойства узла 24 node.properties = { 25 ...node.properties, 26 ...props, 27 }; 28 } 29 }); 30 }; 31}
getMdx() и наслаждаемся лёгкостью получения данных с .mdx файлов. get-project.ts:1'use server'; 2 3import { getLang } from '@/shared/config/i18n'; 4import { CategoryMetadata, getMdx } from '@/shared/config/mdx'; 5import path from 'path'; 6import { ProjectResponse } from '../types/project.type'; 7 8export async function getProject( 9 category: string, 10 name: string, 11): Promise<ProjectResponse> { 12 const lang = await getLang(); 13 14 // Получаем директорию проекта нужного нам .mdx файла 15 const dir = path.join( 16 process.cwd(), 17 'projects', 18 category, 19 name, 20 `${lang}.mdx`, 21 ); 22 // Получаем данные проекта 23 const { content, headlines, metadata } = await getMdx(dir); 24 25 // Получаем директорию категории нужного нам .mdx файла 26 const dirCategory = path.join( 27 process.cwd(), 28 'projects', 29 category, 30 `${lang}.mdx`, 31 ); 32 // Получаем данные категории 33 const { content: contentCategory, metadata: metadataCategory } = 34 await getMdx<CategoryMetadata>(dirCategory); 35 36 // Если метадата не найдена, то пробрасываем ошибку! 37 if (!metadata) throw new Error(`Unable to find metadata in file "${dir}"`); 38 39 // Просто возвращаем полученные данные с getMdx() 40 return { 41 metadata, 42 metadataCategory: { 43 ...metadataCategory, 44 link: `/projects/${category}`, 45 }, 46 contentCategory, 47 content, 48 headlines, 49 }; 50}
get-projects-by-category.ts:1'use server'; 2 3import { getLang } from '@/shared/config/i18n'; 4import { CategoryMetadata, getMdx, ProjectMetadata } from '@/shared/config/mdx'; 5import fs from 'fs/promises'; 6import path from 'path'; 7 8interface ProjectsByCategoryResponse { 9 category: CategoryMetadata; 10 projects: (ProjectMetadata & { link: string })[]; 11} 12 13export async function getProjectListByCategory( 14 category: string, 15): Promise<ProjectsByCategoryResponse> { 16 // Смотрим статью про i18n! 17 // Ссылка в самом начале страницы 18 // Или выпиливаем функционал с i18n 19 const lang = await getLang(); 20 21 const dirCategory = path.join(process.cwd(), 'projects', category); 22 const projectFile = path.join(dirCategory, `${lang}.mdx`); 23 24 const { metadata: metadataCategory } = await getMdx<CategoryMetadata>( 25 projectFile, 26 ); 27 28 const projectDirs = await fs.readdir(dirCategory, { withFileTypes: true }); 29 30 // Проходимся по всем проектам из категории 31 // И возвращаем метаданные проектов для отображения 32 const metadataProject = await Promise.all( 33 projectDirs 34 .filter((dirent) => dirent.isDirectory()) 35 .map(async (dirent) => { 36 const projectFile = path.join(dirCategory, dirent.name, `${lang}.mdx`); 37 38 const mdxProject = await getMdx<CategoryMetadata>(projectFile); 39 const projectMetadata = mdxProject.metadata; 40 41 return { 42 ...projectMetadata, 43 link: `/projects/${category}/${dirent.name}`, 44 } as ProjectMetadata & { link: string }; 45 }), 46 ); 47 48 return { 49 category: metadataCategory, 50 projects: metadataProject, 51 }; 52}
getMdx(dir) и возвращаем данные get-projects.ts:1'use server'; 2 3import { getLang } from '@/shared/config/i18n'; 4import { CategoryMetadata, getMdx, ProjectMetadata } from '@/shared/config/mdx'; 5import fs from 'fs/promises'; 6import path from 'path'; 7import { ProjectsResponse } from '../types/project.type'; 8 9export async function getProjectList(): Promise<ProjectsResponse[]> { 10 const lang = await getLang(); 11 12 const rootDir = path.join(process.cwd(), 'projects'); 13 const categoryDirs = await fs.readdir(rootDir, { withFileTypes: true }); 14 15 const projectsByCategory: ProjectsResponse[] = []; 16 17 for (const dirent of categoryDirs) { 18 if (dirent.isDirectory()) { 19 const categoryDir = path.join(rootDir, dirent.name); 20 const projectFile = path.join(categoryDir, `${lang}.mdx`); 21 22 const mdxCategory = await getMdx<CategoryMetadata>(projectFile); 23 const metadataCategory = mdxCategory.metadata; 24 25 const projectDirs = await fs.readdir(categoryDir, { 26 withFileTypes: true, 27 }); 28 const projects: (ProjectMetadata & { link: string })[] = []; 29 30 for (const projectDirent of projectDirs) { 31 if (projectDirent.isDirectory()) { 32 const projectFile = path.join( 33 categoryDir, 34 projectDirent.name, 35 `${lang}.mdx`, 36 ); 37 38 const mdxProject = await getMdx<CategoryMetadata>(projectFile); 39 const projectMetadata = mdxProject.metadata; 40 41 projects.push({ 42 ...projectMetadata, 43 link: `/projects/${dirent.name}/${projectDirent.name}`, 44 } as ProjectMetadata & { link: string }); 45 } 46 } 47 48 projectsByCategory.push({ 49 title: metadataCategory.title, 50 link: `/projects/${dirent.name}`, 51 projects, 52 }); 53 } 54 } 55 56 return projectsByCategory; 57}
1import { 2 CategoryMetadata, 3 MdxHeadline, 4 ProjectMetadata, 5} from '@/shared/config/mdx'; 6 7export interface ProjectResponse { 8 metadata: ProjectMetadata; 9 content: string; 10 metadataCategory: CategoryMetadata; 11 contentCategory: string; 12 headlines: MdxHeadline[]; 13} 14 15export interface ProjectsResponse { 16 title: string; 17 link: string; 18 projects: (ProjectMetadata & { link: string })[]; 19}
/app:1[lang] - для интернационализации 2└── projects 3 └── [category] 4 ├── [name] 5 │ └── page.tsx - layout для mdx файлов проектов 6 └── page.tsx - страница с проектами из N категории
app/[lang]/projects/[category]/[name]/page.tsx:1import { getProject } from '@/entity/project'; 2import { MDXRemote } from '@/shared/config/mdx'; 3import { Header, Headlines } from '@/widgets'; 4import { MDXComponents } from 'mdx/types'; 5import { Metadata } from 'next'; 6import dynamic from 'next/dynamic'; 7 8export type ProjectPageProps = { 9 params: { name: string; category: string }; 10}; 11 12export default async function ProjectPage({ params }: ProjectPageProps) { 13 const { metadata, content, metadataCategory, headlines } = await getProject( 14 params.category, 15 params.name, 16 ); 17 18 const components: MDXComponents = { 19 // Здесь вы можете добавить импорт компонента в ваш .mdx файл 20 // Чтобы не импортировать его внутри .mdx 21 AuthExample: dynamic(() => 22 import('@/projects/best-practice/app-router-auth/examples/auth').then( 23 (mod) => mod.AuthExample, 24 ), 25 ), 26 }; 27 28 return ( 29 <> 30 <Header 31 note={metadata?.note || metadataCategory?.title} 32 noteLink={metadata?.note || metadataCategory?.link} 33 title={metadata?.title} 34 description={metadata?.description} 35 tags={metadata?.tags} 36 /> 37 {/* MDXRemote импортируем из @/shared/config/mdx ! */} 38 <MDXRemote source={content} components={components} /> 39 <Headlines headlines={headlines} /> 40 </> 41 ); 42} 43 44// Генерируем метадату (не обязательно) 45// Будет полезно для SEO вашего сайта 46export async function generateMetadata({ 47 params, 48}: ProjectPageProps): Promise<Metadata> { 49 const { metadata } = await getProject(params.category, params.name); 50 51 return { 52 title: `Simple App | ${metadata.title}`, 53 description: `${metadata.description}. Technologies: ${metadata.tags?.join( 54 ', ', 55 )}`, 56 }; 57}
app/[lang]/projects/[category]/page.tsx:1import { getProjectListByCategory } from '@/entity/project'; 2import { getDictionary } from '@/shared/config/i18n'; 3import { Header } from '@/widgets'; 4import { Metadata } from 'next'; 5import Link from 'next/link'; 6 7export type ProjectCategoryPageProps = { 8 params: { category: string }; 9}; 10 11export default async function ProjectCategoryPage({ 12 params, 13}: ProjectCategoryPageProps) { 14 const { projects, category } = await getProjectListByCategory(params.category); 15 16 // Получаем переводы 17 const dict = await getDictionary(); 18 19 return ( 20 <> 21 <Header 22 note={dict.ui['category-note']} 23 title={category.title} 24 description={dict.ui['category-description']} 25 /> 26 27 {/* Просто выводим проекты, которые относятся к определённой категории */} 28 <div className='grid sm:grid-cols-2 gap-4'> 29 {projects.map((project) => ( 30 <Link 31 href={project.link} 32 key={project.title} 33 className='px-6 py-4 bg-default-100 hover:bg-default-100/50 border border-default-200 hover:border-default-300 rounded-md flex flex-col gap-2 transition active:scale-[0.98]' 34 > 35 {project.title} 36 <span className='text-default-600 text-sm'> 37 {project.description} 38 </span> 39 </Link> 40 ))} 41 </div> 42 </> 43 ); 44} 45 46// Генерируем метадату (не обязательно) 47// Будет полезно для SEO вашего сайта 48export async function generateMetadata({ 49 params, 50}: ProjectCategoryPageProps): Promise<Metadata> { 51 const { category, projects } = await getProjectListByCategory(params.category); 52 53 return { 54 title: `Simple App | ${category.title}`, 55 description: `Category: ${category.title}. Projects: ${projects 56 .map((project) => project.title) 57 .join(', ')}.`, 58 }; 59}