Wróć

Jak powstał ten blog?

W swoim pierwszym, oficjalnie opublikowanym wpisie na tym blogu, opiszę swoją krótką przygodę z frameworkiem Astro - świetnym narzędziem do generowania statycznych stron internetowych.

Czym jest Astro?

Framework Astro, który całkiem niedawno doczekał się publikacji wersji 5.0, od dłuższego czasu cieszy się niezwykłą popularnością wśród developerów. Jego prostota użycia i elastyczność pozwalają na bardzo szybkie i łatwe tworzenie stron internetowych, które wymagają przede wszystkim lekkości i szybkości działania - strony tworzone z użyciem Astro budowane są statycznie, co oznacza, że na serwer trafiają głównie gotowe pliki HTML, które są następnie przesyłane do przeglądarek użytkowników bezpośrednio, bez potrzeby generowania dynamicznej treści ani po stronie serwera, ani klienta.

Unikamy dzięki temu zbędnej pracy po stronie serwera oraz tworzenia ogromnych plików (tzw. bundle), które przeglądarka musi pobrać, sparsować i obsługiwać w czasie rzeczywistym, jak ma to miejsce w przypadku nowoczesnych aplikacji internetowych SPA. Do przeglądarek użytkowników trafia zatem tylko HTML z treścią oraz niewielki zestaw zoptymalizowanych plików, które pozwalają na interakcję i płynne przejścia pomiędzy stronami.

Oczywiście, jeśli wymaga tego od nas konkretny projekt, możemy zaciągnąć do współpracy z Astro dowolną bibliotekę - Svelte, React, czy Vue. W przypadku niniejszego bloga jednak nie było to konieczne, a zatem to, co widzicie, jest maksymalnie zoptymalizowane pod kątem wydajności i wielkości plików, i właśnie na tym zależało mi przy wyborze narzędzia odpowiedniego do jego stworzenia.

Integracja z CMS - Contentful

Kolejnym, niesamowicie ważnym dla mnie aspektem były koszty. Chciałem mieć dostęp do wygodnego systemu zarządzania treścią, ale takiego, który nie wyszarpie ode mnie choćby grosza za podstawowe działanie, a jednocześnie pozwoli na pisanie treści w dwóch językach, i ostatecznie wybór padł na Contenful. Jego darmowy plan pozwala utworzyć pojedynczą przestrzeń na treści, obsługę maksymalnie dwóch języków oraz limity na 50 GB transferu oraz 100.000 requestów do API miesięcznie. Biorąc pod uwagę, że żądania do API Contentfula będą wykonywane tylko przy przebudowywaniu aplikacji (wskutek aktualizacji kodu lub treści), uznałem te limity za bardziej niż wystarczające.

Po stronie Contentful mam utworzone dwa, bardzo proste modele treści - "Blog Post" oraz "Page Content". Obydwa zawierają tytuł i tzw. slug, czyli znormalizowany ciąg tekstowy, tworzony dynamicznie na podstawie tytułu, po którym można się odwoływać do konkretnego wpisu zamiast np. numeru ID. Dzięki temu w adresie URL, prowadzącym do konkretnego wpisu, widzimy jego tytuł, pisany małymi literami i ze spacjami zastąpionymi myślnikiem.

Screenshot modeli treści Contentful
Screenshot modeli treści Contentful

Następnie, po stronie Astro jestem w stanie wyciągnąć wpisy i treści z Contentfula za pomocą oficjalnej paczki JS contentful. Poniżej prosta konfiguracja, której używam.

import contentful from "contentful";
export const contentfulClient = contentful.createClient({
  space: import.meta.env.CONTENTFUL_SPACE_ID,
  accessToken: import.meta.env.DEV
    ? import.meta.env.CONTENTFUL_PREVIEW_TOKEN
    : import.meta.env.CONTENTFUL_DELIVERY_TOKEN,
  host: import.meta.env.DEV ? "preview.contentful.com" : "cdn.contentful.com",
});

export type ContentfulLocale = "en-US" | "pl";

export const languageToContentfulLocale: Record<Language, ContentfulLocale> = {
  [Language.pl]: "pl",
  [Language.en]: "en-US",
};

Posty i treści z API zaciągane są w postaci tablicy elementów, wyświetlanych kolejno po sobie. Przy pomocy funkcji documentToHtmlString z paczki @contentful/rich-text-html-renderer jestem w stanie przekonwertować te dane na czysty tekst, zawierający kod HTML, i po prostu wyświetlić go na stronie.

Aby ostylować po swojemu każdy z przesłanych przez Contentful elementów, można utworzyć obiekt konfiguracyjny, dzięki któremu documentToHtmlString wyświetli każdy element dokładnie tak, jak chcę.

