Design
Contentlayer z Next'em
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ć tunpm
czyyarn
.
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.
---
title: Post testowy
date: 2024-02-29
---
**Treść** MDX'a sobie zostawimy for (tzw.) fun!
---
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:
{
"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ą:
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
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:
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...
Kodowe
Kompozycja Reactowa czyli elastyczność ponad przewidywalność.
Projekty