4/9/2022 ∙ 13 minut czytania
Dynamiczna treść ⚡️
Pierwsza zasada Remix'aa brzmi:
Embrace the server/client model, including separation of source code from content/data.
W obecnej formie, w kodzie strony mieszają sie odpowiedzialności i kolejne blog
posty mieszają sie z kodem źródłowym. Pliki z rozszerzeniem .md
sa budowane
razem z pozostałymi plikami. Każdy kolejny post wydłuża proces budowania, a żeby
poprawić nawet drobna literówkę, trzeba przebudować kod całej strony i zrobić
jeszcze raz wystawić na produkcje. Przy 5 tekstach nie stanowi to większego
problemu, ale przy większej ilości, może już znacząco wydłużyć czas całego
procesu budowania.
Powinno to wyglądać nieco inaczej. Treść powinna byc zapisana niezależnie, w jakimś miejscu w sieci, a sama strona powinna pobierać ją dynamicznie i serwować użytkownikowi strony.
Na ten moment każdy artykuł ma swoja własną stronę:
blog/hello-world
blog/remix-basics
...
Jeśli użytkownik wejdzie na posta, który nie istnieje (przykładowo blog/x
), to
wejdzie na stronę 404
.
Remix ładuje pliki md lub mdx jako część routing'u. Każdy z tych plików jest traktowany przez Remix'a jako osobna strona co jak juz wcześniej wspomniałem wydłuża czas kompilacji.
Pliki md / mdx mogą byc również importowane jako zwykły komponent do innego pliku / byc współdzielone pomiędzy wieloma innymi modułami:
import Component, { attributes, filename } from './hello-world.mdx'
Remix korzysta z biblioteki frontmatter
, dzięki której do każdego pliku md /
mdx możemy dorzucić dodatkowe metadane na samym początku pliku korzystając z
formatowania YAML:
---
meta:
title: 'Hello World!'
description: 'Some description goes here'
headers:
Cache-Control: no-cache
---
# Hello World!
To co możemy zrobić, to przenieść nasze posty w inne miejsce (do bazy danych / repozytorium plików) i dynamicznie je ładować bez konieczności budowania każdego posta.
Przepisywanie postów statycznych na posty dynamiczne
Żeby oddzielić treść od kodu źródłowego strony musimy pomyśleć o kilku ważnych rzeczach.
Gdzie chcemy trzymać treść?
Jest wiele miejsc w których możemy trzymać nasze posty. Może to byc baza danych, jednak trzeba by było dokonać kilku modyfikacji i przede wszystkim wrzucić je do bazy. Ponadto, każdorazowa zmiana wiązałaby sie z połączeniem z bazą. Treść można tez wrzucać po prostu do... repozytorium na GitHubie i pobierać ja dynamicznie w zależności od potrzeby.
W jaki sposób chcemy ładować treść dynamicznie?
GitHub udostępnia REST-owe / GraphQL-owe API, dzięki któremu możliwe jest pobranie treści bezpośrednio z repozytorium. Dodatkowo, istnieje paczka napisana w JavaScript, która integruje się z tymi API. Paczka nazywa sie octokit.js i dzięki niej praca z API GitHuba jest dużo bardziej przyjemna.
Jest jednak jeden drobny mankament tego rozwiązania. Pisanie postów w trybie lokalnym / bez połączenia z internetem będzie w takim przypadku bez sensu, ponieważ za każdym razem trzeba będzie wypchnąć zmiany w artykule do repozytorium na GitHubie, żeby zobaczyć efekt na stronie. W przypadku braku połączenia - nie będziemy mieli kompletnie do nich dostępu. W związku z tym, w trybie deweloperskim, można skorzystać z systemu plików i odczytać treść bezpośrednio z dysku (wszystko oczywiście po stronie serwera).
Jakie zmiany trzeba wykonać w Remix'ie, żeby treść strony ładowała sie dynamicznie?
Przede wszystkim, trzeba oddzielić treść od kodu źródłowego. W tym momencie cala
aplikacja żyje w zasadzie w katalogu app
, a wraz z nia posty które pisze.
Wszystkie posty znajdują sie w katalogu app/routes/blog
. W pierwszym kroku
należy wynieść je do innego katalogu np content
, który zostanie utworzony w
głównym katalogu repozytorium. Struktura katalogów powinna po tej modyfikacji
wyglądać tak:
app
.
├── entry.client.tsx
├── entry.server.tsx
├── root.tsx
├── routes
│ ├── blog
│ ├── blog.tsx
│ └── index.tsx
└── styles
├── app.css
└── code.css
content
.
├── copywriter-tool-belt.md
├── dynamic-posts.md
├── hello-world.md
├── prettier-tailwind.md
├── project-launch.md
├── remix-basics.md
├── syntax-highlight.md
└── tailwind-setup.md
W drugim kroku należy napisać kod, który obsłuży obie strategie pobierania postów:
- odczytywanie postów z dysku za pomocą paczki Node'a do obsługi systemu plików
- pobieranie postów za pomocą
octokit.js
Należy jednak pamiętać, że pobrana zawartość danego pliku to po prostu tekst, zapisany w formacie Markdown, którego przeglądarka nie obsługuje.
Poprzednio budowaniem postów zajmował sie Remix, ale robił to w trakcie budowania całej aplikacji. Wersja produkcyjna strony jest wystawiana w zbudowanej formie i nie da sie jej przebudować dynamicznie przy ładowaniu strony. Remix po prostu tak nie działa. Jak poradzić sobie z tym problemem?
Paczka mdx-bundler
Jest to genialne rozwiązanie problemu dynamicznego ładowania treści w formacie
.md
oraz .mdx
. Paczka ta składa się w zasadzie z jednej funkcji bundleMDX
,
która to pod spodem korzysta z ultra szybkiego bundler'a
esbuild. Paczka ta w pełni integruje sie z
narzędziami frontmatter
, remark
, rehype
, więc w trakcie dynamicznego
bundlowania, można również dorzucić wtyczki, które w locie zamienią naszego
Markdowna na piękny HTML.
Koncepcja całości
W dynamicznej wersji powinno to wyglądać tak
- użytkownik wchodzi na stronę
blog/hello-world
jeżeli strona działa w trybie deweloperskim, to po stronie serwera:
- sprawdzamy czy na dysku, w katalogu
content
istnieje posthello-world
.
jeżeli strona działa w trybie produkcyjnym, to po stronie serwera:
-
odpytujemy API GitHuba za pomocą paczki
octokit.js
o konkretny post z repozytorium -
jeśli post istnieje, to odczytujemy jego treść
-
treść przepuszczamy przez funkcje
bundleMDX
wraz ze wszystkimi wtyczkami (remark
,rehype
) -
Remix zwraca do użytkownika dynamicznie wygenerowany post. 🎉
Remix - kod działający po stronie serwera
To co zasługuje na szczególną uwagę to fakt, ze Remix decyduje za nas, kiedy
wykonać dany fragment kodu po stronie serwera, a kiedy po stronie klienta.
Oczywiście można zgłębić się w szczegóły i doprecyzować jak to właściwie sie
dzieje, ale jest to temat na osobny post. Na ten moment przyjmijmy, ze każdy z
piłków w katalogu app
może uruchamiać sie zarówno po stronie serwera w
środowisku Node, jak i klienta w środowisku przeglądarki.
Odczyt treści postów za pomocą systemu plików może odbywać sie tylko po stronie
serwera. Jak wiec Remix radzi sobie z tym problemem? Okazuje sie, że jest to
bardzo proste. Wystarczy, że nasz plik będzie miał końcówkę: .server.(js|ts)
.
Dzięki temu możemy być pewni, że kod zostanie wykonany tylko po stronie serwera.
Odczyt treści lokalnie
Napiszmy kod, który pobierze treść postów z katalogu content
.
W tym celu utworzymy plik app/utils/files.server.ts
:
import fs from 'fs/promises'
import path from 'path'
const rootPath = process.cwd()
const blogPath = path.join(rootPath, 'content')
export const localFilesHelpers = {
downloadFile: async (fileName: string) => {
const source = await fs.readFile(path.join(blogPath, fileName), 'utf-8')
return source
},
downloadDirectoryList: async (
dirPath: string = 'content'
): Promise<string[]> => {
const files = await fs.readdir(path.join(rootPath, dirPath), {
encoding: 'utf-8',
withFileTypes: false,
})
return files.filter(file => file.endsWith('.md') || file.endsWith('.mdx'))
},
}
Powyższy kod definiuje obiekt z dwiema funkcjami pomocniczymi, dzięki którym łatwiejsze będzie pobieranie treści z wybranego przez nas katalogu.
Dzięki funkcji downloadDirectoryList
możliwe jest pobranie zawartości plików,
które znajdują się w katalogu contents
, ale zawierającymi rozszerzenia .md
lub .mdx
.
Natomiast funkcja downloadFile
zajmuje się odczytem zawartości danego pliku.
Przygotowanie listy postów do wyświetlenia
Utwórzmy plik app/utils/blog.server.ts
i napiszmy funkcję, która zajmie się
przygotowaniem postów do wyświetlenia.
import { localFilesHelpers } from './files.server'
import { bundleMDX } from 'mdx-bundler'
const helpers = localFilesHelpers
type BlogFrontmatter = {
title: string
createdAt: string
}
export const getBlogPosts = async () => {
const files = await helpers.downloadDirectoryList('content')
const posts = await Promise.all(
files.map(async fileName => {
const source = await helpers.downloadFile(fileName)
const { frontmatter } = await bundleMDX<BlogFrontmatter>({ source })
const { title, createdAt } = frontmatter
return {
slug: fileName.replace(/\.mdx?$/, ''),
title,
createdAt: new Date(createdAt),
}
})
)
const sortedPosts = posts.sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
)
return sortedPosts
}
Funkcja getBlogPosts
w pierwszej kolejności wykorzystuje napisany wcześniej
helper do pobrania zawartości katalogu content
, a następnie pobiera każdy plik
i przygotowuje obiekty do wyświetlenia na liście postów.
Wykorzystujemy tutaj funkcję bundleMDX
, której głównym zadaniem jest
parsowanie MDXa i tworzenie kodu HTML gotowego do wyświetlenia na stronie, ale o
tym nieco później. Tutaj funkcja bundleMDX jest nam potrzebna do wyciągnięcia
obiektu frontmatter
, czyli zapisu na samej górze każdego posta:
---
title: 'Dynamiczna tresc ⚡️'
createdAt: '2022-04-09'
---
W kolejnym kroku tworzymy slug
dla każdego posta, czyli nazwę, która będzie
wyświetlać się w pasku URL w przeglądarce. Przykładowo: blog/dynamic-posts
.
Żeby to zrobić, musimy z nazwy odczytanych plików usunąć jego rozszerzenie.
W ostatnim etapie sortujemy posty zgodnie z kolejnością ich utworzenia.
Przygotowanie treści pojedynczego posta.
W pliku app/utils/blog.server.ts
dopiszmy jeszcze jedną funkcję, która zajmie
się odczytem treści pojedynczego posta:
import { bundleMDX } from 'mdx-bundler'
import type { Options as ExternalLinksOptions } from 'rehype-external-links'
import rehypePrettyCode from 'rehype-pretty-code'
import type { Options as PrettyCodeOptions } from 'rehype-pretty-code'
const rehypePrettyCodeOptions: Partial<PrettyCodeOptions> = {
theme: 'one-dark-pro',
}
const rehypeExternalLinksOptions: Partial<ExternalLinksOptions> = {
target: '_blank',
}
export const getBlogPost = async (slug: string) => {
const fileName = `${slug}.mdx`
const source = await helpers.downloadFile(fileName)
const { default: rehypeExternalLinks } = await import('rehype-external-links')
const { default: remarkGfm } = await import('remark-gfm')
const { code, frontmatter } = await bundleMDX<BlogFrontmatter>({
source,
mdxOptions(options) {
options.rehypePlugins = [
...(options.rehypePlugins ?? []),
[rehypePrettyCode, rehypePrettyCodeOptions],
[rehypeExternalLinks, rehypeExternalLinksOptions],
]
options.remarkPlugins = [...(options.remarkPlugins ?? []), [remarkGfm]]
return options
},
})
return {
code,
frontmatter,
}
}
Każdy link do posta na stronie będzie zawierał slug
, który utworzyliśmy w
poprzedniej funkcji. Żeby wiedzieć który post chcemy pobrać bez pobierania listy
postów musimy znowu dodać rozszerzenie .mdx
.
Po pobraniu zawartości danego posta (w formacie .mdx
) należy ją przygotować do
wyświetlenia na stronie. W tym celu znowu używamy funkcji bundleMDX
. Jednak
tym razem chcemy odpowiednie przygotować treść do wyświetlenia.
W poprzednim wpisie opisywałem w jaki sposób zachodzi transformacja pliku MDX na kod wynikowy HTML. Dla przypomnienia:
markdown |> remark |> remark-rehype |> rehype |> html
W pierwszej kolejności dorzucamy wtyczkę
remark-gfm
dzięki której sprawimy,
że nasze posty będą obsługiwały składnię markdown w stylu Github'a.
Następnie, post przejdzie przez przez wtyczkę remark-rehype (zamieniając
markdown'a na html'a). Dzieje się to automatycznie w paczce mdx-bundler
.
Kolejnym krokiem będzie nałożenie na htmla dodatkowych funkcjonalności. W
pierwszej kolejności chcemy zmodyfikować zachowanie przeglądarki po wciśnięciu w
każdego zewnętrznego linka występującego na stronie -
rehype-external-links
oraz dodać funkcjonalność upiększania kodu -
rehype-pretty-code
.
Z posta zwracamy code
oraz obiekt frontmatter
Renderowanie listy postów na stronie.
Mamy już przygotowane funkcje do pobierania listy postów oraz treści pojedynczego posta.
W pierwszej kolejności wykorzystamy funkcję getBlogPosts
do pobrania listy
postów w funkcji loader
w pliku app/routes/index.tsx
:
type Posts = Awaited<ReturnType<typeof getBlogPosts>>
type LoaderData = {
posts: Posts
}
export const loader: LoaderFunction = async () => {
const posts = await getBlogPosts()
return json<LoaderData>({
posts,
})
}
Abstrakcja którą utworzyliśmy w pliku app/utils/blog.server.ts
sprawia, że
kodu potrzebnego do przygotowania postów do wyświetlenia jest niewiele.
W następnym kroku wystarczy wykonać mapowanie na liście postów i wyświetlić je na stronie:
<motion.ul variants={variants.list} initial="hidden" animate="visible">
{posts.map(post => (
<motion.li
key={post.slug}
variants={variants.itemHorizontal}
className="peer group -mx-3 rounded-md text-lg hover:bg-slate-900 focus:bg-slate-900"
>
<Link
to={`/blog/${post.slug}`}
prefetch="intent"
className="flex flex-row items-center justify-between p-3 transition duration-150 group-hover:text-indigo-400 peer-focus:text-indigo-400"
>
{post.title}
<span className="font-mono text-base opacity-50 transition-opacity group-hover:opacity-100 peer-focus:opacity-100">
{format(new Date(post.createdAt), 'do LLL', {
locale: pl,
})}
</span>
</Link>
</motion.li>
))}
</motion.ul>
Lista postów zrobiona. Przejdźmy teraz do funkcjonalności zajmującej się wyświetlaniem pojedynczego posta.
Renderowanie treści posta
Pierwszym krokiem będzie pobranie posta za pomocą funkcji getBlogPost
w
funkcji loader
w pliku app/routes/blog/$slug.tsx
:
type Post = Awaited<ReturnType<typeof getBlogPost>>
type LoaderData = {
post: Post
}
export const loader: LoaderFunction = async ({ params }) => {
const { slug } = params
invariant(slug, 'Slug is required')
const post = await getBlogPost(slug)
return json<LoaderData>({ post })
}
Ta funkcja również jest prosta. Jedyna funkcjonalność, którą tutaj dodajemy to
walidacja parametrów, a konkretnie parametru slug
.
Ostatnim krokiem będzie wyświetlenie posta na stronie:
import { getMDXComponent } from 'mdx-bundler/client'
import { useMemo } from 'react'
const Article = () => {
const { post } = useLoaderData<LoaderData>()
const { code, frontmatter } = post
const Component = useMemo(() => getMDXComponent(code), [code])
return (
<motion.main
className="prose prose-slate prose-indigo dark:prose-invert md:prose-base lg:prose-lg z-0 my-8 !max-w-full"
variants={variants.present}
initial={'hidden'}
animate={'visible'}
>
<span className="font-mono text-base opacity-50 transition-opacity group-hover:opacity-100 peer-focus:opacity-100">
{format(new Date(post.createdAt), 'do LLL', {
locale: pl,
})}
</span>
<Component />
</motion.main>
)
}
Najważniejsza tutaj jest funkcja getMDXComponent
, która na podstawie kodu
przygotowanego dzięki funkcji bundleMDX
zwraca komponent który możemy
wyrenderować na stronie.
Pobieranie postów z Github'a
Mamy już napisane funkcjonalności pobierania listy postów oraz treści pojedynczego posta, ale wszystko działa tylko lokalnie. Ostatnim elementem naszego zadania będzie pobieranie postów z Github'a, kiedy strona działa na środowisku produkcyjnym. Jak to zrobić?
W pliku app/utils/blog.server.ts
znajduje się kod, który ma nas przygotować na
tę modyfikację:
const helpers = localFilesHelpers
Zauważ, że funkcje getBlogPosts
oraz getBlogPost
nie odwołują się
bezpośrednio do zmiennej localFilesHelpers
. Jest to celowy zabieg i zaraz
wszystko stanie się jasne.
W pierwszym kroku utwórzmy plik app/utils/octokit.server.ts
i wrzućmy do niego
następującą zawartość:
import { throttling } from '@octokit/plugin-throttling'
import { Octokit as createOctokit } from 'octokit'
import invariant from 'tiny-invariant'
const Octokit = createOctokit.plugin(throttling)
const octokit = new Octokit({
auth: 'auth_key_here',
})
Żeby pobrać pliki z Github'a za pomocą protokołu HTTP, wykorzystamy paczkę
Octokit
. Jej dokumentacja dokładnie opisuje skąd pobrać klucz do autoryzacji.
Następnie stworzymy funkcję do pobierania zawartości danego katalogu:
export const downloadDirectoryList = async (
path: string = 'content'
): Promise<string[]> => {
const { data } = await octokit.rest.repos.getContent({
owner: 'owner_name_here',
repo: 'repository_name_here',
path,
})
if (!Array.isArray(data)) {
return []
}
return data
.filter(item => item.type === 'file')
.filter(item => item.name.endsWith('.mdx'))
.map(item => item.name)
}
Wykorzystujemy tutaj metodę octokit.rest.repos.getContent
, dzięki której
możemy pobrać zawartość katalogu z danego repozytorium. Jeżeli pobrane dane są
tablicą oznacza to, że mamy do czynienia z katalogiem. W przeciwnym wypadku nie
ma sensu otwierać jego zawartości. Na samym końcu wybieramy tylko te pliki,
które nie są katalogami i są w formacie .mdx
.
Należy zwrócić uwagę na to, że interfejsy funkcji downloadDirectoryList
są
takie same zarówno w pliku: app/utils/files.server.ts
oraz
app/utils/octokit.server.ts
. To bardzo ważne!
Drugą funkcją o takim samym interfejsie będzie funkcja downloadFile
export const downloadFile = async (fileName: string) => {
const { data } = (await octokit.rest.repos.getContent({
owner: 'owner_name_here',
repo: 'repository_name_here',
path: `content/${fileName}`,
})) as { data: { content: string; encoding: string } }
invariant(data.encoding === 'base64', 'encoding is not base64')
invariant(data.content, 'content is not defined')
const { encoding, content } = data
return Buffer.from(content, encoding).toString()
}
Tutaj wykorzystujemy funkcję octokit.rest.repos.getContent
aby pobrać
zawartość danego pliku. Dekodujemy treść i zwracamy ją w postaci string'a.
W ostatnim kroku tworzymy obiekt pomocniczy i eksportujemy go:
export const octokitHelpers = {
downloadFile,
downloadDirectoryList,
}
Zmiana strategii pobierania plików w zależności od zmiennych środowiskowych
Wróćmy do pliku app/utils/blog.server.ts
gdzie wykonamy ostatnią drobną
zmianę:
const helpers =
process.env.NODE_ENV === 'development' ? localFilesHelpers : octokitHelpers
To tyle. Od teraz, w zależności od tego w jakim środowisku działa serwer, nasze posty będą ładować się z systemu plików albo bezpośrednio z repozytorium na Github'ie.
Od tego momentu nasza treść żyje niezależnie od kodu strony, a drobne literówki
nie będą już wymagały przebudowania strony na nowo. Tworzenie, modyfikacja i
usuwanie postów odbywać się będzie w obrębie katalogu content
w którym to będą
znajdować się pliki .mdx
:
├── hello-world.mdx
├── infoshare-f3.mdx
├── prettier-tailwind.mdx
├── project-launch.mdx
├── reading-time-and-progress.mdx
├── remix-basics.mdx
├── syntax-highlight.mdx
└── tailwind-setup.mdx
exit
Poniżej znajdziesz więcej postów, które mogą Cię zainteresować.