import {
  documentToHtmlString,
  type Options,
} from "@contentful/rich-text-html-renderer";

const options: Options = {
  renderNode: {
    [BLOCKS.PARAGRAPH]: (node, next) =>
      `<p class="mb-4">${next(node.content)}</p>`,
    [BLOCKS.HEADING_1]: (node, next) =>
      `<h1 class="text-lg md:text-2xl">${next(node.content)}</h1>`,
    [BLOCKS.HEADING_2]: (node, next) =>
      `<h2 class="text-md md:text-lg">${next(node.content)}</h2>`,
    [BLOCKS.HEADING_3]: (node, next) =>
      `<h3 class="text-md md:text-xl">${next(node.content)}</h3>`,
    [BLOCKS.EMBEDDED_ASSET]: (node) => {
      const { title, description, file } = node.data.target.fields;
      return `
        <a
          href="${node.data.target.fields.file.url}"
          class="my-8 block"
          target="_blank"
          rel="noopener noreferrer"
        >
          <figure class="flex flex-col items-center gap-2">
            <img
              class="w-full max-w-[80%] bg-brandGray-200 dark:bg-brandGray-700 rounded-sm lazy-image [&[data-src]]:blur-md"
              width="${file.details.image.width}"
              height="${file.details.image.height}"
              src=""
              data-src="${file.url}"
              alt="${title}"
            />
            ${
              title || description
                ? `<figcaption class="text-center w-full max-w-[80%] flex flex-col gap-1">
              ${title ? `<span class="text-sm">${title}</span>` : ""}
              ${description ? `<span class="text-xs">${description}</span>` : ""}
            </figcaption>`
                : ""
            }
          </figure>
        </a>`;
    },
    [INLINES.HYPERLINK]: (node, next) =>
      `<a href="${node.data.uri}" class="text-primary hover:underline">${next(node.content)}</a>`,
  },
  renderMark: {
    [MARKS.CODE]: (text) => {
      const regex = /^lang:(\w+)/m;
      const match = text.match(regex);

      if (!match) {
        return `<code>${text}</code>`;
      }

      const language = match[1];
      let code = text.replace(regex, "").trim();

      return `<pre><code class="language-${language}">${code}</code></pre>`;
    },
  },
};

const bodyHtmlContent = documentToHtmlString(body, options);

Następnie w kodzie szablonu:

<div
  class="...klasy tailwind..."
  set:html={bodyHtmlContent}
/>

Wyświetlanie i formatowanie bloków kodu - Prism.js

Jako, że ma to być z założenia blog o programowaniu i frontendzie - konieczne było pomyśleć o tym, jak w przystępny sposób przedstawiać czytelnikom kody źródłowe. Niestety, przeglądarki nie potrafią tego robić same z siebie - tagi <code> traktują jako zwykły tekst do wyświetlenia czarno na białym (lub biało na czarnym, zależnie od ustawionych styli), a zatem potrzebna była mi pomoc kolejnej biblioteki zewnętrznej.

Do wykonywania tego zadania znalazłem dwoje potencjalnych kandydatów - Prism.js oraz Highlight.js. Wybór ponownie był stosunkowo prosty - zminifikowany plik JS w przypadku Prism.js, wraz z wymaganymi przeze mnie słownikami, które wybiera się samemu, jest o ok. 75% mniejszy od Highlight.js, a co za tym idzie - przeglądarka ma mniej kodu do pobrania, sparsowania i wykonania, a jak już wiemy, zależy mi tutaj na maksymalnej optymalizacji. Dodatkowo, mogę kiedy chcę zmienić formatowanie kodu w trybie jasnym i ciemnym, po prostu podmieniając odpowiednie pliki CSS.

Optymalizacja obrazków i lazy loading

Największym arch-nemesis szybkiego wczytywania stron są zdecydowanie obrazki. Te mogą osiągać rozmiary w setkach kilobajtów, o ile nie megabajtów, jeśli nie są odpowiednio zoptymalizowane. W tej sekcji przedstawię narzędzia i techniki, których używam na co dzień i w każdym projekcie, w którym biorę udział. Na tym blogu, gdziekolwiek się da, stosuję format WEBP, który oferuje obecnie najlepszy stosunek kompresji obrazu do jego jakości.

Obrazy rastrowe (JPG, PNG, WebP) przepuszczam przed publikacją przez darmowe narzędzia optymalizacyjne - w przypadku mniejszych (do 5 MB) doskonale radzi sobie TinyPNG, w przypadku nieco większych - Squoosh, który umożliwia dostosowanie do własnych potrzeb konfigurację kompresji na dziesiątki różnych sposobów i technik.

