Jak pisałem w poście powitalnym - za mocno kombinuję. Postanowiłem więc, że zacznę z absolutnym minimum, niejako wbrew sobie. Jak zatem zaprojektowałem tę stronę?

Wyznaczone cele

Mówiąc “absolutne minimum” mam tutaj na myśli:

  • użycie generatora statycznych treści, by móc pracować z Gitem, a nie CMSem opartym o interfejs www i bazę danych
  • #continuous deployment, by w sposób zautomatyzowany treści trafiały na stronę dokładnie wtedy, gdy będę chciał
  • minimalny, choć schludny i funkcjonalny interfejs użytkownika
  • wielojęzyczność (#i18n) zarówno od strony kodu/treści, jak i adresów URL
  • dostęp do treści z użyciem protokołu HTTPS
  • brak kosztów, jakie by trzeba ponieść (ani jednorazowo, ani cyklicznie)

Podjęte decyzje

Mając na uwadze tendencję do przekombinowania, nie chciałem poświęcać dużo czasu na rozeznanie w kwestii potencjalnych rozwiązań. Zdałem się na to, co już znałem oraz to, co pojawiło się po drodze na etapie implementacji, o czym za chwilę.

Gitlab

Do przechowywania kodu wybrałem Gitlab z paru powodów:

  • używam go od lat w pracy, znam jego możliwości dużo lepiej niż Github
  • umożliwia zagnieżdżanie grup projektowych, co osobiście mocno sobie cenię w kwestii organizacji kodu

Cloudflare

Początkowo chciałem użyć Cloudflare jedynie do zarządzania DNS i do zapewnienia certyfikatów SSL, ale po wydelegowaniu domeny na ich serwery nazw natrafiłem na sekcję Pages i postanowiłem zbadać temat. Nie znałem wcześniej tej funkcji, choć premierę miała rok temu. Po wstępnym rozeznaniu się postanowiłem z niej skorzystać.

Hugo - Static Site Generator

Hugo to generator statycznych stron, który jest niebywale elastyczny, rozszerzalny i oczywiście wydajny. Nie używałem go wcześniej, ale widziałem jego użycie na wielu stronach technicznych i wydał mi się odpowiedni, by oprzeć o niego zarządzanie treścią.

Plan działania

Mając wybrane elementy układanki, wykonałem rekonesans jak je wszystkie ze sobą posklejać. Plan zakładał utworzenie Cloudflare Pages, skierowanie na nie własnych domen oraz automatyczny deploy stron wygenerowanych z użyciem Hugo poprzez Gitlab CI.

graph LR; A[Tworzenie treści] -->|git push| B[Gitlab Pipeline] B --> C[Cloudflare Pages PL] B --> D[Cloudflare Pages EN]

Brzmi prosto, ale jak się miało później okazać — wystąpiły komplikacje 😅

Szkielet strony

Trzeba było oczywiście zacząć od stworzenia projektu w Gitlabie i postawienia bazowej architektury Hugo. Sposobów instalacji jest dużo, osobiście skorzystałem z brew install hugo. Po stworzeniu szkieletu z użyciem hugo new site mogłem przystąpić do szlifowania według swojego widzimisię.

Wybór skórki

Postawiłem na Minima, bo chciałem skupić się na treści, a nie na fajerwerkach. Dodatkowo lista dostępnych funkcjonalności odpowiadała moim potrzebom.

Instalacja skórki odbywa się za pomocą submodułów Git:

git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/paper-mod

Do repozytorium commitujemy wtedy jedynie .gitmodules oraz wpis odpowiadający katalogowi, w którym submoduł został zainstalowany (w zasadzie jest to wskaźnik na konkretny commit z danego repo, dzięki czemu później można odtworzyć stan wykonując git submodule init).

Żeby skórka faktycznie została użyta, trzeba oczywiście skonfigurować Hugo, ustawiając theme: paper-mod w config/_default/config.yml

Wsparcie dla i18n

Jako że posiadam 2 domeny, chciałem z tego skorzystać i oddzielić wersje językowe domeną, a nie infiksem w URLu. Dlatego idąc za dokumentacją wydzieliłem osobne katalogi content/en oraz content/pl oraz stworzyłem odpowiednią konfigurację:

# config/_default/languages.yml

pl:
  contentDir: content/pl
  baseURL: https://blog.codito.pl/
  languageName: ':poland:'
  languageCode: pl
  paginatePath: strona
  title: Codito.pl
  weight: 1
  taxonomies:
    category: kategorie
    tag: tagi
    series: serie
  params:
    # Must be set under languages.xx.params since Hugo 0.112 (instead of languages.xx)
    # see: https://gohugo.io/content-management/multilingual/#changes-in-hugo-01120
    languageAltTitle: Polski

en:
  contentDir: content/en
  baseURL: https://blog.codito.dev/
  languageName: ':gb:'
  languageCode: en
  title: Codito.dev
  weight: 2
  taxonomies:
    category: categories
    tag: tags
    series: series
  params:
    # Must be set under languages.xx.params since Hugo 0.112 (instead of languages.xx)
    # see: https://gohugo.io/content-management/multilingual/#changes-in-hugo-01120
    languageAltTitle: English

Nie jest to kompletna konfiguracja (pominąłem definicję menu), ale pokazuje koncept — chciałem mieć nie tylko możliwość publikowania w dwóch językach, ale też pełne wsparcie językowe w adresach URL (stąd oddzielne definicje taksonomii).

Kolejną istotną funkcjonalnością, jaką chciałem zagwarantować, to możliwość przechodzenia między wersjami językowymi konkretnych stron. W wybranej przeze mnie skórce nie było to dostępne z automatu, musiałem zatem nadpisać layout header.html, w czym pomógł mi ten wątek społeczności Hugo. Aby jednak takie przechodzenie między wersjami językowymi było możliwe trzeba te wersje ze sobą powiązać — robi się to za pomocą translationKey definiowanym we front matter strony:

---
title: "Hello World!"
translationKey: "2022-04-10-hello-world"
date: 2022-04-10T01:04:46+02:00
---

Dzięki temu możliwe jest odnalezienie identyfikatora odpowiednika danej strony w innych językach i wygenerowanie linku do niego.

System komentarzy

Skórka Minima, którą zastosowałem, wspiera Disqus oraz Utteranc.es. Jako osoba komentująca miałem okazję korzystać z obu systemów, każdy z nich ma wady i zalety. Natomiast mając na uwadze, że strona ma zawierać treści techniczne, adresowane głównie do osób IT, postawiłem na system oparty o Github, czyli utteranc.es 🙂

Konfiguracja integracji jest bardzo prosta, nie będę jej zatem opisywał. Jest natomiast jeden detal, na który chciałem zwrócić uwagę — trwałe powiązanie stron z komentarzami. Jako że adresy URL wpisów mogą ulegać zmianie, a do tego wymagane jest wsparcie wielojęzyczności, ponownie postawiłem na translationKey. Dzięki temu bez względu na to, która wersja językowa postu jest wyświetlana i komentowana, wszystko trafia do tego samego issue na GitHubie. Okaże się w praniu, czy to dobra decyzja, natomiast osiągnąłem to nadpisując kolejny layout:

<!-- layouts/partials/utterances.html (for Minima theme) -->

<script type="text/javascript">
  const repo = '{{ .Site.Params.utterances.repo }}'
  const issueTerm = '{{ if .Params.translationKey }}{{ .Params.translationKey }}{{ else }}{{ .Site.Params.utterances.issueTerm }}{{ end }}'
  const theme = localStorage.theme ? `github-${localStorage.theme}` : 'preferred-color-scheme';

  const script = document.createElement('script')
  script.src = 'https://utteranc.es/client.js'
  script.async = true
  script.crossOrigin = 'anonymous'

  script.setAttribute('repo', repo)
  script.setAttribute('issue-term', issueTerm)
  script.setAttribute('theme', theme)
  script.setAttribute('label', 'comment')

  document.querySelector('main').appendChild(script)
</script>

Podział na sekcje

Kolejnym drobnym szlifem było rozróżnienie stron od postów. Te pierwsze chciałem mieć dostępne pod maksymalnie uproszczonymi linkami, a te drugie automatycznie ustrukturyzowane z podziałem według dat publikacji.

permalinks:
  posts: '/:year/:month/:slug/'
  pages: '/:slug/'

Taka definicja permalinków sprawia, że niezależnie jak sobie poukładam posty wewnątrz content/<language>/posts to i tak finalnie będą dostępne pod odpowiednim adresem.

Warto również pomyśleć o ustawieniu removePathAccents: true aby polskojęzyczne URLe zostały pozbawione znaków diaktrycznych.

Automatyzacja deploymentu

Jak wspomniałem na początku, strona jest serwowana poprzez Cloudflare Pages. Konfiguracja jest dobrze udokumentowana, a proces oraz interfejs intuicyjne. Po kolei:

  • wiążemy konto Gitlab / Github z Cloudflare
  • wybieramy repozytorium, z którego zbudowana zostanie strona
  • ustawiamy nazwę projektu Cloudflare Pages (będzie się później wyświetlał na liście serwowanych stron oraz posłuży do stworzenia subdomeny *.pages.dev)
  • konfigurujemy deployment
    • wybieramy gałąź, z której będą wykonywane produkcyjne wdrożenia
    • ustawiamy komendę budowania i katalog, którego zawartość ma być serwowana
    • ustawiamy zmienną środowiskową HUGO_VERSION, najlepiej na najnowszą (koniecznie tą samą, której używamy lokalnie do developmentu)

Formularz ma również opcję wyboru ustawień z szablonu, więc wystarczy wybrać Hugo. W moim przypadku jednak dostosowałem ustawienia do konfiguracji z podziałem na języki, dlatego jako “Build output directory” ustawiłem public/pl. Przydatne może być też ustawienie hugo --verbose --log --verboseLog --debug jako build command, wtedy logi z procesu budowania zawierają więcej informacji.

Po zatwierdzeniu konfiguracji rozpoczyna się próbny deployment, a jeśli wszystko poszło dobrze, nasza strona jest dostępna pod dedykowaną subdomeną. Warto oczywiście skonfigurować własną domenę, zwłaszcza że bez dodatkowych kosztów i w prosty sposób otrzymamy certyfikat SSL.

Na tym etapie każdy git push do wskazanej gałęzi będzie skutkował wyzwoleniem procesu budowania i wdrażania strony na Cloudflare Pages. Dzieje się tak, ponieważ Cloudflare w momencie skonfigurowania projektu dodaje webhook do repozytorium i dostaje powiadomienia o aktywnościach.

No dobra, mamy wdrożoną wersję PL, czas skonfigurować EN. A zatem ponownie klikamy “Create a project”, wybieramy repozytorium i… dostajemy informację, że repozytorium już jest użyte 😩

Napotkane problemy wyzwania

Cloudflare Pages 1:1 Projekt Gitlab

Okazuje się, że Cloudflare Pages może użyć repozytorium Github / Gitlab tylko raz, w ramach jednego projektu. Więc owszem, można serwować wersje wielojęzyczne, ale tylko w formie segmentu w URL, czyli https://example.com/pl/. Chcąc podejść do tematu tak jak ja, czyli serwując wersję językowe pod różnymi domenami, trzeba się posiłkować dodatkowymi mechanizmami.

Pamiętacie, co pisałem o nadmiernym kombinowaniu? Naprawdę chciałem uniknąć przestoju, który byłby spowodowany poszukiwaniem dostępnych rozwiązań, dlatego bardzo szybko zrobiłem coś, co przyszło mi do głowy — skorzystałem z Gitlab Pages do serwowania anglojęzycznej wersji strony 😅

Oczywiście nie chciałem modyfikować istniejącego projektu Hugo i rozbijać go na 2 bliźniacze projekty, dlatego podszedłem do tematu sprytnie i w .gitlab-ci.yml głównego projektu dodałem wywołanie pipeline z meta-repozytorium:

# Trigger build for Gitlab Pages (english version of the site)
gitlab-pages:
  stage: Deploy
  trigger:
    project: codito-net/codito-net.gitlab.io
    branch: main
    strategy: depend
  rules:
    - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH

Konwencja domen w Gitlab Pages jest dość klarowna, w dodatku chcąc przykryć je własną domeną nie musimy się bardzo przejmować strukturą projektów. Natomiast ja dla porządku utworzyłem projekt codito-net/codito-net.gitlab.io dzięki czemu techniczna domena Gitlaba wciąż jest przyjazna.

Ten projekt zawiera jedynie definicję Gitlab CI:

image: registry.gitlab.com/pages/hugo/hugo_extended:0.96.0

variables:
  GIT_SUBMODULE_STRATEGY: recursive
  HUGO_ENV: production

pages:
  script:
    - apk add --update --no-cache git go
    - git clone --depth 1 --shallow-submodules https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/codito-pl/landing-page.git
    - cd landing-page
    - git submodule update --init --recursive
    - hugo
    # Workaround for: https://gitlab.com/gitlab-org/gitlab-pages/-/issues/668
    - cd ..
    - mkdir public
    - cp -R landing-page/public/en/* public
  artifacts:
    paths:
      - public
  rules:
    # See: https://docs.gitlab.com/ee/ci/triggers/index.html#configure-cicd-jobs-to-run-in-triggered-pipelines
    - if: $CI_PIPELINE_SOURCE == "pipeline"

Finalnie proces continuous deployment w Gitlab CI wygląda następująco:

Gitlab Pipeline

Podsumowując:

  • Zadanie gitlab-pages to trigger w głównym projekcie, wyzwalający zagnieżdżony pipeline w meta-projekcie. Istotne jest ustawienie strategy: depend aby to zadanie otrzymywało swój status dopiero po zakończeniu zagnieżdżonego pipeline’u
  • Zadanie Cloudflare Pages na etapie External to automatyczny build wywołany webhookiem, który wykonuje deploy polskojęzycznej wersji strony na Cloudflare Pages
  • Zadanie pages na etapie Build w pipeline Downstream buduje anglojęzyczną wersję strony i zapisuje wynik jako artefakt, który jest później automatycznie wdrażany na Gitlab Pages.

Gitlab Pages tylko z artefaktem public

Uważne oko mogło zauważyć, że w zadaniu pages użyty został workaround 😉 Niestety na tym etapie prac naciąłem się zarówno na problem konwencji (sam początkowo użyłem innej nazwy zadania), jak i na wymóg tworzenia artefaktu z katalogu public. Problem jest zasygnalizowany i zapewne na jakimś etapie zostanie wyeliminowany, a publikacja Gitlab Pages będzie możliwa z dowolnego, wskazanego folderu.

Cloudflare Preview Environment baseUrl

Kolejnym wyzwaniem okazało się takie skonfigurowanie Cloudflare Pages, by środowiska preview (wystawiane z nieprodukcyjnych gałęzi) miały prawidłowy baseUrl. O ile produkcyjne adresy są zaszyte w konfiguracji, o tyle środowiska preview są wystawiane pod losowymi subdomenami. Losowości nie da się obejść, ale można ją pominąć, wykorzystując aliasy środowisk. Alias tworzony jest dla każdej gałęzi, ale żeby móc zbudować stabilny proces musimy zdecydować na jedną gałąź, która będzie służyła do rozwoju naszej strony. W moim przypadku jest to gałąź develop, a więc środowisko preview jest dostępne pod adresem develop.codito-pl.pages.dev.

Z pomocą przychodzi “Access policy”, dostępne w ustawieniach Cloudflare Pages:

Cloudflare Pages --> Settings --> General --> Access policy
Cloudflare Zero Trust --> Access --> Applications --> Edit

Mając testowe środowisko dostępne pod stałym adresem, możemy usprawnić proces budowania tak, by adres środowiska preview był używany jako baseUrl. Tu również miałem zagwozdkę, bo dokumentacja Hugo jest w tym względzie nieprecyzyjna. Próbując znaleźć rozwiązanie trafiłem na to zgłoszenie, a także stworzyłem temat na stronie społeczności. Ostatecznie udało się skonfigurować bazowy adres ustawiając zmienną środowiskową HUGO_LANGUAGES_pl_baseurl, oczywiście dla środowisk preview:

Cloudflare Pages --> Settings --> Environment Variables

Finalny proces

Po zmierzeniu się z tymi wszystkimi wyzwaniami i podjęciu odpowiednich działań otrzymałem następujący proces:

graph LR; A[Tworzenie treści] -->|git push main| B[Gitlab Pipeline] A[Tworzenie treści] -->|git push develop| G[Gitlab Pipeline] B --> C[Cloudflare External Job] B --> D[Trigger External Pipeline] C -->|Deploy PL| E[Cloudflare Pages] D -->|Deploy EN| F[Gitlab Pages] G --> H[Cloudflare External Job] H -->|Deploy Preview PL| I[Cloudflare Pages]

Nie jest on może idealny, ale robi dokładnie to, co ma robić. Moim celem było bezkosztowe hostowanie dwóch wersji językowych strony ze wsparciem dla HTTPS oraz automatyczny deployment i to udało się uzyskać.

Na ten moment minusy są następujące:

  • nie ma środowiska testowego dla anglojęzycznej wersji strony
  • nie ma cyklicznego deploymentu, przez co nie można planować publikacji z publishDate w przyszłości (strony budują się tylko po pushu, zatem gdy wrzucimy do repo stronę z datą publikacji w przyszłości nie zostanie ona opublikowana aż do momentu kolejnego git push, który nastąpi po tej dacie)

Nie od razu Rzym zbudowano 😉