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-worldblog/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.csscontent
.
├── copywriter-tool-belt.md
├── dynamic-posts.md
├── hello-world.md
├── prettier-tailwind.md
├── project-launch.md
├── remix-basics.md
├── syntax-highlight.md
└── tailwind-setup.mdW 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
contentistnieje posthello-world.
jeżeli strona działa w trybie produkcyjnym, to po stronie serwera:
-
odpytujemy API GitHuba za pomocą paczki
octokit.jso konkretny post z repozytorium -
jeśli post istnieje, to odczytujemy jego treść
-
treść przepuszczamy przez funkcje
bundleMDXwraz 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 |> htmlW 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 = localFilesHelpersZauważ, ż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 : octokitHelpersTo 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.mdxexitPoniżej znajdziesz więcej postów, które mogą Cię zainteresować.