Logo NFU StudioLogo medium icon
wróć do listy
Kodowe

Contentlayer z Next'em

Next.js jest absolutym 🐐 jeśli chodzi o statyczne treści. Wymieszajmy to z wygodą tworzenia ustrukturyzowanych danych i Markdown'em - wyjdzie nam coś niesamowicie skutecznego. Sprawdzimy jak i po co używać tego zestawu przy użyciu biblioteki Contentlayer.
7 minut czytania

INFO: Contentlayer nie jest już aktywnie rozwijany.

Słowo wstępu

W NFU zawsze stawialiśmy na rozwiązania skuteczne i dopasowane do naszych możliwości. Kiedyś zdarzało się, że machina Wordpressa "zjadała" nasze rozwiązania, wygodą i nieznośną popularnością bytu ©. Bacznie rozglądając się na rynku - wyróżniamy dwa podejścia, które pozwalają uzyskać elastyczność treści na stronie, a jednocześnie nie wymagają serwera PHP.

  • systemy headless CMS ("bezgłowy system zarządzania treścią") - coś takiego jak panel admina Wordpressa, ale bez "fasady". CMS udostępnia wtedy tylko sposób, żeby dane z niego wyciągnąć, a prezentacja pozostaje w naszych rękach.
  • tworzenie treści w kodzie - czyli już na poziomie strony my jako tworzący dodajemy pliki, które podczas np. budowania projektu stworzą nam strukturę treści.

Oba rozwiązania pomimo tego, że efekt osiągają taki sam, są jak łyżka i widelec. Nie możemy udostępnić klientowi kodu do edycji, bo prawdopodobnie się przestraszy. Nikt nie musi umieć programować i formatować treści samymi znakami w tekście - zwykliśmy raczej do Worda i "bogatych" edytorów tekstu. Więc klientom dajemy CMSa. Z kolei my techniczni, stroniący od myszki i wychodzenia z terminala - nie wychodzimy z terminala!

Contentlayer - SDK do contentu

Contentlayer - jak na stronie określili - zestaw do tworzenia treści. Biblioteka pozwala nam:

  • utworzyć "interfejs"/umowę jak mają być treści strukturyzowane,
  • umieszczać i kontrolować obok kodu,
  • generować użyteczne w kodzie gotowe dane.

Next.js + Contentlayer = 🚜

Zróbmy coś konkretnego.

Instalacja

Zaczniemy od nowego projektu z Next'em.

bunx create-next-app@latest

Wszystkie opcje zostawiamy domyślne. Zainstalujmy contentlayer'a:

bun add contentlayer

Ja używam bun, ale nic nie stoi na przeszkodzie, żeby wstawić tu npm czy yarn.

W bazowym template od Next'a dostajemy tailwind'a i nie będziemy dodawać niczego bardziej ambitnego.

Konfiguracja Contentlayer'a

Na początku musimy utworzyć plik contentlayer.config.ts

touch contentlayer.config.ts

Ten plik to źródło naszej prawdy dot. contentlayera. Szczegóły i wszystkie opcje dostępne są w dokumentacji. My postaramy się wspomnieć o tym co najważniejsze.

Plik contentlayer.config.ts musi eksportować wynik funkcji makeSource, którą importujemy z biblioteki.

import { makeSource } from 'contentlayer/source-files'

export default makeSource({})

W najprostszej wersji musimy dodać w parametrze makeSource dwa pola:

  • contentDirPath - czyli adres folderu z zawartością strony
  • documentTypes - tablicę z naszymi typami dokumentów.

Typy dokumentów to już konwencja contentlayera - odnosi się ona do konkretnych i określonych za pomocą funkcji defineDocumentType naszych "dokumentów", a ogólniej mówiąc treści. To za pomocą tej funkcji tworzymy "umowę" z biblioteką - czego i gdzie ma się spodziewać, żeby mogła działać.


Dodajmy więc nasz typ dokumentu - zwykły post na bloga.

import { makeSource, defineDocumentType } from 'contentlayer/source-files'