Na blogu używam też kilku ikon SVG - te optymalizuję za pomocą SVGO i nieoficjalnej aplikacji internetowej, która zapewnia wygodny w użyciu interfejs użytkownika - SVGO's Missing GUI. W ustawieniach włączam usuwanie absolutnie wszystkiego poza atrybutami xmlns i viewBox. Number Precision i Transform Precision ustawiam na najniższe możliwe wartości, które nie psują wyglądu ikony.

SVGOMG
SVGOMG

Obrazki w postach są ładowane leniwie - dopiero, kiedy czytelnik bloga zescrolluje nieco wyświetlany wpis, a jego obszar czytania zbliży się bardziej do pozycji samego obrazka, zacznie się on wczytywać. Osiągam to za pomocą atrybutu loadingw HTML, dzięki któremu przeglądarka wie, co do których obrazków należy stosować tę zasadę.

Okrutnie tani hosting - Mikr.us

Wspomniałem już, że jestem fanem rozwiązań możliwie bezkosztowych oraz niskokosztowych. Wybranie całkowicie darmowego hostingu byłoby możliwe, ale w większości przypadków wiązałoby się z pewnymi nieprzyjemnymi skutkami ubocznymi: jak np. wyświetlanie nieopcjonalnych reklam, albo comiesięczne ograniczenia w transferze.

Postawiłem więc na Mikr.us - firmę udostępniającą bardzo tanie serwery VPS. Cena za najtańszy serwer z systemem Ubuntu to koszt zaledwie 35 zł rocznie! Zupełnie wystarczy na umieszczenie w sieci podstawowej strony internetowej opartej o pliki statyczne, a przecież taką jest właśnie taki blog, po zbudowaniu wersji produkcyjnej.

Co prawda nie miałem do tej pory większego doświadczenia z konfigurowaniem serwerów poza tymi, których używam, aby odpalać aplikacje lokalnie w trybie deweloperskim, więc stanowiło to idealną okazję, by w końcu trochę douczyć się w temacie. Bardzo pomógł tutaj tutorial konfiguracji Nginx, opublikowany na oficjalnej stronie dystrybucji Ubuntu.

Github Actions i budowanie automatyczne

Jako, że większość rzeczy na VPSach ustawia się za pomocą terminala i połączenia przez SSH, uznałem, że najprotszym i najłatwiejszym sposobem na automatyczne budowanie i publikację nowej wersji bloga po zaktualizowaniu kodu lub treści, będzie skorzystanie z pluginu drone-ssh i ustawienie go w taki sposób, aby wykonał kilka prostych komend na moim VPS.

Na serwerze ustawiłem specjalnego użytkownika, który ma uprawnienia wyłącznie do pracy na folderach związanych z blogiem. Ten użytkownik ma też specjalny klucz SSH, umożliwiający połączenie z moim kontem Github. A zatem, tuż po tym, jak osobiście zrobię git push do maina na Github, albo opublikuję post na Contentfulu, Github Actions odpala akcję z pluginem drone-ssh, a on wykonuje następujące akcje:

  1. Połączenie z serwerem SSH, na wskazane konto użytkownika.

  2. Przejście do folderu z plikami bloga. W nim jest sklonowane jego repozytorium.

  3. drone-ssh wykonuje komendy: git pull, pnpm i, pnpm build.

Poniżej cały kod workflow, który obsługiwany jest przez Github Actions.

name: Deploy blog to VPS

on:
  repository_dispatch:
    types: [publish-event]
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to VPS
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USERNAME }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            export NVM_DIR=~/.nvm
            source ~/.nvm/nvm.sh
            source ~/.bashrc
            cd /var/www/patrykb.pl
            git pull origin main
            pnpm i --reporter=silent
            pnpm build --silent

Jak widać, jest to skrypt niezwykle prosty - a powiedziałbym wręcz, że prostacki; ale działa i spełnia swoje zadanie!

[publish-event] wskazany wyżej związany jest z Contentfulem - po opublikowaniu lub zaktualizowaniu czegokolwiek w treści po stronie CMS, Contentful przez webhook wysyła do Githuba żądanie z wydarzeniem, dzięki któremu Github wie, że właśnie wtedy ma odpalić akcję.

Podsumowanie

Stworzenie tego bloga z pomocą Astro, Contentful i innych narzędzi, z których korzystam na co dzień, było czystą przyjemnością i czuję, że ciężko byłoby wybrać lepszy zestaw do osiągnięcia mojego celu. Łatwość i szybkość tworzenia, satysfakcjonująca wydajność i znikome koszty - jeśli jest to coś, co Ty również sobie cenisz, koniecznie wypróbuj opisane przeze mnie rzeczy.