Решил сделать небольшой проект для статей. Для разработки я выбрал Next.js, создал структуру проекта и пошел думать над тем, как мне будет проще и удобнее публиковать и редактировать статьи.
Начал искать подходящую headless CMS. Первое, что выдает поисковик и что у многих на слуху, — это Strapi. Попробовав ее в их тестовой среде, понял, что это мощный инструмент, подходящий для более крупных проектов, где важно уметь управлять всем контентом, однако для небольшого блога с фокусом на статьях Strapi кажется избыточным.
В итоге я продолжил искать и наткнулся на новую, более легковесную CMS — Outstatic, разработанную для управления контентом с использованием Markdown.
О ней и пойдет речь в этой статье.

Outstatic

— это CMS с открытым исходным кодом, которая хранит все ваши данные в виде markdown файлов прямо в проекте. Поэтому она не требует базы данных, не зависит от среды развертывания и не нуждается в отдельной системе контроля версий, что очень удобно.

Быстрый старт в 4 шага

1) GitHub OAuth

Для того, чтобы Outstatic могла автоматически коммитить все изменения в статьях, нужно подключить к ней авторизацию в GitHub.

  • После регистрации сгенерировал новый Client secret и записал его в заметки, он понадобится в env переменных

Обратите внимание, что Github не дает прописать сразу несколько путей для аутентификации, поэтому, для приложения развернутого на сервере, понадобится настроить еще один OAuth app.

2) Устанавливаю Outstatic в проект

npm i outstatic

3) Cоздаю два файла в проекте

/app/outstatic/[[...ost]]/page.tsx

import 'outstatic/outstatic.css';
import { Outstatic } from 'outstatic';
import { OstClient } from 'outstatic/client';

export default async function Page({ params }: { params: { ost: string[] } }) {
  const ostData = await Outstatic();
  return <OstClient ostData={ostData} params={params} />;
}

/app/api/outstatic/[[...ost]]/route.ts

import { OutstaticApi } from 'outstatic';

export const GET = OutstaticApi.GET;

export const POST = OutstaticApi.POST;

4) Остается прописать .env переменные

OST_GITHUB_ID=GITHUB_OAUTH_APP_ID
OST_GITHUB_SECRET=GITHUB_OAUTH_APP_SECRETN
OST_REPO_SLUG=GITHUB_REPOSITORY_SLUG
 
# OPTIONAL
OST_REPO_BRANCH=YOUR_GITHUB_REPOSITORY_BRANCH

Вот и все, теперь можно запускать приложение

Пишем первую статью

Переходим на https://localhost:3000/outstatic и авторизуемся через github.

Можно писать свою первую статью (подробнее как это делать тут)

После сохранения статьи она автоматически попадает в репозиторий, но не обновляет файлы локально, поэтому не забывайте пулить изменения, чтобы подтянулись все обновленные markdown файлы.

Отображение

Теперь стоит задача отобразить список постов и конкретный пост по его id.
Для начала давайте напишем функцию, которая трансформирует наш markdown в html, чтобы отобразить его на странице. Для этого будем использовать unified и remark-parse

Установим все необходимые зависимости
npm i unified remark-parse remark-gfm remark-rehype rehype-sanitize rehype-stringify

Создадим файл
lib/markdownToHtml.ts

import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeSanitize from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';

export default async function markdownToHtml(markdown: string) {
  const result = await unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype)
    .use(rehypeSanitize)
    .use(rehypeStringify)
    .process(markdown);
  return result.toString();
}

Получение данных
Напишем функцию, которая получит отсортированный список постов по категории, и функцию для получения контента поста. Поле категории я добавил при создании поста в Outstatic (тут об этом подробно написано)

/lib/posts.ts

import { getDocuments, getDocumentBySlug } from 'outstatic/server';
import markdownToHtml from './markdownToHtml';

export function getSortedPostsData({ category }: { category: string; }) {
  const allPostsData = getDocuments(category, [
    'title',
    'status',
    'author',
    'slug',
    'description',
    'coverImage',
    'publishedAt',
    'price',
    'category',
    'tags',
  ]);

  return allPostsData.sort((a, b) => {
    if (a.publishedAt < b.publishedAt) {
      return 1;
    } else {
      return -1;
    }
  });
}

export async function getPostContent({ category, slug }: { category: string; slug: string }) {
  const post = getDocumentBySlug(category, slug, [
    'title',
    'status',
    'author',
    'slug',
    'description',
    'coverImage',
    'publishedAt',
    'content',
    'category',
  ]);

  if (!post) return null;

  const content = await markdownToHtml(post.content);

  return {
    ...post,
    content,
  };
}

Также Outstatic поддерживает простые запросы на выборку данных (подробнее тут)

Создадим директорию в /app, в которой будут отображаться все наши посты.

/app/posts/page.tsx

import { getSortedPostsData } from '@/lib/posts';
import Image from 'next/image';
import Link from 'next/link';

export default async function Page() {
  const allPostsData = getSortedPostsData({
    category: 'tilda-widgets',
  });

  return (
    <div>
      {allPostsData.map((post) => (
        <Link key={post.slug} href={`/${post.category}/${post.slug}`}>
          <Image width={200} height={200} src={post.coverImage} alt={post.title} />
        </Link>
      ))}
    </div>
  );
}

/app/posts/[id]/page.tsx

import { getPostContent } from '@/lib/posts';
import Image from 'next/image';

export default async function Page({ params }: { params: { id: string } }) {
  const postInfo = await getPostContent({ category: 'tilda-widgets', slug: params.id });

  if (!postInfo) return <p>Такого поста нет</p>;

  return (
    <div>
      <h1>{postInfo.title}</h1>
      <Image src={postInfo.coverImage} alt={postInfo.title} width={0} height={0} sizes='100vw' />
      <div dangerouslySetInnerHTML={{ __html: postInfo.content ?? '' }}></div>
    </div>
  );
}

После перезапуска приложения, мы должны увидеть список постов, и при переходе по элементам сам контент поста.

Обратите внимание, что стиль в редакторе и в самом посте будут отличаться, поэтому нужно поработать со стилями. Для отображения блока с кодом можете использовать prismjs.

Вот и все, минимальный функционал для нашего блога готов.

Минусы

После использования Outstatic из минусов могу выделить следующее:

  • Невозможно прикрепить несколько изображений к заголовку

  • Нет автосохранения контента и в целом не всегда уверен, что изменения улетели в гит, поэтому приходится перепроверять перед закрытием

  • Нет возможности редактировать сам markdown напрямую из редактора

Но несмотря на все эти минусы, считаю, что это очень легковесный и удобный продукт для небольших блогов.

Спасибо за внимание

Комментарии (2)


  1. denisemenov
    10.11.2024 05:34

    и в целом не всегда уверен, что изменения улетели в гит, поэтому приходится перепроверять перед закрытием

    Мне кажется, если ты не уверен, что данные сохранились, то такаую CMS лучше избегать.

    Мне недавно тоже надо было выбрать headless CMS и мне Outstatic не подошла не из-за проблем с сохранением, но из-за отсутствия какой-либо поддержки i18n и сложности в её реализации в будущем по версии автора. Нашёл более гибкую self-hosted open source альтернативу - Payload CMS.


  1. elroy
    10.11.2024 05:34

    Недавно использовал Sanity, очень хороший функционал и возможности для расширения. Настроить и добавить в проект легко с помощью Claude.