4/9/2022 ∙ 13 minut czytania

banner

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ę:

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:

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

  1. użytkownik wchodzi na stronę blog/hello-world

jeżeli strona działa w trybie deweloperskim, to po stronie serwera:

  1. sprawdzamy czy na dysku, w katalogu content istnieje post hello-world.

jeżeli strona działa w trybie produkcyjnym, to po stronie serwera:

  1. odpytujemy API GitHuba za pomocą paczki octokit.js o konkretny post z repozytorium

  2. jeśli post istnieje, to odczytujemy jego treść

  3. treść przepuszczamy przez funkcje bundleMDX wraz ze wszystkimi wtyczkami (remark, rehype)

  4. 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ć.