const BlogPost = defineDocumentType(() => ({
  name: "BlogPost",            // nazwa do rozróżniania
  filePathPattern: "**/*.mdx", // glob do wyszukiwania i łapania plików
  contentType: "mdx",          // typ danych w plikach
  fields: {
    title: {
      type: "string",
      required: true
    },
    date: {
      type: "date",
      required: true
    }
   },
   computedFields: {
    url: {
      type: 'string',
      resolve: (doc) => `posts/${doc._raw.flattenedPath}`
    }
   }
  }))

export default makeSource({
  contentDirPath: "posts",
  documentTypes: [BlogPost]
})

Zadziało się, ale to nic czego nie ma w dokumentacji. Podaliśmy wymagane pola przez funkcję defineDocumentType:

  • name - nazwę dokumentu,
  • filePathPattern - ścieżkę (tutaj globa) w której biblioteka ma szukać plików z postami,
  • contentType - typ danych w plikach (co to MDX to temat na innego posta)
  • fields - konfigurację konkretnych pól, które muszą się pojawić, żeby contentlayer zrobił z nich posta.
  • computedFields - również konfiguracja pól, z tą różnicą, że te pola będą automatycznie tworzone przez contentlayera.

Teraz jak spróbujemy uruchomić nasz projekt - to nie powinno się nic stać! Dopiero zadeklarowaliśmy w jaki sposób nasze dane będą wyglądać. Musimy jeszcze je utworzyć, a potem wyświetlić... Oczywiście też, nie damy rady od tak po prostu - przecież planujemy tworzyć je w MDX, a on wymaga przetworzenia pliku.

Zacznijmy od stworzenia folderu posts zgodnie z configiem.

mkdir posts

Utwórzmy dwa posty w tym folderze:

cd posts
touch testowy-post.mdx
touch testowy-post-inny.mdx

Teraz musimy dodać treść. WAŻNE - treść musi być zgodna z "umową". Nasze pola dodatkowe poza treścią, opisane polem fields będą dodawane tak jak frontmatterem.

posts/testowy-post.mdx
---
title: Post testowy
date: 2024-02-29
---
**Treść** MDX'a sobie zostawimy for (tzw.) fun!
posts/testowy-post-inny.mdx
---
title: Post testowy inny
date: 2024-02-29
---
**Nie będziemy się hamować z testowaniem!**

Skoro posty mamy gotowe musimy teraz uruchomić contentlayera, żeby wygenerował nam dane = po to jest 🔥 Zakładając, że korzystamy z Next'a - a do niego jest contentlayer musimy w next.config.js albo next.config.mjs dodać plugin contentlayera - wtedy będzie on działał razem z serverem nexta. Osobiście przenoszę co mogę na moduły więc my skonfigurujemy next.config.mjs używając też hack'a z shadcn-ui - bo contentlayer nie był dawno aktualizowany i jest w CJS na ten moment.

Zainstalujmy plugin do Next'a:

bun add next-contentlayer

Mój projekt na wersji Next'a 14.1.0 utworzył się z next.config.mjs więc musimy tylko go zmienić:

import { createContentlayerPlugin } from 'next-contentlayer' 

const withContentLayer = createContentlayerPlugin({})

/** @type {import('next').NextConfig} */
const nextConfig = {};

// moglibyśmy skorzystać z jakiejś funkcji `pipe` gdybyśmy mieli dużo pluginów
// teraz zostaniemy przy takiej wersji

export default withContentLayer(nextConfig);

Po tym wszystkim powinniśmy móc zobaczyć, że contentlayer działa - uruchommy serwer dev'a:

bun dev

W konsoli jeśli wszystko się udało - po webpackowym i nextowym spamie - powinien być też log z contentlayera

Generated 2 documents in .contentlayer


Dla naszej wygody dodajmy też alias, żeby móc łatwo dostać się do wygenerowanych przez contentlayera danych:

tsconfig.json
{
  "compilerOptions": {
    // (...) pomijamy dla czytelności
    "baseUrl": ".", // <---
    "paths": {
      "@/*": ["./*"],
      "contentlayer/generated": ["./.contentlayer/generated"] // <---
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

WAŻNE Ostatnią rzeczą będzie zgodnie z dokumentacją dodanie folderu .contentlayer do .gitignore.

Pokażmy nasze treści

Teraz już tylko fajerwerki. Zróbmy tak, że na stronie głównej pokażemy posty i zrobimy podstronę dla każdego posta. Nie będziemy też przesadnie komplikować kodu - robimy prosty przykład.

Zmieńmy najpierw stronę główną:

app/page.tsx
import { allBlogPosts, BlogPost } from "contentlayer/generated";
import Link from "next/link";

const PostCard = ({ blogPost }: { blogPost: BlogPost }) => {
  return (
    <div className="bg-gray-950 relative p-4 rounded-lg flex">
      <div>
        <h3 className="font-semibold text-xl">{blogPost.title}</h3>
        <p className="text-gray-400">
          {new Date(blogPost.date).toLocaleDateString()}
        </p>
      </div>
      <Link href={blogPost.url} className="absolute size-full"></Link>
    </div>
  );
};

export default function Home() {
  return (
      <section>
        <h2 className="font-bold text-3xl border-b">Posty</h2>
        <div className="grid lg:grid-cols-2 my-4 gap-4">
          {allBlogPosts.map((post) => (
            <PostCard blogPost={post} key={post._id} />
          ))}
        </div>
      </section>
  );
}

Jedyne ciekawostki to importy z contentlayer/generated mamy tam:

  • typ: BlogPost - z definicją Typescriptowego typu
  • allBlogPosts - cała kolekcja naszych postów w postaci tablicy (dokładnie to BlogPost[])

Zostaje nam tylko stworzenie podstrony z wpisem. Będziemy korzystać z generowanego pola _raw_flattenedPath, które posłuży nam z "slug".

Stwórzmy więc naszą podstronę:

cd app
mkdir posts
mkdir [slug]
touch page.tsx
app/posts/[slug]/page.tsx
import { allBlogPosts } from "@/.contentlayer/generated";
import { getMDXComponent } from "next-contentlayer/hooks";
import { notFound } from "next/navigation";

interface PostPageProps {
  params: { slug: string };
}
const PostPage = ({ params: { slug } }: PostPageProps) => {
  const post = allBlogPosts.find((post) => post._raw.flattenedPath === slug);
  if (!post) return notFound();
  const Content = getMDXComponent(post.body.code);
  return (
    <article>
      <header>
        <h1>{post.title}</h1>
        <time dateTime={post.date}>
          {new Date(post.date).toLocaleDateString()}
        </time>
      </header>
      <Content />
    </article>
  );
};
export default PostPage;

Żeby poflexować się layoutami przeniosłem kawałek kodu do głównego layoutu:

app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <main className="min-h-dvh container mx-auto">
          <header className="min-h-32 flex items-center justify-center">
            <h1 className="font-bold text-6xl">Contentlayer z Nextem</h1>
          </header>
          {children}
        </main>
      </body>
    </html>
  );
}

Wrap-up

Postawiliśmy projekt, z bardzo bazową strukturą i całym content-layerem. Trzebaby tu jeszcze dodać sporo rzeczy, nawet z samego przykładowego projektu jak generateStaticParams, ale to zostawiam ambitnym czytelnikom.

Główny projekt przykładowy od contentlayera: LINK

Kod do tego projektu znajduje się pod LINK - wybaczcie pushowanie w maina.

Na dzień 29-02-2024 widziałem na githubie komentarz twórcy, że może biblioteka będzie przez kogoś przejmowana!

jeszcze jeden i starczy...

Design

Polecane i przydatne pluginy do Figmy

Zobacz przydatne pluginy, które ulepszają pracę w Figmie
Przeczytaj post - Polecane i przydatne pluginy do Figmy

Kodowe

Kompozycja Reactowa czyli elastyczność ponad przewidywalność.

Dlaczego do znudzenia warto utrwalać wzorzec kompozycji w React. Dlaczego biblioteki takie jak Shadcn rządzą na polu frontendowych rozwiązań.
Przeczytaj post - Kompozycja Reactowa czyli elastyczność ponad przewidywalność.

Projekty

Projekt strony internetowej - ważniejszy niż implementacja?

Po co jest faza projektowa strony internetowej i co powinno zostać ustalone przed rozpoczęciem implementacji. Jak projektować żeby to miało ręce i nogi a do tego przynosiło firmie zyski.
Przeczytaj post - Projekt strony internetowej - ważniejszy niż implementacja?