light

MDX в HTML

Преобразуем Markdown в HTML и синхронизируем с i18n

Опубликовано: 20 июля 2024

Внимание: Статья была обновлена более года назад - 20 июля 2024. Информация может быть не актуальной
Перед тем как нырнуть в код, быстро пробегитесь по статье про
интернационализацию
 — так всё, что будет дальше, сложится в единую картину.

Markdown и многомерные выражения

Markdown
- лёгкий язык разметки, который превращает привычный «plain text» в аккуратный HTML. Чаще всего его используют для статей, документации и блогов.
Вы пишете...
1Я **люблю** использовать [Next.js](https://extjs.org/)
А браузер получает:
1<p>
2  Я <strong>люблю</strong> использовать <a href="https://extjs.org/">Next.js</a>
3</p>
MDX
- это надмножество markdown, которое позволяет вам записывать
JSX
непосредственно в ваши файлы markdown. Это мощный способ добавить динамическую интерактивность и встроить компоненты React в ваш контент.

Что получится в итоге?

Функционал статей, навигация по главам и категории на этом сайте - это то, что получится в итоге (:
Просто создаёте папки с категориями и проектами внутри projects, перемещаете как угодно - всё сделается за вас, но сначала нужно объяснить кодом компу, что вам нужно 🧑‍
Так будет выглядеть структура ваших проектов, блогов или чего захотите:
1your-project
2├── projects
3│   └── category
4│       ├── project-name
5│       │   ├── en.mdx
6│       │   └── ru.mdx
7│       ├── en.mdx
8│       └── ru.mdx
9└── package.json
Допустим, вы захотели создать новый проект в новой категории:
  1. Создаём папку с категорией и добавляем в неё ru.mdx, en.mdx;
  2. Создаём папку с проектом и добавляем ru.mdx, en.mdx;
  3. Пишем статью 🥰

Создаём основной функционал

Почему не реализовать MDX как в документации
Next.js
? Почему не использовать готовые плагины по типу
rehype-slug
,
rehype-autolink-headings
и
rehype-mdx-code-props
?
Вы можете так и сделать, это будет намного проще, но вы потеряете часть контроля над процессом разработки. Конечно, намного больше контроля вы можете получить написав свой фреймворк вместо Next.js 😁 Но не будем уходить в крайности.
Так выглядит директория с основным функционалом MDX:
1shared/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

Получение данных с MDX

Создадим функцию для получение всех данных из .mdx файлов shared/config/mdx/get-mdx.ts:
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().
Теперь мы можем спокойно изменить логику получения данных из MDX. Например, вместо
gray-matter
спокойно поставить
front-matter
и т.п.

Типизация MDX данных

Внутри MDX файлов у нас отсутствует какая-либо типизация. Но для удобства всё равно типизируем:
config/mdx/types/get-mdx.type.ts
1import { 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. Вот моя реализация компонентов, можете брать идеи!
config/mdx/mdx-components.tsx
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, но нам же нужна гибкость? Вдруг мы резко захотим использовать MDXRemote не только в layout для MDX файлов, но и на другой странице?
config/mdx/mdx-remote.tsx
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};

Пишем свои rehype плагины

Тут всё легко. Просто копируем и пользуемся. Сейчас расскажу для чего нужны эти плагины и почему мы не используем готовые решения.

rehypeAutoHeading

  • rehypeAutoHeading - плагин добавляет id к h1 - h6 как в
    rehype-slug
    , добавляет якорную ссылку на заголовок как в
    rehype-autolink-headings
    и выводит массив всех заголовков с учётом вложенности (но не более 2-х уровней вложенности) в виде:
1{
2  title: string;
3  href: string;
4  nested?: [
5    {
6      title: string;
7      href: string;
8    }
9  ];
10}[]
Код плагина rehypeAutoHeading:
config/mdx/plugins/rehype-auto-heading.ts
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

  • rehypeExtractCodeProps - плагин для получения всех полей ключ-значение в болоке кода из файла .mdx, также поддерживается boolean тип, если передан только ключ.
Не знаю почему, но готовый плагин
rehype-mdx-code-props
у меня не работал, поэтому написал свой.
config/mdx/plugins/rehype-extract-code-props.ts
1import { 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}

Логика получения проектов

Структура файлов получения проектов:
1entity/project/model
2├── services
3│   ├── get-project.ts   - получение проекта
4│   ├── get-projects-by-category.ts   - получение проектов по категории
5│   └── get-projects.ts   - получение всех категорий и проектов
6└── types
7    └── project.type.ts

Получение проекта

Теперь мы пускаем в ход раннее созданную функцию getMdx() и наслаждаемся лёгкостью получения данных с .mdx файлов. get-project.ts:
entity/project/model/services/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:
entity/project/model/services/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:
entity/project/model/services/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}

Типизация сущности проекта

entity/project/model/types/project.type.ts
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 Router

Структура динамических роутов в /app:
1[lang]   - для интернационализации
2└── projects
3    └── [category]
4        ├── [name]
5        │   └── page.tsx   - layout для mdx файлов проектов
6        └── page.tsx   - страница с проектами из N категории

Обёртка для MDX

Создадим layout для mdx app/[lang]/projects/[category]/[name]/page.tsx:
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:
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}

Заключение

Спасибо, что прочитал до конца. Надеюсь ты нашёл ответы на свои вопросы и смог реализовать то, что планировал!
Если что-то не получается, то просто загляни в репозиторий моего проекта и проанализируй код. Почему у тебя не работает, а у меня работает? Может версии пакетов не те, или просто невнимательность 🤨
UPD: Самую актуальную реализацию mdx в NextJs можно посмотреть у меня тут -
melior-web