Архитектура модуля markdown для Jamstack на Next.js
Начало туториала здесь.
Добавление поддержки Markdown в проект на Next.js — это не только возможность генерировать страницы из файлов, но и создание удобной структуры кода. В этом туториале мы разберём, как организовать удобный модуль обработки Markdown-файлов в проекте.
Шаг 4 — добавляем поддержку Markdown
Нам надо считать сами файлы и контент в них, а потом преобразовать markdown в то, что отобразится на сайте.
Я предлагаю написать сначала интерфейс для всего процесса. Потом реализацию. Давайте забегу вперёд и покажу всю файловую структуру:
# 📁 Структура проекта: модуль `markdown`
── posts/ # (Внешняя папка с markdown-контентом , на уровне с app, в корне проекта) — Markdown-файлы постов
── app/
└── lib/
└── markdown/
├── index.ts # Фасад: экспорт getPosts() и getPostBySlug()
├── init.ts # Инициализация зависимостей (DI точка входа)
│
├── interfaces/ # Абстракции (интерфейсы)
│ ├── file-reader.interface.ts # Интерфейс FileReader
│ └── markdown-processor.interface.ts # Интерфейс MarkdownProcessor
│
├── services/ # Реализации интерфейсов
│ ├── local-file-reader.service.ts # Чтение .md/.mdx файлов из папок
│ └── remark-markdown-processor.service.ts # Обработка markdown в HTML
│
├── repositories/ # Репозитории данных
│ └── post.repository.ts # Класс PostRepository: логика загрузки и обработки постов
│
└── types/ # Типы и модели
├── post.type.ts # Тип Post (slug, title, content)
└── theme.type.ts # Enum для тем оформления markdown (например, ROSE_PINE_DAWN)
Интерфейсы
// Интерфейс для чтения файлов
// file-reader.interface.ts
export interface FileReader {
getAllFiles(dirPath: string, arrayOfFiles?: string[]): string[];
readFile(fullPath: string): Promise<string>;
}
// Интерфейс для обработки markdown
// markdown-processor.interface.ts
export interface MarkdownProcessor {
convertToHtml(markdown: string): Promise<string>;
}
Как я говорил, здесь требуется немного больше знаний, чем у начинающих. Но я попробую всё объяснить:
Я постарался в меру своих знаний и своего опыта сделать код читаемым и поддерживаемым, потому я использовал некоторые паттерны проектирования:
✅ 1. Dependency Inversion Principle (из SOLID)
- Код зависит не от конкретных реализаций, а от абстракций (
FileReader
,MarkdownProcessor
). - Это позволяет внедрять зависимости через конструктор и легко подменять их, например, в тестах или при замене логики.
Реализовано через:
export interface FileReader
export interface MarkdownProcessor
✅ 2. Strategy Pattern
- Поведение (например, как читать файлы или как обрабатывать markdown) выносится в отдельные стратегии.
- Можно легко менять реализацию без изменения используемого в других функциях кода: например,
LocalFileReader
,RemoteFileReader
,RemarkMarkdownProcessor
,CustomMarkdownProcessor
.
📌 Каждая реализация — это стратегия.
✅ 3. Interface Segregation Principle (из SOLID)
- Интерфейсы узкие, одна ответственность:
FileReader
— только за чтение файлов,MarkdownProcessor
— только за обработку markdown. - Это делает их легко (в идеале) заменяемыми и поддерживаемыми.
🧩 В связке с остальной архитектурой (например, PostRepository
), эти интерфейсы также участвуют в паттерне "Repository" и поддерживают Dependency Injection.
Сервисы
// local-file-reader.service.ts
// Чтение файлов из папок
import fs from "fs";
import path from "path";
import { FileReader } from "@/app/lib/markdown/interfaces/file-reader.interface";
export class LocalFileReader implements FileReader {
getAllFiles(dirPath: string, arrayOfFiles: string[] = []): string[] {
const files = fs.readdirSync(dirPath);
files.forEach((file) => {
const fullPath = path.join(dirPath, file);
if (fs.statSync(fullPath).isDirectory()) {
// Рекурсивный обход поддиректорий
arrayOfFiles = this.getAllFiles(fullPath, arrayOfFiles);
} else {
// Добавляем только MD и MDX файлы
if (file.endsWith(".md") || file.endsWith(".mdx")) {
arrayOfFiles.push(fullPath);
}
}
});
return arrayOfFiles;
}
async readFile(fullPath: string): Promise<string> {
return fs.promises.readFile(fullPath, "utf8");
}
}
Здесь мы рекурсивно вычитываем файлы из директорий. И в своё время я например спрашивал зачем так долго меня учили асинхронному чтению, если здесь почти все используют синхронные операции?
Ответ простой. Сборка сайта синхронно или асинхронно незначительно влияет на скорость, синхронный код читаем чуть лучше, и нам важнее, что б не было ошибок. Так что выбор метода не критичен и вы можете заменить на асинхронный подход. И вообще всё написано модулями, по отдельности можно, да и нужно улучшать код, если это необходимо.
А вот получение файл по урлу или slug уже имеет смысл делать асинхронно.
// remark-markdown-processor.service.ts
// Обработка markdown в HTML
// Внимание! этот файл будет дополнятся!
import { remark } from "remark";
import { MarkdownProcessor } from "@/app/lib/markdown/interfaces/markdown-processor.interface";
export class RemarkMarkdownProcessor implements MarkdownProcessor {
async convertToHtml(markdown: string): Promise<string> {
const result = await remark().process(markdown);
return result.toString();
}
}
И здесь у нас первая реализация интерфейса MarkdownProcessor
. Мы используем библиотеку remark
для обработки markdown. Она позволяет нам легко преобразовать markdown в HTML. Давайте её установим:
npm i remark --save-dev
Забегая вперёд укажу, что в данном гайде для всего процесса преобразования нам ещё понадобятся: gray-matter, rehype-pretty-code, rehype-stringify, remark-gfm, remark-rehype, shiki.
Вот так мы можем их все сразу установить:
npm install gray-matter remark remark-gfm remark-rehype rehype-stringify rehype-pretty-code shiki --save-dev
Все эти библиотеки работают при сборке сайта на сервере, потому могут быть dev-зависимостями.
Типизация
Здесь всё просто. Наш итоговый пост будет состоять из урла, заголовка и самого контента. Можно добавить дату, но пока это не обязательно.
// post.type.ts
export type Post = {
slug: string;
title: string;
content: string;
};
Репозиторий
// post.repository.ts
import path from "path";
import matter from "gray-matter";
import { MarkdownProcessor } from "@/app/lib/markdown/interfaces/markdown-processor.interface";
import { FileReader } from "@/app/lib/markdown/interfaces/file-reader.interface";
import { Post } from "@/app/lib/markdown/types/post.type";
export class PostRepository {
private fileReader: FileReader;
private markdownProcessor: MarkdownProcessor;
private readonly postsDirectory: string;
constructor(
fileReader: FileReader,
markdownProcessor: MarkdownProcessor,
postsDirectory: string,
) {
this.fileReader = fileReader;
this.markdownProcessor = markdownProcessor;
this.postsDirectory = postsDirectory;
}
// Метод для генерации slug из пути к файлу
private generateSlug(fullPath: string): string {
const relativePath = path.relative(this.postsDirectory, fullPath);
return relativePath.replace(/\.(md|mdx)$/, "").replace(/\//g, "--");
}
// Метод для извлечения данных из файла поста
private async processPostFile(fullPath: string): Promise<Post> {
const fileContents = await this.fileReader.readFile(fullPath);
const { data, content } = matter(fileContents);
const htmlContent = await this.markdownProcessor.convertToHtml(content);
return {
slug: this.generateSlug(fullPath),
title: data.title || path.basename(fullPath, path.extname(fullPath)),
content: htmlContent,
};
}
async getPosts(): Promise<Post[]> {
try {
const allFiles = this.fileReader.getAllFiles(this.postsDirectory);
const posts = await Promise.all(
allFiles.map((file) => this.processPostFile(file)),
);
return posts;
} catch (error) {
console.error("Ошибка при чтении постов:", error);
return [];
}
}
async getPostBySlug(slug: string): Promise<Post | null> {
try {
const allFiles = this.fileReader.getAllFiles(this.postsDirectory);
const matchingFile = allFiles.find(
(file) => this.generateSlug(file) === slug,
);
if (!matchingFile) {
console.log(`Пост не найден: ${slug}`);
return null;
}
return this.processPostFile(matchingFile);
} catch (error) {
console.error(`Ошибка при чтении поста ${slug}:`, error);
return null;
}
}
}
Класс PostRepository инкапсулирует всю логику получения и подготовки постов:
- Считывает все .md/.mdx файлы через FileReader
- Извлекает title и content через gray-matter
- Конвертирует Markdown в HTML через MarkdownProcessor
- Генерирует slug на основе пути
Над этим классом можно поработать и вынести например генерацию slug. Но это считайте ваше домашнее задание.
Инициализация
import path from "path";
import { PostRepository } from "@/app/lib/markdown/repositories/post.repository";
import { LocalFileReader } from "@/app/lib/markdown/services/local-file-reader.service";
import { RemarkMarkdownProcessor } from "@/app/lib/markdown/services/remark-markdown-processor.service";
const postsDirectory = path.join(process.cwd(), "posts");
const fileReader = new LocalFileReader();
const markdownProcessor = new RemarkMarkdownProcessor();
export const postRepository = new PostRepository(
fileReader,
markdownProcessor,
postsDirectory,
);
Здесь мы всё инициализируем и получаем посты. Именно здесь мы и передаём название папки, где находится контент. Инициализируем зависимости и передаём путь к папке с контентом. Вся логика получения и обработки постов инкапсулирована в классе PostRepository
. А дальше...
Фасад
// index.ts
import { Post } from "@/app/lib/markdown/types/post.type";
import { postRepository } from "@/app/lib/markdown/init";
export async function getPosts(): Promise<Post[]> {
return postRepository.getPosts();
}
export async function getPostBySlug(slug: string): Promise<Post | null> {
return postRepository.getPostBySlug(slug);
}
Через index.ts
мы предоставляем удобный API для остальной части проекта. Такой подход называется паттерн Фасад — он позволяет изолировать сложную внутреннюю реализацию и дать удобную точку входа. Внешний код не знает ничего о MarkdownProcessor
, FileReader
или PostRepository
— он просто вызывает getPosts()
.
Можем собирать приложение. И перейдём к следующему шагу.