FastAPI + HTMX: Full-stack bez kompilacji
# FastAPI + HTMX: Full-stack bez kompilacji
TL;DR: FastAPI + HTMX + Alpine.js + Bootstrap 5 + Jinja2 + czysty CSS pozwala tworzyć produkcyjne aplikacje webowe bez narzędzi budowania, bez
node_modules/i z idealnymi wynikami Lighthouse. Niniejszy przewodnik obejmuje cały system — od architektury po wdrożenie — wykorzystując blakecrosley.com jako produkcyjny przykład obsługujący 37 wpisów blogowych, 20 interaktywnych komponentów JavaScript, 20 przewodników i tłumaczenia na dziesięć języków — wszystko bez bundlera, kompilatora czy transpilera.1
Współczesny stos technologii webowych zakłada, że potrzebny jest React, webpack, TypeScript i pipeline budowania. Dla szerokiej kategorii aplikacji — stron opartych na treści, narzędzi wewnętrznych, aplikacji CRUD, stron portfolio, platform dokumentacyjnych — to założenie jest błędne. Stos opisany w tym przewodniku eliminuje całkowicie frontend-owy łańcuch narzędzi budowania, jednocześnie generując strony z wynikiem 100/100/100/100 w Lighthouse.2
To nie jest agitacja. To pomiar. Opisana tu architektura działa w produkcji, obsługuje rzeczywistych użytkowników w dziesięciu językach, a wyniki można zweryfikować.
Kluczowe wnioski
- Renderowany po stronie serwera HTML eliminuje trzy całe kategorie problemów: zarządzanie stanem po stronie klienta, granice serializacji JSON oraz niezgodności hydratacji. HTMX sprawia, że odpowiedzi serwera stanowią finalny wynik — bez etapu renderowania po stronie klienta.
- Brak narzędzi budowania oznacza brak błędów budowania. Żadnych konfliktów zależności
npm install, żadnych błędów kompilatora TypeScript w plikach, których nie modyfikowano, żadnych PR-ów Dependabota dla tranzytywnych zależności, których nigdy nie importowano. Pipeline wdrożeniowy togit push. - Alpine.js obsługuje stan istniejący wyłącznie po stronie klienta, którego HTMX nie jest w stanie obsłużyć. Rozwijane menu, modale, przełączniki nawigacji mobilnej i każdy stan UI istniejący wyłącznie w przeglądarce należą do Alpine.js. Granica jest jasna: jeśli stan wymaga serwera, należy użyć HTMX. Jeśli nie — Alpine.js.
- Czysty CSS z właściwościami niestandardowymi zastępuje Sass i Tailwind. Właściwości niestandardowe CSS kaskadują, dziedziczą i reagują na media queries w czasie wykonania. Zmienne preprocesorów kompilują się do wartości statycznych i znikają. Przeglądarka odczytuje właściwości niestandardowe bezpośrednio — bez etapu kompilacji.
- To podejście ma jasno określone granice. Nie sprawdzi się w dużych zespołach współdzielących interfejsy komponentów, produktach SaaS ze złożonym stanem po stronie klienta ani w aplikacjach zależnych od bibliotek ekosystemu npm. Framework decyzyjny w sekcji 15 precyzyjnie identyfikuje tę granicę.
- blakecrosley.com jest dowodem. Każdy wzorzec opisany w tym przewodniku działa w produkcji. Każde twierdzenie ma ścieżkę do pliku, blok konfiguracyjny lub audyt Lighthouse, które można samodzielnie zweryfikować na PageSpeed Insights.2
Jak korzystać z tego przewodnika
To kompleksowe kompendium. Warto zacząć od miejsca odpowiadającego poziomowi doświadczenia:
| Doświadczenie | Punkt wyjścia | Następne kroki |
|---|---|---|
| Programista Python, nowy w HTMX | Teza No-Build → Przegląd architektury → HTMX — szczegółowe omówienie | Wzorce Alpine.js, Bezpieczeństwo |
| Programista React/Vue oceniający alternatywy | Teza No-Build → Framework decyzyjny | Przegląd architektury, Wydajność |
| Programista FastAPI dodający interaktywność | HTMX — szczegółowe omówienie → Wzorce Alpine.js | i18n i lokalizacja, Wdrażanie |
| Programista full-stack budujący od podstaw | Czytaj sekwencyjnie od Przeglądu architektury | Karta szybkiego odniesienia do bieżącego użytku |
Ctrl+F / Cmd+F umożliwia wyszukiwanie konkretnych wzorców lub atrybutów. Karta szybkiego odniesienia na końcu zawiera zwięzłe podsumowanie.
Teza No-Build
Teza jest wąska i precyzyjna: w przypadku stron zorientowanych na treść, tworzonych przez jednego programistę lub mały zespół, narzędzia budowania rozwiązują problemy, których nie masz, jednocześnie tworząc problemy, które masz.
Oto rzeczywiste metryki z blakecrosley.com:
| Metryka | blakecrosley.com (No-Build) | Typowy projekt Next.js3 |
|---|---|---|
| Zależności | 15 pakietów Python | 311+ pakietów npm |
| Pliki konfiguracji budowania | 0 | 5-8 (next.config, tsconfig, postcss, tailwind, itp.) |
Rozmiar node_modules/ |
Nie istnieje | 187 MB bazowo, 250-400 MB z dodatkami |
| Czas instalacji | pip install: 8 sekund |
npm install: 30-90 sekund |
| Etap budowania | Brak | next build: 15-60 sekund |
| Pipeline wdrożeniowy | git push → na żywo w ~40 sekund |
Instalacja → budowanie → wdrożenie: 2-5 minut |
| Lighthouse Performance | 100 | 70-90 bez jawnej optymalizacji4 |
15 pakietów Python obejmuje FastAPI, Jinja2, Pydantic, uvicorn, nh3 i 10 innych. Żaden nie jest narzędziem budowania. Żaden nie jest kompilatorem. Żaden nie jest bundlerem.5
Czego się rezygnuje
Uczciwość wymaga wymienienia rzeczywistych kosztów:
Brak TypeScript. Każdy plik .js to czysty JavaScript. Błędy typów są wychwytywane przez testy i analizę kodu, nie przez kompilator. Sprawdza się to w przypadku jednego programisty. Nie sprawdziłoby się w 10-osobowym zespole współdzielącym interfejsy komponentów.
Brak Hot Module Replacement. Zmiany w CSS wymagają ręcznego odświeżenia przeglądarki. hx-boost w HTMX sprawia, że nawigacja jest wystarczająco szybka, by pełne odświeżenia były tolerowalne, ale przy intensywnej iteracji wizualnej HMR oszczędza czas.
Brak Tree Shaking. Każdy bajt napisanego JavaScript trafia do przeglądarki. To ograniczenie wymusza dyscyplinę: małe, skoncentrowane pliki zamiast dużych modułów narzędziowych.
Brak bibliotek komponentów npm. Żadnego Radix, shadcn/ui ani Headless UI. Każdy interaktywny element jest tworzony ręcznie lub wykorzystuje wbudowane komponenty Bootstrap 5.
Brak tokenów Design System z npm. System projektowy żyje we właściwościach niestandardowych CSS. Nie można go zaimportować jako pakietu w innym projekcie.
Te kompromisy są akceptowalne dla strony zorientowanej na treść z jednym do trzech programistów. Byłyby nieakceptowalne dla produktu SaaS z 15-osobowym zespołem inżynierskim. Sekcja 15 zawiera framework decyzyjny.
Co się zyskuje
Zero błędów budowania. npm install nie może zakończyć się błędem z powodu konfliktów zależności. next build nie może zakończyć się błędem z powodu błędu TypeScript w pliku, którego nie modyfikowano.6
Debugowanie przez View Source. JavaScript działający w przeglądarce to ten sam JavaScript, który został napisany. Mapy źródeł nie są potrzebne.
Natychmiastowy start lokalny. uvicorn app.main:app --reload uruchamia się w niecałe 2 sekundy.
Konkretny waterfall żądań. Pierwsza wizyta ładuje: jeden dokument HTML (~15KB gzip), jeden plik CSS (~8KB), HTMX (~14KB, z cache), Alpine.js (~14KB, z cache) oraz interaktywny JS strony (~4-8KB). Łącznie: 45-60KB przy pierwszej wizycie.1
Frontend odporny na przyszłość. Kod po stronie klienta wykorzystuje HTML, CSS i JavaScript — standardy, które zachowują wsteczną kompatybilność od 30 lat.7 Żadnej migracji Webpack 4 → 5, żadnego wycofania Create React App, żadnej migracji na Next.js App Router.
Przegląd architektury
Przepływ żądań
Każde żądanie przechodzi przez cztery warstwy jedną ścieżką:
Browser FastAPI Jinja2 HTMX/Alpine
| | | |
|--- GET /about ------>| | |
| |-- render template ->| |
| | |-- base.html ------->|
| | | + about.html |
| |<-- full HTML -------| |
|<--- HTML response ---| | |
| |
|--- hx-get /search ------------------------------------------------>|
| |<-- HTMX request ----| |
| |-- render partial -->| |
| | |-- _results.html |
| |<-- HTML fragment ---| |
|<--- HTML fragment ---| | |
|--- DOM swap -------------------------------------------------------->|
Pełne ładowania stron zwracają kompletne dokumenty HTML (szablon bazowy + szablon strony). Żądania HTMX zwracają fragmenty HTML (częściowe szablony). Serwer decyduje, co renderować, na podstawie typu żądania. Alpine.js zarządza stanem po stronie klienta, który nigdy nie trafia na serwer.
Role komponentów
| Komponent | Rola | Zakres |
|---|---|---|
| FastAPI | Routing, logika biznesowa, dostęp do danych, walidacja | Serwer |
| Jinja2 | Renderowanie szablonów, dziedziczenie, makra | Serwer |
| HTMX | Interaktywność sterowana serwerem (formularze, paginacja, wyszukiwanie) | Klient ↔ Serwer |
| Alpine.js | Stan wyłącznie po stronie klienta (rozwijane menu, modale, przełączniki) | Tylko klient |
| Bootstrap 5 | System siatki, klasy narzędziowe, responsywny układ | Klient (CSS) |
| Zwykły CSS | Właściwości niestandardowe, style komponentów, tokeny projektowe | Klient (CSS) |
| Pydantic | Walidacja żądań/odpowiedzi, ustawienia | Serwer |
Struktura projektu
app/
├── main.py # FastAPI app, middleware, templates
├── config.py # Pydantic settings management
├── routes/
│ ├── pages.py # Page routes (HTML responses)
│ └── api.py # API routes (JSON/HTML fragment responses)
├── content.py # Markdown loading, blog post parsing
├── security/
│ ├── headers.py # CSP, HSTS, security headers middleware
│ ├── csrf.py # HMAC-signed CSRF tokens
│ ├── rate_limit.py # 3-tier rate limiting
│ └── logging.py # Security event logging
├── i18n/
│ ├── config.py # Supported locales, mappings
│ ├── middleware.py # URL-based locale detection
│ ├── jinja.py # Translation functions for templates
│ └── d1_client.py # Cloudflare D1 translation storage
├── cache_assets.py # Content-hash asset versioning
└── templates/
├── base.html # Base layout with Alpine.js state
├── components/ # Reusable partials (_language_switcher.html, etc.)
└── pages/ # Page templates (home.html, about.html, etc.)
content/
├── blog/ # Markdown blog posts with YAML frontmatter
└── guides/ # Multi-section guide markdown
static/
├── css/ # Plain CSS (no preprocessors)
├── js/ # Vanilla JavaScript (no bundlers)
│ └── vendor/ # Self-hosted HTMX, Alpine.js
└── images/ # Optimized images with WebP srcset
Struktura opiera się na jednej zasadzie: każdy katalog zawiera jeden typ zasobów. Trasy znajdują się w routes/. Szablony w templates/. Zasoby statyczne w static/. Żaden etap budowania nie przekształca jednych w drugie.
Porównanie z architekturą SPA
W projekcie React + Next.js równoważna struktura obejmowałaby:
src/
├── components/ # React components (JSX)
├── pages/ # Route handlers (also JSX)
├── api/ # API routes (also in pages/)
├── hooks/ # Custom React hooks
├── context/ # React context providers
├── lib/ # Utility functions
├── styles/ # CSS modules or Tailwind config
└── types/ # TypeScript type definitions
# Plus build configuration
next.config.js
tsconfig.json
postcss.config.js
tailwind.config.js
eslint.config.js
package.json
package-lock.json
node_modules/ # 187+ MB of dependencies
Architektura SPA wymaga koordynacji między tymi katalogami na etapie budowania. TypeScript kompiluje .tsx do JavaScript. PostCSS przetwarza dyrektywy Tailwind na CSS. Webpack (lub Turbopack) pakuje wynik w porcje (chunks). Każdy z tych kroków może zakończyć się niepowodzeniem niezależnie.
Architektura bez etapu budowania nie wymaga żadnej koordynacji. Szablon odwołuje się do pliku CSS. Plik CSS istnieje w static/css/. Przeglądarka ładuje go bezpośrednio. Zmiana nazwy pliku powoduje błąd referencji w szablonie w momencie żądania — nie w momencie budowania. To przenosi błędy z etapu kompilacji na etap wykonania, co stanowi rzeczywisty kompromis. Dla samodzielnego programisty korzystającego z uvicorn --reload podczas pracy, błędy wykonania pojawiają się natychmiast w przeglądarce. W przypadku dużego zespołu błędy kompilacji wychwycone przez TypeScript zapobiegają kategorii błędów, których błędy wykonania nie są w stanie wykryć.
Wzorce FastAPI
Konfiguracja aplikacji
Aplikacja inicjalizuje się w main.py z jawnie określoną kolejnością middleware:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.gzip import GZipMiddleware
app = FastAPI(
title="Blake Crosley",
docs_url=None, # Disable docs in production
redoc_url=None,
openapi_url=None, # Prevent /openapi.json exposure
)
# Middleware order matters: last added = first executed
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(LocaleMiddleware)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(SecurityLogMiddleware, site_name="blakecrosley.com")
# Static files
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# Templates
templates = Jinja2Templates(directory=TEMPLATES_DIR)
Trzy decyzje projektowe mają tu znaczenie. Po pierwsze, docs_url=None i openapi_url=None wyłączają automatyczne endpointy dokumentacji API. Publiczna strona z treścią nie potrzebuje /docs ani /openapi.json wystawionych w internecie.8 Po drugie, kolejność middleware ma znaczenie — logowanie bezpieczeństwa wykonuje się jako pierwsze (dodane ostatnie), dzięki czemu rejestruje każde żądanie, łącznie z tymi odrzuconymi przez rate limiting. Po trzecie, GZipMiddleware kompresuje wszystkie odpowiedzi powyżej 500 bajtów, co zazwyczaj zmniejsza rozmiar transferu HTML o 70–80%.
Routing
Trasy dzielą się na dwie kategorie: trasy stron zwracają pełne dokumenty HTML, a trasy API zwracają JSON lub fragmenty HTML.
# routes/pages.py — full HTML responses
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("/about")
async def about(request: Request):
templates = request.app.state.templates
return templates.TemplateResponse("pages/about.html", {
"request": request,
"page_title": "About — Blake Crosley",
"page_description": "Designer, developer, dad.",
})
# routes/api.py — JSON or HTML fragment responses
@router.get("/api/quiz/{quiz_id}/step")
async def quiz_step(request: Request, quiz_id: str, answers: str = ""):
# Parse answers, compute next question or result
question = get_next_question(quiz_id, answers)
templates = request.app.state.templates
return templates.TemplateResponse("components/_quiz_step.html", {
"request": request,
"question": question,
"answers": answers,
"step": len(answers.split(",")) if answers else 0,
})
To rozróżnienie ma znaczenie dla HTMX. Trasy pełnych stron zwracają dokumenty rozszerzające base.html. Trasy API zwracają fragmenty HTML, które HTMX podmienia w istniejących elementach DOM. Ten sam silnik szablonów Jinja2 renderuje oba typy — bez oddzielnej warstwy API.
Wstrzykiwanie zależności
System Depends() w FastAPI zapewnia czyste rozdzielenie między handlerami tras a współdzieloną logiką:
from fastapi import Depends, Request
def get_templates(request: Request):
"""Get templates from app state."""
return request.app.state.templates
def get_current_locale(request: Request) -> str:
"""Get locale from middleware-set request state."""
return getattr(request.state, "locale", "en")
@router.get("/blog/{slug}")
async def blog_post(
request: Request,
slug: str,
templates=Depends(get_templates),
locale: str = Depends(get_current_locale),
):
post = load_post_by_slug(slug)
if not post:
raise HTTPException(404, "Post not found")
return templates.TemplateResponse("pages/blog/post.html", {
"request": request,
"post": post,
"locale": locale,
})
Zależności można komponować. Zależność get_db może zależeć od get_current_locale, która z kolei zależy od żądania. FastAPI automatycznie rozwiązuje cały łańcuch.
Ustawienia Pydantic
Konfiguracja wykorzystuje BaseSettings z Pydantic z priorytetem zmiennych środowiskowych:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
D1_WORKER_URL: str = ""
D1_AUTH_SECRET: str = ""
CLOUDFLARE_ACCOUNT_ID: str = ""
CLOUDFLARE_API_TOKEN: str = ""
ANALYTICS_PASSKEY: str = ""
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
Zmienne środowiskowe nadpisują wartości z pliku .env. W produkcji (Railway) sekrety ustawiane są jako zmienne środowiskowe. Lokalnie plik .env dostarcza wartości domyślne. Klasa Settings waliduje typy przy starcie — brakujące wymagane pole powoduje natychmiastowy błąd zamiast awarii w czasie działania.
Wzorce asynchroniczne
Trasy FastAPI są domyślnie asynchroniczne. W przypadku operacji ograniczonych przez I/O (zapytania do bazy danych, żądania HTTP, odczyty plików) async zapobiega blokowaniu pętli zdarzeń:
@app.on_event("startup")
async def startup_load_translations():
"""Load translations from D1 into memory at startup."""
client = init_d1_client(
worker_url=settings.D1_WORKER_URL,
auth_secret=settings.D1_AUTH_SECRET,
)
if not client.is_configured:
logger.warning("i18n: D1 not configured, translations use defaults")
return
cache = await load_translations(client)
logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")
Operacje ograniczone przez CPU (renderowanie Markdown, ekstrakcja CSS) mogą wykorzystywać funkcje synchroniczne. FastAPI uruchamia je automatycznie w puli wątków, gdy handler trasy nie jest zadeklarowany jako async:
# Sync function — FastAPI runs it in a thread pool
@router.get("/blog/{slug}")
def blog_post(slug: str):
post = load_post_by_slug(slug) # CPU-bound Markdown parsing
return templates.TemplateResponse(...)
Zasada jest prosta: jeśli funkcja oczekuje na I/O, należy ją zadeklarować jako async. Jeśli wykonuje operacje CPU, warto pozostawić ją synchroniczną. Nie należy mieszać await z blokującymi wywołaniami w tej samej funkcji.9
Szablony Jinja2
Dziedziczenie szablonów
System dziedziczenia Jinja2 zastępuje kompozycję komponentów Reacta prostszym modelem. Jeden szablon bazowy definiuje szkielet strony. Szablony potomne wypełniają nazwane bloki:
<!-- base.html — the skeleton -->
<!DOCTYPE html>
<html lang="{{ lang_attr() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title | default("Blake Crosley") }}</title>
<meta name="description" content="{{ page_description | default('...') }}">
<!-- CSS — single file, no preprocessor -->
<link rel="stylesheet" href="{{ asset('css/styles.css') }}">
<!-- JSON-LD structured data -->
<script type="application/ld+json">
{ "@context": "https://schema.org", "@graph": [...] }
</script>
{% block head %}{% endblock %}
</head>
<body>
<header class="header">...</header>
<main id="main" role="main">
{% block content %}{% endblock %}
</main>
<footer class="footer">...</footer>
<!-- Scripts deferred for performance -->
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
<script defer src="{{ asset('js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
<!-- pages/about.html — fills the blocks -->
{% extends "base.html" %}
{% block head %}
<script type="application/ld+json">
{ "@type": "AboutPage", "name": "About Blake Crosley", ... }
</script>
{% endblock %}
{% block content %}
<section class="hero">
<h1>About</h1>
<p>Designer, developer, dad.</p>
</section>
{% endblock %}
Dyrektywa {% extends %} ustanawia relację rodzic-dziecko. Szablon potomny definiuje jedynie te bloki, które musi nadpisać. Cała reszta — <head>, nagłówek, stopka, tagi skryptów — pochodzi z szablonu bazowego. To kompozycja przez odejmowanie, nie przez budowanie.
Funkcja globalna asset()
Zasoby statyczne wykorzystują wersjonowanie oparte na hashu zawartości w celu wymuszania odświeżenia pamięci podręcznej:
# cache_assets.py
def build_asset_map(static_dir: Path) -> dict[str, str]:
"""Compute MD5 hashes of all static files at startup."""
asset_map = {}
for filepath in static_dir.rglob("*"):
if filepath.is_file():
rel_path = str(filepath.relative_to(static_dir))
content_hash = hashlib.md5(filepath.read_bytes()).hexdigest()[:10]
asset_map[rel_path] = content_hash
return asset_map
def make_asset_url(asset_map: dict, path: str) -> str:
"""Generate versioned URL: /static/css/styles.css?v=a3f8b2c1d0"""
clean_path = path.lstrip("/")
version = asset_map.get(clean_path, "0")
return f"/static/{clean_path}?v={version}"
W szablonie: {{ asset('css/styles.css') }} generuje /static/css/styles.css?v=a3f8b2c1d0. Hash zmienia się wraz ze zmianą pliku, wymuszając odświeżenie pamięci podręcznej CDN. Zastępuje to strategię nazw plików [contenthash] webpacka — 30 linii Python obliczanych przy starcie aplikacji.
Include dla komponentów wielokrotnego użytku
Komponenty powtarzające się na wielu stronach wykorzystują {% include %}:
<!-- base.html -->
{% include "components/_language_switcher.html" %}
<!-- components/_language_switcher.html -->
{%- set current = current_locale() -%}
{%- set locales = all_locales() -%}
<div class="language-switcher"
x-data="{ open: false }"
@click.away="open = false">
<button @click="open = !open" :aria-expanded="open">
{{ current_locale_native() }}
</button>
<ul class="language-switcher-menu"
:class="{ 'is-open': open }"
x-cloak>
{% for locale in locales %}
<li>
<a href="{{ locale_url(request.url.path, locale.code) }}"
hreflang="{{ locale.code }}">
{{ locale.native }}
</a>
</li>
{% endfor %}
</ul>
</div>
Prefiks podkreślenia (_language_switcher.html) to konwencja oznaczająca partial — fragment szablonu nieprzeznaczony do samodzielnego renderowania. Ten komponent wykorzystuje zarówno Alpine.js (do przełączania rozwijanego menu), jak i Jinja2 (do listy lokalizacji). Granica jest czytelna: Alpine.js zarządza stanem otwarcia/zamknięcia, Jinja2 zarządza danymi.
Makra jako komponenty wielokrotnego użytku
Makra to funkcje Jinja2 — wielokrotnie używalne bloki szablonów z parametrami:
<!-- components/_macros.html -->
{% macro card(title, description, href, badge=None) %}
<article class="card">
<a href="{{ href }}" class="card__link">
{% if badge %}
<span class="card__badge">{{ badge }}</span>
{% endif %}
<h3 class="card__title">{{ title }}</h3>
{% if description %}
<p class="card__description">{{ description }}</p>
{% endif %}
</a>
</article>
{% endmacro %}
{% macro optimized_image(image_config, loading="lazy") %}
{% if image_config.get("svg") %}
<img src="{{ image_config.svg }}"
width="{{ image_config.width }}"
height="{{ image_config.height }}"
alt="{{ image_config.alt }}">
{% else %}
<picture>
<source type="image/webp"
srcset="{{ image_config.webp_srcset }}"
sizes="(max-width: 768px) 100vw, 50vw">
<img src="{{ image_config.fallback }}"
width="{{ image_config.width }}"
height="{{ image_config.height }}"
alt="{{ image_config.alt }}"
loading="{{ loading }}">
</picture>
{% endif %}
{% endmacro %}
Import i użycie makr w szablonach stron:
{% from "components/_macros.html" import card, optimized_image %}
<section class="projects">
{% for project in projects %}
{{ card(
title=project.title,
description=project.description,
href=project.link,
badge="New" if project.is_new else None
) }}
{% endfor %}
</section>
Makra zastępują komponenty Reacta w przypadku wzorców prezentacyjnych. Przyjmują parametry, obsługują wartości domyślne i można je komponować z innymi makrami. Kluczowa różnica: makra renderują się jednokrotnie po stronie serwera i generują statyczny HTML. Komponenty Reacta renderują się po stronie klienta i utrzymują stan. Do wyświetlania treści makra są właściwym narzędziem.
Kontekst szablonów i zmienne globalne
Zmienne globalne Jinja2 to funkcje dostępne w każdym szablonie bez jawnego przekazywania:
# In main.py — register globals
templates.env.globals["asset"] = lambda path: make_asset_url(_asset_map, path)
templates.env.globals["csrf_token"] = generate_csrf_token
templates.env.globals["analytics_script"] = analytics.tracking_script
Globalna funkcja asset() generuje wersjonowane adresy URL. Globalna funkcja csrf_token() generuje świeże tokeny CSRF. Globalna funkcja analytics_script() wstrzykuje skrypt śledzący. Funkcje te można wywoływać w dowolnym szablonie bez konieczności jawnego przekazywania ich przez handler trasy.
W przypadku i18n konfiguracja jest bardziej złożona — funkcje tłumaczeniowe potrzebują dostępu do lokalizacji bieżącego żądania:
# i18n/jinja.py
def setup_i18n_jinja(env):
"""Register translation functions as Jinja2 globals."""
env.globals["_"] = get_translation # _('ui.nav.about')
env.globals["locale_prefix"] = get_locale_prefix # '/ja' or ''
env.globals["current_locale"] = get_current_locale
env.globals["all_locales"] = get_all_locales
env.globals["alternate_urls"] = get_alternate_urls
env.globals["lang_attr"] = get_lang_attr # 'ja' for HTML lang
env.globals["og_locale"] = get_og_locale # 'ja_JP' for og:locale
env.globals["jsonld_lang"] = get_jsonld_lang # 'ja-JP' for JSON-LD
Każda funkcja odczytuje lokalizację ze zmiennej kontekstu żądania ustawionej przez middleware lokalizacji. Szablon wywołuje {{ _('ui.nav.about') }} i otrzymuje przetłumaczony ciąg znaków dla lokalizacji bieżącego żądania bez jawnego parametru lokalizacji.
Bloki warunkowe
System bloków Jinja2 obsługuje warunkowe nadpisywanie:
<!-- base.html -->
{% block head %}{% endblock %}
<!-- pages/blog/post.html -->
{% block head %}
<script type="application/ld+json">
{
"@type": "Article",
"headline": "{{ post.meta.title }}",
"author": { "@id": "https://blakecrosley.com/#person" },
"datePublished": "{{ post.meta.date.isoformat() }}",
"dateModified": "{{ post.meta.updated.isoformat() if post.meta.updated else post.meta.date.isoformat() }}"
}
</script>
{% if post.meta.scripts %}
{% for script in post.meta.scripts %}
<script defer src="{{ asset(script.lstrip('/static/')) }}"></script>
{% endfor %}
{% endif %}
{% if post.meta.styles %}
{% for style in post.meta.styles %}
<link rel="stylesheet" href="{{ asset(style.lstrip('/static/')) }}">
{% endfor %}
{% endif %}
{% endblock %}
Wpisy blogowe deklarują swoje zależności we frontmatterze YAML (scripts: ["/static/js/boids.js"]). Szablon warunkowo je dołącza. Strony niewymagające dodatkowych skryptów ani stylów nie ładują żadnych — bez martwego kodu, bez nieużywanych importów.
Filtry niestandardowe
Filtry Jinja2 przekształcają dane podczas renderowania. Filtr sanitize zapobiega atakom XSS w treściach generowanych przez użytkowników:
import nh3
ALLOWED_TAGS = {"a", "b", "blockquote", "br", "code", "em", "h1", "h2",
"h3", "h4", "h5", "h6", "hr", "i", "img", "li", "ol",
"p", "pre", "span", "strong", "table", "td", "th", "tr", "ul"}
def sanitize_html(value: str) -> str:
"""Sanitize HTML to prevent XSS attacks."""
if not value:
return ""
return nh3.clean(
value,
tags=ALLOWED_TAGS,
attributes={"a": {"href", "title"}, "img": {"src", "alt"}},
link_rel="noopener noreferrer",
)
templates.env.filters["sanitize"] = sanitize_html
W szablonach: {{ user_content | sanitize }}. Biblioteka nh3 to napisany w Rust sanitizer HTML — szybki i bezpieczny. Usuwa wszelkie tagi i atrybuty spoza listy dozwolonych, zapobiegając składowanym atakom XSS nawet gdy treść pochodzi z niezaufanego źródła.10
HTMX — szczegółowe omówienie
HTMX sprawia, że dowolny element HTML może wysyłać żądania HTTP i podmieniać odpowiedź w drzewie DOM. Kluczowa idea ma charakter architektoniczny: renderowany po stronie serwera HTML stanowi API. Serwer zwraca finalną reprezentację. Bez renderowania po stronie klienta, bez serializacji JSON, bez hydratacji.
Podstawowe atrybuty
| Atrybut | Przeznaczenie | Przykład |
|---|---|---|
hx-get |
Wysłanie żądania GET | hx-get="/search?q=term" |
hx-post |
Wysłanie żądania POST | hx-post="/contact" |
hx-target |
Miejsce umieszczenia odpowiedzi | hx-target="#results" |
hx-swap |
Sposób wstawienia odpowiedzi | hx-swap="innerHTML" (domyślnie), outerHTML, beforeend |
hx-trigger |
Zdarzenie wyzwalające żądanie | hx-trigger="click", keyup changed delay:300ms, load |
hx-indicator |
Element wyświetlany podczas żądania | hx-indicator="#spinner" |
hx-push-url |
Aktualizacja adresu URL w przeglądarce | hx-push-url="true" |
hx-replace-url |
Podmiana adresu URL bez wpisu w historii | hx-replace-url="true" |
Wzorzec 1: Interaktywny quiz (wieloetapowy stan po stronie serwera)
blakecrosley.com zawiera interaktywny quiz, który prowadzi użytkowników przez wybór narzędzi. Cały stan quizu rezyduje na serwerze — bez zarządzania stanem po stronie klienta:
<!-- _quiz_container.html — initial load -->
<div hx-get="/api/quiz/claude-vs-codex/step?answers="
hx-trigger="load"
hx-swap="innerHTML"
id="quiz-wrapper">
<p>Loading quiz...</p>
</div>
<!-- _quiz_step.html — each question -->
<div class="quiz-step" id="quiz-container">
<p>Question {{ step }} of {{ total }}</p>
<h3>{{ question.question }}</h3>
<div class="quiz-step__options">
{% for opt in question.options %}
<button class="quiz-step__btn"
hx-get="/api/quiz/claude-vs-codex/step?answers={{ answers }},{{ opt.value }}"
hx-target="#quiz-container"
hx-swap="outerHTML">
{{ opt.label }}
</button>
{% endfor %}
</div>
</div>
Każde kliknięcie przycisku wysyła zgromadzone odpowiedzi jako parametr zapytania. Serwer oblicza kolejne pytanie lub końcowy wynik na podstawie historii odpowiedzi. Stan akumuluje się w adresie URL — bez ciasteczek, bez sesji, bez JavaScript po stronie klienta. Quiz przechodzi do kolejnych kroków za pomocą podmian outerHTML: każda odpowiedź zastępuje cały element kroku quizu.
Wzorzec 2: Paginowana lista wpisów na blogu
Strona z artykułami wykorzystuje HTMX do płynnej paginacji z aktualizacją adresu URL:
<!-- Pagination link -->
<a href="/writing?page=2&category=Engineering"
hx-get="/writing?page=2&category=Engineering"
hx-target="#writing-content"
hx-swap="innerHTML"
hx-replace-url="true"
hx-indicator="#writing-loading"
aria-label="Go to page 2">
2
</a>
Cztery atrybuty działające wspólnie:
hx-getwysyła żądanie pod ten sam adres URL cohref(stopniowe ulepszanie — działa bez JavaScript)hx-targetumieszcza odpowiedź w kontenerze#writing-contenthx-replace-url="true"aktualizuje adres URL w przeglądarce bez dodawania wpisu w historiihx-indicatorwyświetla wskaźnik ładowania podczas trwania żądania
Serwer wykrywa żądania HTMX za pomocą nagłówka HX-Request i zwraca jedynie fragment z listą wpisów zamiast pełnej strony. Właśnie dlatego middleware nagłówków bezpieczeństwa dodaje Vary: HX-Request — aby cache CDN przechowywał pełną stronę i fragment osobno.11
Wzorzec 3: Wyszukiwanie z debounce
<input type="search" name="q"
hx-get="/api/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results"
hx-indicator="#search-spinner" />
<div id="results"></div>
Atrybut hx-trigger łączy trzy modyfikatory:
keyupwyzwala się po zwolnieniu klawiszachangedwyzwala się tylko wtedy, gdy wartość faktycznie się zmieniła (zapobiega duplikatom żądań od klawiszy modyfikujących)delay:300msstosuje debounce — czeka 300 ms od ostatniego keyup przed wysłaniem żądania
Serwer zwraca wyrenderowany fragment HTML:
@router.get("/api/search")
async def search(request: Request, q: str = ""):
results = search_content(q)
return templates.TemplateResponse("components/_search_results.html", {
"request": request,
"results": results,
"query": q,
})
Bez stanu po stronie klienta. Bez biblioteki debounce. Bez useEffect. Szablon renderuje wyniki, HTMX podmienia je w DOM, a serwer pozostaje jedynym źródłem prawdy.
Wzorzec 4: Podmiany out-of-band (OOB)
Czasami pojedyncza akcja serwera musi zaktualizować wiele elementów DOM. Mechanizm podmian out-of-band w HTMX obsługuje to bez orkiestracji po stronie klienta:
<!-- Server returns multiple elements in one response -->
<!-- Primary target: swapped normally via hx-target -->
<div id="cart-items">
<ul>
<li>Widget A — $29.99</li>
<li>Widget B — $14.99</li>
</ul>
</div>
<!-- OOB target: swapped independently via hx-swap-oob -->
<span id="cart-count" hx-swap-oob="true">2 items</span>
<span id="cart-total" hx-swap-oob="true">$44.98</span>
Atrybut hx-swap-oob="true" informuje HTMX, aby znalazł element po id w dowolnym miejscu drzewa DOM i zastąpił go, niezależnie od hx-target. Zastępuje to wzorzec „przenoszenia stanu w górę” znany z React — serwer oblicza cały stan pochodny i wysyła finalny HTML dla każdego elementu w jednej odpowiedzi.
Na blakecrosley.com ten wzorzec pojawia się w formularzu kontaktowym: wysłanie formularza zastępuje jego treść komunikatem o powodzeniu i jednocześnie aktualizuje plakietkę powiadomień za pomocą podmiany OOB.
Wzorzec 5: Wzmocnione linki (boosted links)
HTMX potrafi „wzmocnić” standardowe linki nawigacyjne, aby korzystały z AJAX zamiast pełnego przeładowania strony:
<nav hx-boost="true">
<a href="/about">About</a>
<a href="/writing">Writing</a>
<a href="/guides">Guides</a>
</nav>
Dzięki hx-boost="true" kliknięcie linku pobiera stronę przez AJAX, podmienia zawartość <body> i aktualizuje adres URL — bez pełnego przeładowania strony. Historia przeglądarki działa normalnie (przyciski wstecz/dalej). Jeśli JavaScript nie załaduje się, linki działają jako standardowa nawigacja.
Korzyścią jest odczuwalna wydajność: wzmocniona nawigacja sprawia wrażenie natychmiastowej, ponieważ przeglądarka nie musi ponownie parsować CSS, ponownie uruchamiać skryptów ani ponownie renderować układu. Zmienia się jedynie zawartość <body>. blakecrosley.com wykorzystuje wzmocnione linki w głównej nawigacji, dzięki czemu przejścia między stronami przypominają aplikację jednostronicową — bez architektury SPA.
Wzorzec 6: Nagłówki żądań HTMX
HTMX wysyła niestandardowe nagłówki z każdym żądaniem:
| Nagłówek | Wartość | Zastosowanie |
|---|---|---|
HX-Request |
true |
Wykrywanie żądań HTMX po stronie serwera |
HX-Target |
ID elementu | Identyfikacja elementu, który otrzyma odpowiedź |
HX-Trigger |
ID elementu | Identyfikacja elementu, który wyzwolił żądanie |
HX-Current-URL |
Pełny URL | Informacja o bieżącej stronie użytkownika |
Serwer może wykorzystać HX-Request do zwracania różnych odpowiedzi:
@router.get("/writing")
async def writing(request: Request, page: int = 1, category: str = None):
posts = load_all_posts(page=page, category=category)
context = {"request": request, "posts": posts, "current_page": page}
# HTMX request: return only the post list fragment
if request.headers.get("HX-Request"):
return templates.TemplateResponse(
"pages/writing/_post_list.html", context
)
# Normal request: return the full page
return templates.TemplateResponse("pages/writing/index.html", context)
Ten wzorzec podwójnej odpowiedzi stanowi fundament architektury. Pełne załadowanie strony zwraca kompletny dokument (szablon bazowy + zawartość strony). Nawigacja przez HTMX zwraca jedynie zmienioną zawartość. O tym decyduje serwer, nie klient.
Wzorzec 7: Stopniowe ulepszanie (progressive enhancement)
Każdy link HTMX na blakecrosley.com zawiera standardowy atrybut href:
<a href="/writing?page=2"
hx-get="/writing?page=2"
hx-target="#writing-content"
hx-swap="innerHTML">
Next Page
</a>
Jeśli JavaScript nie załaduje się, href działa jako zwykły link. Jeśli HTMX się załaduje, przechwytuje kliknięcie i wykonuje podmianę AJAX. To właśnie stopniowe ulepszanie: strona działa bez JavaScript, a HTMX wzbogaca doświadczenie, gdy jest dostępny.
Wzorzec 5: Stany ładowania
<button hx-post="/api/contact"
hx-target="#form-result"
hx-indicator="#submit-spinner">
<span id="submit-spinner" class="htmx-indicator">Sending...</span>
<span>Send Message</span>
</button>
HTMX dodaje klasę htmx-request do elementu wyzwalającego podczas trwania żądania. Atrybut hx-indicator wskazuje element, który staje się widoczny podczas żądania. Wystarczy ostylować go za pomocą CSS:
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline;
}
Bez zarządzania stanami ładowania. Bez useState(false). Bez setLoading(true). CSS obsługuje widoczność, HTMX obsługuje przełączanie klasy.
Wzorce Alpine.js
Alpine.js wypełnia lukę, którą pozostawia HTMX: stan istniejący wyłącznie po stronie klienta, który nigdy nie musi komunikować się z serwerem. Gdy użytkownik klika rozwijane menu i ono się otwiera, ten stan istnieje tylko w przeglądarce. Alpine.js zarządza nim za pomocą atrybutów HTML.
Zasada granicy
Granica między HTMX a Alpine.js jest precyzyjna:
| Typ stanu | Narzędzie | Przykład |
|---|---|---|
| Wymaga danych z serwera | HTMX | Wyniki wyszukiwania, walidacja formularzy, paginacja |
| Istnieje tylko w przeglądarce | Alpine.js | Otwieranie/zamykanie menu, przełącznik menu mobilnego, widoczność modala |
| Łączy oba podejścia | Oba | Przełącznik języka (toggle Alpine.js, nawigacja w stylu HTMX) |
Nawigacja mobilna
Szablon bazowy opakowuje cały nagłówek w komponent Alpine.js:
<div x-data="{ navOpen: false, langOpen: false }"
@keydown.escape.window="navOpen = false; langOpen = false">
<!-- Mobile hamburger button -->
<button @click="navOpen = !navOpen; langOpen = false"
:aria-expanded="navOpen"
:class="navOpen ? 'nav__toggle is-open' : 'nav__toggle'"
aria-label="Toggle navigation">
<span class="nav__toggle-icon">
<span class="nav__toggle-bar"></span>
<span class="nav__toggle-bar"></span>
<span class="nav__toggle-bar"></span>
</span>
</button>
<!-- Mobile menu panel -->
<div class="mobile-menu" x-show="navOpen" x-cloak>
<nav class="mobile-menu__nav">
<a href="/about" @click="navOpen = false">About</a>
<a href="/#work" @click="navOpen = false">Work</a>
<a href="/writing" @click="navOpen = false">Writing</a>
</nav>
</div>
</div>
Kluczowe wzorce Alpine.js:
x-datadeklaruje zakres komponentu i stan początkowyx-showprzełącza widoczność na podstawie stanu (wykorzystuje CSSdisplay: none)x-cloakukrywa element do momentu inicjalizacji Alpine.js (zapobiega błyskowi niestylizowanej treści)@clickwiąże obsługę kliknięć z wyrażeniami:aria-expanded(skrót odx-bind:aria-expanded) dynamicznie ustawia atrybuty@keydown.escape.windownasłuchuje klawisza Escape globalnie, aby zamknąć panele
Komponent rozwijany
Przełącznik języka wykorzystuje Alpine.js do zarządzania stanem toggle z @click.away do zamykania po kliknięciu poza elementem:
<div x-data="{ open: false }"
@click.away="open = false"
@keydown.escape.window="open = false">
<button @click="open = !open"
:aria-expanded="open"
aria-haspopup="listbox">
English
<svg :class="{ 'rotated': open }">...</svg>
</button>
<ul :class="{ 'is-open': open }"
:aria-hidden="!open"
role="listbox"
x-cloak>
<li role="option">
<a href="/ja/about">日本語</a>
</li>
<!-- more languages -->
</ul>
</div>
Modyfikator @click.away zamyka menu po kliknięciu na zewnątrz. Alpine.js obsługuje to za pomocą jednego atrybutu — bez rejestracji nasłuchiwaczy zdarzeń, bez sprzątania, bez zarządzania referencjami.
Kiedy używać Alpine.js, a kiedy czystego JavaScript
Alpine.js sprawdza się, gdy:
- Stan jest ograniczony do pojedynczego elementu DOM (menu rozwijane, modal, przełącznik)
- Interakcje są binarne lub proste (otwórz/zamknij, pokaż/ukryj, przełącz)
- Wiele elementów musi reagować na tę samą zmianę stanu
- Atrybuty dostępności muszą pozostawać zsynchronizowane z widocznością
Czysty JavaScript sprawdza się, gdy:
- Interakcja wymaga złożonych obliczeń (wizualizacje, symulacje)
- Komponent ma własną pętlę renderowania (canvas, animacje)
- Wydajność jest kluczowa (Alpine.js dodaje narzut na każdy komponent
x-data) - Logika przekracza 20–30 linii wyrażeń Alpine.js
blakecrosley.com wykorzystuje Alpine.js do nawigacji, przełączania języków i rozwijania treści. 20 interaktywnych komponentów bloga (symulacja boidów, wizualizator kodu Hamminga itp.) używa czystego JavaScript, ponieważ wymagają renderowania na canvas i złożonych maszyn stanów.
Bootstrap 5 bez Sass
Bootstrap 5 porzucił jQuery jako zależność i obsługuje samodzielne użycie CSS. Nie potrzeba Sass, PostCSS ani żadnego narzędzia budowania, aby korzystać z systemu siatki i klas narzędziowych Bootstrap.
Samodzielny hosting bez CDN
blakecrosley.com hostuje wszystkie biblioteki zewnętrzne lokalnie:
<!-- base.html — no CDN, no external requests -->
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
Samodzielny hosting eliminuje zależności zewnętrzne, zapobiega awariom CDN powodującym problemy z witryną i umożliwia niezmienne buforowanie z adresami URL opartymi na hashach treści. Wystarczy pobrać skompilowany CSS Bootstrap (nie źródła Sass) i umieścić go w static/css/vendor/.
System siatki
Siatka Bootstrap działa ze zwykłymi klasami HTML:
<div class="container">
<div class="row">
<div class="col-12 col-md-8">
<article>Main content</article>
</div>
<div class="col-12 col-md-4">
<aside>Sidebar</aside>
</div>
</div>
</div>
Bez mixinów Sass. Bez @include make-col(). Skompilowany CSS zawiera responsywne klasy siatki. W przypadku niestandardowych breakpointów wykraczających poza domyślne wartości Bootstrap wystarczy napisać zwykłe zapytania medialne CSS.
Nadpisywanie za pomocą czystego CSS
Domyślne style Bootstrap można nadpisać za pomocą właściwości niestandardowych CSS i standardowych selektorów:
/* Custom design tokens — no Sass, no Tailwind */
:root {
--color-bg-dark: #000000;
--color-text-primary: #ffffff;
--color-text-secondary: rgba(255, 255, 255, 0.65);
--color-text-tertiary: rgba(255, 255, 255, 0.40);
--spacing-sm: 1rem;
--spacing-md: 1.5rem;
--spacing-lg: 2rem;
--gutter: 48px;
--font-size-lg: 1.25rem;
}
/* Responsive override — the browser reads this at runtime */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 48px → 24px on mobile */
}
}
/* Override Bootstrap's default body styles */
body {
background: var(--color-bg-dark);
color: var(--color-text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
Właściwości niestandardowe CSS kaskadują przez DOM, dziedziczą z elementów nadrzędnych i reagują na zapytania medialne w czasie wykonania. Zmienne Sass kompilują się do statycznych wartości i znikają. To rozróżnienie ma znaczenie przy tworzeniu motywów: pojedyncza zmiana właściwości niestandardowej może zaktualizować każdą pochodną wartość bez ponownej kompilacji.12
Klasy narzędziowe a komponentowy CSS
Klas narzędziowych Bootstrap warto używać do jednorazowych odstępów i układu. Komponentowego CSS — do powtarzalnych wzorców:
<!-- Bootstrap utility for one-off spacing -->
<div class="mt-4 mb-3 px-2">One-off layout</div>
<!-- Component class for repeated patterns -->
<article class="writing__item">
<h3 class="writing__item-title">Post Title</h3>
<p class="writing__item-description">Description</p>
</article>
/* Component CSS — BEM naming, reusable */
.writing__item {
padding: var(--spacing-md);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
transition: background 0.15s ease;
}
.writing__item:hover {
background: rgba(255, 255, 255, 0.03);
}
.writing__item-title {
font-size: var(--font-size-lg);
margin-bottom: 0.5rem;
}
Zasada jest następująca: klasy narzędziowe Bootstrap do mechaniki układu (marginesy, paddingi, flexbox). Niestandardowy CSS do tożsamości wizualnej (kolory, typografia, animacje). Nigdy nie należy mieszać klas narzędziowych ze stylami komponentowymi w ramach tego samego zagadnienia.
Internacjonalizacja i lokalizacja
blakecrosley.com udostępnia treści w 10 językach: angielskim, japońskim, koreańskim, chińskim uproszczonym, chińskim tradycyjnym, niemieckim, francuskim, hiszpańskim, polskim i portugalskim (brazylijskim).
Routing oparty na lokalizacji w URL
Lokalizacja znajduje się w ścieżce URL: /about (angielski), /ja/about (japoński), /zh-Hans/about (chiński uproszczony). Angielski jest domyślny i nie ma prefiksu.
# i18n/config.py
SUPPORTED_LOCALES = [
"en", "zh-Hans", "zh-Hant", "fr", "de", "ja", "ko", "pl", "pt-BR", "es"
]
DEFAULT_LOCALE = "en"
Middleware lokalizacji wyodrębnia lokalizację ze ścieżki URL:
# i18n/middleware.py
class LocaleMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
path = request.url.path
# Check if path starts with a supported locale
for locale in SUPPORTED_LOCALES:
if path.startswith(f"/{locale}/") or path == f"/{locale}":
request.state.locale = locale
# Strip locale prefix for route matching
request.scope["path"] = path[len(f"/{locale}"):]
break
else:
request.state.locale = DEFAULT_LOCALE
response = await call_next(request)
return response
Middleware usuwa prefiks lokalizacji przed dopasowaniem tras. Oznacza to, że handlery tras nie potrzebują ścieżek specyficznych dla lokalizacji — /about obsługuje zarówno angielski (/about), jak i japoński (/ja/about), ponieważ middleware normalizuje ścieżkę.
Funkcje tłumaczeń w szablonach
Zmienne globalne Jinja2 udostępniają funkcje tłumaczeń:
<!-- Template usage -->
<h3>{{ _('ui.footer.navigate') | default('Navigate') }}</h3>
<a href="{{ locale_prefix() }}/about">
{{ _('ui.nav.about') | default('About') }}
</a>
Funkcja _() wyszukuje klucz tłumaczenia w pamięci podręcznej. Filtr | default() zapewnia angielską wersję zastępczą, jeśli tłumaczenie jest niedostępne. Funkcja locale_prefix() zwraca prefiks URL dla bieżącej lokalizacji ("" dla angielskiego, "/ja" dla japońskiego).
Tagi hreflang
Każda strona zawiera tagi hreflang dla wszystkich obsługiwanych lokalizacji:
<!-- Generated in base.html -->
{% for alt in alternate_urls(request.url.path) %}
<link rel="alternate" hreflang="{{ alt.hreflang }}" href="{{ alt.url }}">
{% endfor %}
Generuje to następujący kod:
<link rel="alternate" hreflang="en" href="https://blakecrosley.com/about">
<link rel="alternate" hreflang="ja" href="https://blakecrosley.com/ja/about">
<link rel="alternate" hreflang="zh-Hans" href="https://blakecrosley.com/zh-Hans/about">
<!-- ... all 10 locales -->
<link rel="alternate" hreflang="x-default" href="https://blakecrosley.com/about">
Wyszukiwarki wykorzystują hreflang do wyświetlania odpowiedniej wersji językowej w wynikach wyszukiwania. Wpis x-default wskazuje na angielską wersję jako domyślną.13
Przechowywanie tłumaczeń i pamięć podręczna
Tłumaczenia są przechowywane w Cloudflare D1 (SQLite na brzegu sieci) i ładowane do pamięci podręcznej przy starcie aplikacji:
@app.on_event("startup")
async def startup_load_translations():
client = init_d1_client(worker_url=settings.D1_WORKER_URL,
auth_secret=settings.D1_AUTH_SECRET)
cache = await load_translations(client)
logger.info(f"i18n: Loaded {cache.stats['key_count']} keys")
Pamięć podręczna eliminuje zapytania do bazy danych przy każdym renderowaniu strony. Aktualizacja tłumaczeń wymaga odświeżenia pamięci podręcznej (wyzwalanego przez endpoint administracyjny lub wdrożenie). Taka architektura poświęca aktualność na rzecz wydajności — tłumaczenia zmieniają się rzadko, natomiast renderowanie stron odbywa się przy każdym żądaniu.
Monitorowanie stanu
blakecrosley.com zawiera endpoint kontroli stanu i18n, który monitoruje pokrycie tłumaczeń dla każdej lokalizacji:
@app.get("/health/i18n")
async def health_i18n():
cache = get_translation_cache()
result = {
"status": "healthy",
"cache_loaded": cache.is_loaded,
"locales": {},
"alerts": [],
}
# Check coverage for each locale
for locale in SUPPORTED_LOCALES:
coverage = await calculate_coverage(locale, en_count)
result["locales"][locale] = {"coverage": round(coverage, 2)}
if coverage < 99.5:
result["alerts"].append(
f"{locale}: {coverage:.1f}% coverage (threshold: 99.5%)"
)
result["status"] = "warning"
return result
Próg pokrycia 99,5% wykrywa brakujące tłumaczenia, zanim użytkownicy napotkają nieprzetłumaczone ciągi znaków. Endpoint stanu integruje się z monitoringiem Railway, aby powiadamiać o spadku pokrycia — na przykład po dodaniu nowych ciągów UI, które nie zostały jeszcze przetłumaczone.
Renderowanie treści z uwzględnieniem lokalizacji
Wpisy blogowe i przewodniki obsługują tłumaczenia metadanych i treści dla poszczególnych lokalizacji:
# In route handler
translated = get_blog_translation(post.meta.slug, locale)
return templates.TemplateResponse("pages/blog/post.html", {
"request": request,
"post": post,
"translated_title": translated.title if translated else post.meta.title,
"translated_description": translated.description if translated else post.meta.description,
})
<!-- In template -->
<h1>{{ translated_title }}</h1>
<p class="post__description">{{ translated_description }}</p>
<!-- Body content falls back to English if translation unavailable -->
{{ post.html | sanitize | safe }}
Wzorzec jest spójny: najpierw przetłumaczona treść, w razie braku — angielska wersja zastępcza. Umożliwia to częściowe tłumaczenie — japoński użytkownik widzi przetłumaczone tytuły i opisy, nawet jeśli pełna treść artykułu pozostaje w języku angielskim. Filtr | default() Jinja2 koduje ten wzorzec w jednym potoku:
{{ translated.title if translated else post.meta.title }}
Tłumaczenie danych lokalizacyjnych
Treści statyczne, takie jak opisy projektów i etykiety nawigacji, są tłumaczone za pomocą funkcji pomocniczych, które zachowują tę samą strukturę danych, podmieniając ciągi znaków specyficzne dla danej lokalizacji:
# i18n/data.py
def translate_projects(projects: list, locale: str) -> list:
"""Return projects with translated titles and descriptions."""
if locale == "en":
return projects
translated = []
for project in projects:
t = get_translation(f"project.{project['slug']}.title", locale)
d = get_translation(f"project.{project['slug']}.description", locale)
translated.append({
**project,
"title": t or project["title"],
"description": d or project["description"],
})
return translated
Takie podejście oddziela warstwę tłumaczeń od warstwy danych. Trasy przekazują tę samą listę projects niezależnie od lokalizacji. Funkcje tłumaczące opakowują dane w sposób przezroczysty.
Mapa witryny z alternatywami hreflang
Dynamiczna mapa witryny zawiera wszystkie strony we wszystkich lokalizacjach z wzajemnymi odniesieniami:
@app.get("/sitemap.xml")
async def sitemap():
for page in static_pages:
for locale in SUPPORTED_LOCALES:
# Each URL entry includes alternates for all locales
locale_path = f"/{locale}{path}" if locale != "en" else path
xml_parts.append(f"<loc>{base_url}{locale_path}</loc>")
# Add xhtml:link alternates
for alt_locale in SUPPORTED_LOCALES:
alt_path = f"/{alt_locale}{path}" if alt_locale != "en" else path
hreflang = LOCALE_TO_HREFLANG[alt_locale]
xml_parts.append(
f'<xhtml:link rel="alternate" hreflang="{hreflang}" '
f'href="{base_url}{alt_path}"/>'
)
Generuje to 10 wpisów URL na stronę (po jednym na lokalizację), z których każdy zawiera 11 linków alternatywnych (10 lokalizacji + x-default). Dla witryny z 50 stronami mapa witryny zawiera 500 wpisów URL z 5500 linkami hreflang. Mapa witryny jest generowana dynamicznie i buforowana na jedną godzinę.
Wzorce bazodanowe
SQLAlchemy 2.0 Async
W przypadku aplikacji wymagających relacyjnej bazy danych, asynchroniczne wsparcie SQLAlchemy 2.0 integruje się płynnie z FastAPI:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, DeclarativeBase
engine = create_async_engine("sqlite+aiosqlite:///./data.db")
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
Wstrzykiwanie zależności dla sesji bazodanowych
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
@router.get("/users/{user_id}")
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
return templates.TemplateResponse("pages/user.html", {
"request": request, "user": user
})
Zależność get_db zarządza cyklem życia sesji: otwiera sesję, udostępnia ją handlerowi trasy, zatwierdza przy sukcesie i wycofuje zmiany w przypadku wyjątku. Każda operacja bazodanowa wykorzystuje zapytania parametryzowane — nigdy interpolację ciągów znaków.
Integracja z Pydantic
Modele Pydantic walidują dane wejściowe na granicy API i serializują dane wyjściowe dla szablonów:
from pydantic import BaseModel, EmailStr
class ContactForm(BaseModel):
name: str
email: EmailStr
message: str
@router.post("/contact")
async def submit_contact(form: ContactForm):
# form.name, form.email, form.message are validated
await send_email(form)
return templates.TemplateResponse("components/_contact_success.html", {
"request": request
})
Pydantic waliduje typy, formaty (e-mail, URL) oraz ograniczenia (minimalna/maksymalna długość) zanim handler trasy zostanie wykonany. Nieprawidłowe dane wejściowe automatycznie zwracają odpowiedź 422. Zastępuje to biblioteki walidacji formularzy po stronie klienta — serwer waliduje, a HTMX podmienia komunikat sukcesu lub informację o błędzie.
Migracje z Alembic
Alembic zarządza zmianami schematu bazy danych:
# Generate a migration from model changes
alembic revision --autogenerate -m "add user preferences table"
# Apply migrations
alembic upgrade head
# Roll back one migration
alembic downgrade -1
Funkcja autogeneracji porównuje modele SQLAlchemy z aktualnym schematem bazy danych i generuje skrypty migracji. Skrypty te są wersjonowanymi plikami Python, przechowywanymi w repozytorium:
# alembic/versions/001_add_user_preferences.py
def upgrade():
op.create_table(
"user_preferences",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id")),
sa.Column("locale", sa.String(10), default="en"),
sa.Column("theme", sa.String(20), default="dark"),
)
def downgrade():
op.drop_table("user_preferences")
Migracje uruchamiane są podczas wdrożenia (przed startem aplikacji). Zapewnia to zgodność schematu bazy danych z kodem aplikacji. W przypadku blakecrosley.com większość danych znajduje się w Cloudflare D1 (dostępnym przez HTTP), więc migracje Alembic dotyczą lokalnej bazy SQLite lub PostgreSQL używanej do danych sesji i analityki.
Wzorzec Cloudflare D1
blakecrosley.com wykorzystuje Cloudflare D1 jako zdalną bazę danych dostępną przez proxy Cloudflare Worker:
class D1Client:
"""HTTP client for Cloudflare D1 via Worker proxy."""
def __init__(self, worker_url: str, auth_secret: str):
self.worker_url = worker_url
self.auth_secret = auth_secret
async def fetch_all(self, sql: str, params: list = None) -> list[dict]:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.worker_url}/query",
json={"sql": sql, "params": params or []},
headers={"Authorization": f"Bearer {self.auth_secret}"},
)
return response.json()["results"]
Wzorzec ten sprawdza się w aplikacjach wymagających bazy danych, ale bez konieczności zarządzania serwerem bazodanowym. D1 to SQLite na krawędzi sieci Cloudflare, dostępny przez HTTP. Proxy Worker obsługuje uwierzytelnianie i ograniczanie liczby żądań. Kompromisem jest opóźnienie: każde zapytanie to żądanie HTTP (~50–100 ms) w porównaniu z lokalnym połączeniem bazodanowym (~1–5 ms). Pamięć podręczna ładowana przy starcie aplikacji łagodzi ten problem w przypadku obciążeń z przewagą odczytów, takich jak tłumaczenia.
Bezpieczeństwo
Middleware nagłówków bezpieczeństwa
blakecrosley.com implementuje wzmocnione nagłówki bezpieczeństwa za pomocą niestandardowego middleware:
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
CSP_DIRECTIVES = {
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' data: https:",
"connect-src": "'self'",
"frame-ancestors": "'self'",
"base-uri": "'self'",
"form-action": "'self'",
"upgrade-insecure-requests": "",
}
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = self.csp
response.headers["Permissions-Policy"] = self.PERMISSIONS_POLICY
return response
CSP zawiera 'unsafe-inline' i 'unsafe-eval', ponieważ Alpine.js wymaga ich do ewaluacji wyrażeń. Alternatywą jest build Alpine.js kompatybilny z CSP, który ma jednak ograniczenia.14 Wszystkie pozostałe funkcje są zablokowane: frame-ancestors zapobiega clickjackingowi, form-action ogranicza wysyłanie formularzy do tego samego originu, a upgrade-insecure-requests wymusza HTTPS.
Bezpieczeństwo cache CDN z HTMX
Middleware nagłówków bezpieczeństwa dodaje Vary: HX-Request do odpowiedzi HTMX:
if request.headers.get("HX-Request"):
existing_vary = response.headers.get("Vary", "")
if "HX-Request" not in existing_vary:
parts = [v.strip() for v in existing_vary.split(",") if v.strip()]
parts.append("HX-Request")
response.headers["Vary"] = ", ".join(parts)
Bez tego nagłówka CDN mógłby zbuforować odpowiedź fragmentu HTMX i serwować ją jako pełną stronę dla żądania spoza HTMX (lub odwrotnie). Nagłówek Vary informuje CDN o konieczności przechowywania oddzielnych wpisów cache w zależności od wartości nagłówka HX-Request.11
Ochrona przed CSRF
Formularze HTMX wykorzystują bezstanowe tokeny CSRF podpisane HMAC:
# csrf.py
def generate_csrf_token() -> str:
"""Token format: timestamp:random:HMAC-SHA256-signature"""
timestamp = str(int(time.time()))
random_value = secrets.token_hex(16)
payload = f"{timestamp}:{random_value}"
signature = hmac.new(
CSRF_SECRET.encode(), payload.encode(), hashlib.sha256
).hexdigest()
return f"{payload}:{signature}"
def validate_csrf_token(token: str) -> bool:
"""Verify signature and check expiration (1 hour)."""
timestamp, random_value, signature = token.split(":")
if int(time.time()) - int(timestamp) > 3600:
return False
expected = hmac.new(
CSRF_SECRET.encode(),
f"{timestamp}:{random_value}".encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Token jest generowany w szablonie za pomocą globalnej funkcji Jinja2 i dołączany do żądań formularzy HTMX:
<form hx-post="/contact" hx-target="#form-result">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- form fields -->
</form>
Bezstanowe tokeny eliminują konieczność przechowywania sesji po stronie serwera. Podpis HMAC gwarantuje, że token został wygenerowany przez serwer. Znacznik czasu zapobiega atakom powtórzeniowym. hmac.compare_digest chroni przed atakami czasowymi.15
Sanityzacja HTML
Treści generowane przez użytkowników przechodzą przez nh3 przed renderowaniem:
templates.env.filters["sanitize"] = sanitize_html
# In templates: {{ content | sanitize }}
Biblioteka nh3 usuwa tagi i atrybuty spoza listy dozwolonych. Linki automatycznie otrzymują rel="noopener noreferrer". Ochrona ta jest niezależna od CSP — zapobiega składowanemu XSS na warstwie renderowania, podczas gdy CSP blokuje wstrzyknięte skrypty na poziomie przeglądarki. Obrona w głąb.
Walidacja danych wejściowych
Modele Pydantic walidują wszystkie dane wejściowe na granicy API:
from pydantic import BaseModel, Field, EmailStr
class ContactRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: EmailStr
message: str = Field(..., min_length=10, max_length=5000)
FastAPI automatycznie zwraca 422 Unprocessable Entity w przypadku nieprawidłowych danych wejściowych. W połączeniu z parametryzowanymi zapytaniami bazodanowymi (SQLAlchemy nigdy nie interpoluje ciągów znaków) zapobiega to SQL injection i zapewnia bezpieczeństwo typów na granicach systemu.
Wydajność
Lighthouse 100/100/100/100
blakecrosley.com uzyskuje wynik 100 we wszystkich czterech kategoriach Lighthouse: Wydajność, Dostępność, Najlepsze praktyki i SEO. Można to zweryfikować na PageSpeed Insights.2
Kluczowe optymalizacje:
Krytyczny CSS
Krytyczny (widoczny bez przewijania) CSS jest wyodrębniany i wstawiany bezpośrednio w <head>. Pełny arkusz stylów ładuje się asynchronicznie:
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loads async — doesn't block render -->
<link rel="stylesheet" href="/static/css/styles.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.css">
</noscript>
Sztuczka z media="print" informuje przeglądarkę, że arkusz stylów nie jest potrzebny do renderowania ekranu, dzięki czemu nie blokuje pierwszego wyświetlenia strony. Handler onload przełącza go na media="all" po załadowaniu. Fallback <noscript> zapewnia załadowanie arkusza stylów nawet bez JavaScript.16
Kompresja GZip
app.add_middleware(GZipMiddleware, minimum_size=500)
Odpowiedzi powyżej 500 bajtów są kompresowane. HTML kompresuje się o 70-80%, redukując 15 KB dokument do 3-4 KB.
Niezmienne buforowanie zasobów statycznych
# In security headers middleware
if request.url.path.startswith("/static/"):
if os.environ.get("RAILWAY_ENVIRONMENT"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
Zasoby statyczne z adresami URL zawierającymi hash treści (?v=a3f8b2c1d0) są buforowane przez rok ze znacznikiem immutable. Hash zmienia się wraz ze zmianą pliku, wymuszając na przeglądarkach i CDN pobranie nowej wersji.
Opóźnione ładowanie skryptów
<script defer src="{{ asset('js/vendor/alpine.min.js') }}"></script>
<script defer src="{{ asset('js/vendor/htmx.min.js') }}"></script>
<script defer src="{{ asset('js/main.js') }}"></script>
Atrybut defer pobiera skrypty równolegle z parsowaniem HTML, ale wykonuje je dopiero po sparsowaniu dokumentu. Zapobiega to blokowaniu renderowania bez złożoności związanej z asynchronicznym ładowaniem i zarządzaniem kolejnością wykonywania.
Optymalizacja obrazów
Obrazy wykorzystują format WebP z responsywnym srcset i jawnymi wymiarami:
OPTIMIZED_IMAGES = {
"vision-sprint": {
"webp_srcset": (
"/static/images/optimized/vision-sprint-400w.webp 400w, "
"/static/images/optimized/vision-sprint-800w.webp 800w, "
"/static/images/optimized/vision-sprint-1200w.webp 1200w"
),
"fallback": "/static/images/optimized/vision-sprint-fallback.jpg",
"width": 1200,
"height": 1045,
},
}
<picture>
<source type="image/webp"
srcset="{{ image.webp_srcset }}"
sizes="(max-width: 768px) 100vw, 50vw">
<img src="{{ image.fallback }}"
width="{{ image.width }}"
height="{{ image.height }}"
alt="{{ image.alt }}"
loading="lazy">
</picture>
Jawne atrybuty width i height zapobiegają skumulowanemu przesunięciu układu (CLS). Atrybut loading="lazy" odracza ładowanie obrazów poza ekranem. WebP zapewnia 25-35% mniejsze pliki niż JPEG przy porównywalnej jakości.17
Early Hints
# In main.py
app.state.preload_links = [
f'<{make_asset_url(_asset_map, "css/styles.css")}>; rel=preload; as=style',
]
# In security headers middleware
if "text/html" in content_type:
preload_links = getattr(request.app.state, "preload_links", [])
if preload_links:
response.headers["Link"] = ", ".join(preload_links)
Nagłówek Link z rel=preload informuje Cloudflare o konieczności wysłania odpowiedzi 103 Early Hints, umożliwiając przeglądarce rozpoczęcie pobierania CSS zanim serwer zakończy generowanie odpowiedzi HTML.18
Minimalny JavaScript
Całkowity rozmiar JavaScript:
| Biblioteka | Rozmiar (zminifikowany + gzip) |
|---|---|
| HTMX | ~14 KB |
| Alpine.js | ~14 KB |
| JS specyficzny dla strony | 4-8 KB |
| Łącznie | 32-36 KB |
Typowa aplikacja React wysyła 100-300 KB frameworkowego JavaScript jeszcze przed kodem aplikacji.19 Podejście bez budowania wysyła mniej JavaScript, ponieważ po prostu jest mniej JavaScript do wysłania.
Wdrożenie
Railway
blakecrosley.com jest wdrażany na Railway za pomocą git push:
# railway.toml
[build]
builder = "nixpacks"
[deploy]
startCommand = "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10
Builder Nixpacks Railway wykrywa projekt Python na podstawie requirements.txt, instaluje zależności i uruchamia polecenie startowe. Nie jest wymagany żaden Docker. Endpoint health check zapewnia responsywność aplikacji przed rozpoczęciem obsługi ruchu:
@app.get("/health")
async def health():
return {"status": "healthy"}
Pipeline wdrożeniowy
git push origin main
→ Railway detects push
→ Nixpacks installs Python + requirements.txt (cached)
→ uvicorn starts
→ Health check passes
→ Traffic routes to new deployment
→ ~40 seconds total
Żadnego npm install. Żadnego npm run build. Żadnej kompilacji webpack. Żadnej kompilacji TypeScript. Jedynym krokiem instalacji jest pip install -r requirements.txt, który jest buforowany między wdrożeniami.
Procfile
web: uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
Procfile stanowi alternatywę kompatybilną z Heroku. Railway obsługuje zarówno railway.toml, jak i Procfile. Składnia ${PORT:-8000} wykorzystuje port udostępniony przez platformę lub domyślnie ustawia 8000 dla lokalnego środowiska deweloperskiego.
Konfiguracja produkcyjna Uvicorn
Przy większym ruchu warto użyć wielu workerów:
uvicorn app.main:app \
--host 0.0.0.0 \
--port ${PORT:-8000} \
--workers 4 \
--loop uvloop \
--http httptools
--workers 4uruchamia cztery procesy robocze (ogólna zasada: 2 * liczba rdzeni CPU + 1)--loop uvloopwykorzystuje szybszą pętlę zdarzeń uvloop (bezpośredni zamiennik asyncio)--http httptoolswykorzystuje szybszy parser HTTP httptools
Do celów deweloperskich --reload monitoruje zmiany w plikach:
uvicorn app.main:app --reload --port 8000
Alternatywa z Docker
Dla platform wymagających Docker:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Lekki obraz bazowy utrzymuje kontener w niewielkim rozmiarze. Flaga --no-cache-dir zapobiega przechowywaniu pobranych pakietów przez pip w warstwie obrazu.
CDN Cloudflare
blakecrosley.com wykorzystuje Cloudflare jako CDN, DNS i Workers:
# Cache headers for HTML pages (set in security middleware)
response.headers["Cache-Control"] = (
"public, max-age=300, s-maxage=3600, "
"stale-while-revalidate=86400"
)
max-age=300— przeglądarka buforuje przez 5 minuts-maxage=3600— CDN buforuje przez 1 godzinęstale-while-revalidate=86400— serwuje nieaktualne treści podczas rewalidacji przez 24 godziny
Zasoby statyczne otrzymują max-age=31536000, immutable, ponieważ adresy URL z hashem treści gwarantują aktualność.
Ramy decyzyjne
Czy potrzebne są narzędzia do budowania?
Należy odpowiedzieć na cztery pytania:
1. Czy więcej niż pięciu deweloperów współdzieli interfejsy JavaScript? Jeśli tak, sprawdzanie typów TypeScript w czasie kompilacji zapobiega błędom integracyjnym, które testy uruchomieniowe wykrywają zbyt późno. Warto dodać krok budowania.
2. Czy aplikacja zarządza złożonym stanem po stronie klienta? Jeśli przeciąganie i upuszczanie, współpraca w czasie rzeczywistym lub praca offline stanowią kluczowe funkcje (nie jedynie miłe dodatki), framework taki jak React lub Svelte uzasadnia swoją złożoność. Warto dodać krok budowania.
3. Czy wiele produktów korzysta ze współdzielonej biblioteki komponentów? Jeśli tak, biblioteka ta wymaga pakowania npm, wersjonowania semantycznego i tree shaking. Warto dodać krok budowania.
4. Czy projekt zależy od bibliotek ekosystemu npm, które zakładają obecność bundlera? Jeśli Radix, Framer Motion, TanStack Query lub podobne biblioteki stanowią rdzeń produktu, pipeline budowania jest niezbędny.
Jeśli na wszystkie cztery pytania odpowiedź brzmi „nie”, podejście bez budowania jest realne. Jeśli na jakiekolwiek pytanie odpowiedź brzmi „tak”, narzędzia do budowania rozwiązują rzeczywisty problem. Błędem jest dodawanie narzędzi do budowania, gdy na wszystkie cztery pytania odpowiedź brzmi „nie” — rozwiązywanie problemów, których się nie ma, jednocześnie generując narzut związany z zarządzaniem zależnościami.1
Porównanie stosów technologicznych
| Kategoria | Bez budowania (ten przewodnik) | React + narzędzia do budowania |
|---|---|---|
| Najlepsze do | Strony treściowe, portfolio, narzędzia wewnętrzne, aplikacje CRUD | Produkty SaaS, złożone SPA, konsumenci systemów projektowych |
| Rozmiar zespołu | 1-5 deweloperów | 5-50+ deweloperów |
| Zarządzanie stanem | Serwer (HTMX) + klient (Alpine.js) | Klient (React state, Redux, Zustand) |
| Bezpieczeństwo typów | W czasie wykonywania (Pydantic po stronie serwera) | W czasie kompilacji (TypeScript) |
| Ponowne użycie komponentów | Jinja2 includes + makra | Pakiety npm, współdzielone biblioteki |
| SEO | Renderowanie serwerowe domyślnie | Wymaga konfiguracji SSR/SSG |
| Dolna granica wydajności | Wysoka (minimalny JS, renderowanie serwerowe) | Zmienna (narzut frameworka) |
| Górna granica złożoności | Niższa (brak offline, brak bogatego stanu klienta) | Wyższa (dowolna interakcja kliencka możliwa) |
| Zależności | 15 pakietów Python | 300+ pakietów npm |
| Czas budowania | 0 sekund | 15-60 sekund |
Kiedy HTMX nie jest właściwym wyborem
HTMX zastępuje stan klienta zapytaniami do serwera. Działa to do momentu, gdy opóźnienie zaczyna mieć znaczenie:
- Interfejsy przeciągnij i upuść — 200 ms opóźnienia serwera na każde zdarzenie przeciągania jest nie do zaakceptowania
- Współpraca w czasie rzeczywistym — stan oparty na WebSocket wymaga rozwiązywania konfliktów po stronie klienta
- Aplikacje offline-first — brak serwera oznacza brak HTMX
- Złożone animacje powiązane ze stanem — Framer Motion i React Spring zakładają model uzgadniania React
- Aplikacje Canvas/WebGL — pętla renderowania jest z natury po stronie klienta
W tych przypadkach framework kliencki jest właściwym narzędziem. Podejście bez budowania nie próbuje go zastąpić.
Karta szybkiego odniesienia
FastAPI
# Development
source venv/bin/activate
uvicorn app.main:app --reload --port 8000
# Production
uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}
# Testing
python -m pytest -v --cov=app
# Database migrations
alembic upgrade head
alembic revision --autogenerate -m "description"
Atrybuty HTMX
hx-get="/url" <!-- GET request -->
hx-post="/url" <!-- POST request -->
hx-target="#element" <!-- Where to put response -->
hx-swap="innerHTML" <!-- How to insert (innerHTML, outerHTML, beforeend) -->
hx-trigger="click" <!-- What triggers request -->
hx-trigger="keyup changed delay:300ms" <!-- Debounced input -->
hx-trigger="load" <!-- Fire on element load -->
hx-indicator="#spinner" <!-- Show during request -->
hx-push-url="true" <!-- Update browser URL -->
hx-replace-url="true" <!-- Replace URL (no history) -->
Atrybuty Alpine.js
x-data="{ open: false }" <!-- Component scope + state -->
x-show="open" <!-- Toggle visibility -->
x-cloak <!-- Hide until Alpine inits -->
@click="open = !open" <!-- Event handler -->
@click.away="open = false" <!-- Outside click -->
@keydown.escape="open = false" <!-- Keyboard event -->
:class="{ 'active': open }" <!-- Dynamic class -->
:aria-expanded="open" <!-- Dynamic attribute -->
x-text="count" <!-- Dynamic text content -->
x-init="fetchData()" <!-- Run on init -->
Właściwości niestandardowe CSS
:root {
--color-bg: #000000;
--color-text: #ffffff;
--spacing-sm: 1rem;
--spacing-md: 1.5rem;
--font-size-lg: 1.25rem;
}
@media (max-width: 768px) {
:root { --gutter: 24px; }
}
Nagłówki bezpieczeństwa
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Cross-Origin-Opener-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Lista kontrolna konfiguracji projektu
[ ] FastAPI app with Jinja2Templates
[ ] Security headers middleware (CSP, HSTS, X-Frame-Options)
[ ] CSRF token generation and validation
[ ] GZip middleware (minimum_size=500)
[ ] Content-hash asset versioning (cache busting)
[ ] HTMX self-hosted in /static/js/vendor/
[ ] Alpine.js self-hosted in /static/js/vendor/
[ ] CSS custom properties for design tokens
[ ] Health check endpoint (/health)
[ ] Error handlers (404, 500)
[ ] robots.txt, sitemap.xml, llms.txt
[ ] JSON-LD structured data in base template
[ ] Hreflang tags for i18n (if multi-language)
[ ] HTML sanitization filter (nh3)
[ ] Rate limiting middleware
[ ] Deferred script loading
Najczęściej zadawane pytania
Czy HTMX nadaje się do produkcyjnych aplikacji webowych?
Tak. HTMX jest stabilny od 2020 roku i wykorzystywany produkcyjnie w wielu branżach. Carson Gross, twórca biblioteki, traktuje kompatybilność wsteczną jako fundamentalną zasadę projektową — dokumentacja HTMX stwierdza, że biblioteka nie będzie łamać istniejących aplikacji w ramach głównej wersji.20 Biblioteka waży 14 KB po minifikacji i gzip, nie ma żadnych zależności i stosuje wersjonowanie semantyczne. Witryna blakecrosley.com działa na HTMX w produkcji od trzech lat bez ani jednego błędu związanego z HTMX.
Czy można używać TypeScript bez etapu kompilacji?
Częściowo. Pliki TypeScript można sprawdzać typami za pomocą tsc --noEmit bez generowania plików wyjściowych, co zapewnia kontrolę typów na etapie kompilacji — działając jak linter. Przeglądarki nie potrafią jednak bezpośrednio wykonywać plików .ts, więc etap kompilacji jest nadal konieczny do ich serwowania. Alternatywą są adnotacje typów JSDoc w zwykłych plikach .js, które TypeScript może sprawdzać bez kompilacji. Daje to bezpieczeństwo typów podczas rozwoju, jednocześnie dostarczając standardowy JavaScript.
Jak to podejście wypada w porównaniu z Astro lub 11ty?
Astro i 11ty to generatory stron statycznych, które tworzą czysty HTML z minimalnym JavaScript po stronie klienta, ale wymagają etapu kompilacji (Node.js, npm install, polecenie build). Podejście bez kompilacji eliminuje ten krok — serwer renderuje HTML przy każdym żądaniu. Kompromis: Astro/11ty generują szybsze strony statyczne (brak obliczeń serwerowych), natomiast FastAPI + HTMX natywnie obsługuje treści dynamiczne (dane użytkownika, formularze, aktualizacje w czasie rzeczywistym) bez dodatkowej warstwy API.
A co z renderowaniem po stronie serwera (SSR) w React?
Next.js SSR i podejście FastAPI + HTMX mają wspólny cel: wysłać wyrenderowany na serwerze HTML do przeglądarki. Różnica tkwi w tym, co dzieje się po początkowym renderowaniu. Next.js hydratuje stronę za pomocą React, wysyłając runtime frameworka i kod komponentów do klienta. FastAPI + HTMX nie wykonuje hydracji — HTML jest końcowym produktem. HTMX obsługuje kolejne interakcje, żądając nowych fragmentów HTML z serwera. Rezultat: FastAPI + HTMX dostarcza łącznie 30–40 KB JavaScript, podczas gdy aplikacja Next.js 100–300 KB.19
Jak obsługiwać walidację formularzy w tym stacku?
Po stronie serwera. Pydantic waliduje dane wejściowe w momencie wysłania formularza. Jeśli walidacja się nie powiedzie, serwer zwraca formularz z komunikatami o błędach. HTMX podmienia odpowiedź w DOM:
<form hx-post="/contact" hx-target="#form-container" hx-swap="outerHTML">
<input type="email" name="email" required>
<button type="submit">Send</button>
</form>
@router.post("/contact")
async def contact(request: Request, email: str = Form(...)):
if not validate_email(email):
return templates.TemplateResponse("components/_contact_form.html", {
"request": request,
"error": "Please enter a valid email address",
"email": email, # Preserve input
})
await send_email(email)
return templates.TemplateResponse("components/_contact_success.html", {
"request": request
})
Serwer waliduje, serwer renderuje stany błędów, a HTMX podmienia wynik. Nie jest potrzebna żadna biblioteka walidacji po stronie klienta. Atrybut HTML required zapewnia podstawową walidację na poziomie przeglądarki jako pierwszą linię obrony.
Czy mogę dodać funkcje czasu rzeczywistego (WebSocket)?
Tak. FastAPI ma wbudowaną obsługę WebSocket:
from fastapi import WebSocket
@app.websocket("/ws/notifications")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
while True:
data = await get_notification()
await websocket.send_text(render_notification_html(data))
HTMX posiada rozszerzenie WebSocket (hx-ws), które łączy elementy z punktami końcowymi WebSocket:
<div hx-ws="connect:/ws/notifications">
<div id="notifications" hx-ws="send"></div>
</div>
Wiadomości z serwera są wstawiane do DOM przy użyciu tych samych mechanizmów targetowania i podmiany co odpowiedzi HTTP. Serwer wysyła fragmenty HTML przez WebSocket, a HTMX je wstawia.
Jak ten stack radzi sobie z SEO?
HTML renderowany po stronie serwera jest z natury przyjazny dla SEO, ponieważ crawlery otrzymują pełną zawartość strony bez konieczności wykonywania JavaScript. Witryna blakecrosley.com dodaje kilka warstw SEO:
- Dane strukturalne JSON-LD w
<head>na każdej stronie (schematy Person, Article, WebSite, FAQPage) - Dynamiczna mapa witryny z alternatywami hreflang dla wszystkich 10 lokalizacji
- Kanał RSS pod adresem
/blog/feed.xml llms.txtw katalogu głównym dla wykrywalności przez crawlery AI- Kanoniczne adresy URL i tagi Open Graph w szablonie bazowym
- Semantyczny HTML:
<article>,<section>,<main>, prawidłowa hierarchia nagłówków
Nie trzeba konfigurować SSR. Żadnego getStaticProps. Żadnego ISR. HTML jest renderowany przy każdym żądaniu — to domyślne zachowanie, nie optymalizacja.
Jaka jest krzywa uczenia się w porównaniu z React?
Dla programistów Python krzywa uczenia się jest znacznie łagodniejsza. Język jest już znany. Handlery tras FastAPI zwracają odpowiedzi szablonowe — ten sam model mentalny co widoki Flask czy Django. HTMX dodaje kilka atrybutów HTML (hx-get, hx-target, hx-swap). Alpine.js dodaje kilka kolejnych (x-data, x-show, @click). Nie ma JSX, wirtualnego DOM, systemu hooków, biblioteki zarządzania stanem ani konfiguracji narzędzi kompilacji do opanowania.
Dokumentacja HTMX mieści się na jednej długiej stronie. Dokumentacja Alpine.js na kilku stronach. Dokumentacja React obejmuje setki stron poświęconych hookom, kontekstowi, refom, efektom, suspense, komponentom serwerowym i strumieniowemu SSR.
Dla programistów JavaScript/React zmiana jest raczej koncepcyjna niż składniowa. Kluczowe spostrzeżenie: serwer jest właścicielem stanu i serwer renderuje HTML. Zarządzanie stanem po stronie klienta zamienia się w obsługę tras po stronie serwera. Pobieranie danych po stronie klienta zamienia się w atrybuty HTMX na elementach HTML. Składnia jest prostsza — model mentalny wymaga oduczenia się założenia SPA, że klient jest właścicielem renderowania.
Dziennik zmian
| Data | Zmiana |
|---|---|
| 24 marca 2026 | Pierwsza publikacja |
Przypisy
Ten przewodnik obejmuje kompletny system użyty do budowy blakecrosley.com. The No-Build Manifesto przedstawia argumentację filozoficzną. Wpis Lighthouse Perfect Score dokumentuje ścieżkę optymalizacji wydajności. Artykuł Vibe Coding vs. Engineering analizuje, w jaki sposób programowanie wspomagane przez AI wpisuje się w ten przepływ pracy.
-
Dane produkcyjne blakecrosley.com na marzec 2026. Strona obsługuje 37 wpisów blogowych, 20 interaktywnych komponentów JavaScript, 20 sekcji przewodników oraz 10 tłumaczeń językowych przy użyciu 15 pakietów Python i zerowej liczbie narzędzi do budowania. Pełna lista zależności: fastapi, uvicorn, starlette, pydantic, pydantic-settings, jinja2, markdown, pygments, beautifulsoup4, lxml, nh3, resend, python-multipart, httpx, analytics-941. Zweryfikowano na podstawie
requirements.txt. ↩↩↩ -
Google PageSpeed Insights (pagespeed.web.dev) przeprowadza audyty Lighthouse dla dowolnego publicznego adresu URL. blakecrosley.com uzyskuje wynik 100/100/100/100 (Wydajność, Dostępność, Najlepsze praktyki, SEO) na marzec 2026. Wyniki można zweryfikować publicznie. Szczegóły w artykule From 76 to 100: Achieving a Perfect Lighthouse Score, opisującym pełną ścieżkę optymalizacji. ↩↩↩
-
Świeża instalacja
npx create-next-app@latest(Next.js 15, testowane w lutym 2026) pobiera 311 pakietów donode_modules/o łącznym rozmiarze 187 MB. Projekty produkcyjne z dodatkowymi zależnościami osiągają wyższe wartości. Poszczególne projekty mogą się różnić. Źródło: testy autora, udokumentowane w The No-Build Manifesto. ↩ -
Dokumentacja wydajności Next.js firmy Vercel zaleca konkretne optymalizacje (optymalizacja obrazów, ładowanie czcionek, dzielenie kodu) w celu osiągnięcia wyników powyżej 90. Patrz nextjs.org/docs/app/building-your-application/optimizing. Zakres 70-90 odzwierciedla ustawienia domyślne przed zastosowaniem tych optymalizacji. ↩
-
Pełna lista zależności zweryfikowana na podstawie pliku
requirements.txtblakecrosley.com na marzec 2026. Żaden pakiet nie jest narzędziem do budowania, kompilatorem ani bundlerem. ↩ -
Na podstawie doświadczenia autora w utrzymywaniu projektów Next.js (2021-2024) ekosystem JavaScript generuje 15-25 PR-ów Dependabot miesięcznie dla aktywnych projektów, z czego większość aktualizuje zależności przechodnie, których programista nigdy bezpośrednio nie importował. ↩
-
Tim Berners-Lee sformułował kompatybilność wsteczną jako zasadę projektowania sieci: „przeglądarka powinna być kompatybilna wstecz”. Strona z 1996 roku renderuje się w Chrome 2026. Patrz w3.org/DesignIssues/Principles. ↩
-
OWASP zaleca wyłączanie punktów końcowych dokumentacji API w środowisku produkcyjnym w celu zmniejszenia powierzchni ataku. Punkt końcowy
/openapi.jsonujawnia wszystkie definicje tras, parametry oraz modele odpowiedzi. ↩ -
Dokumentacja FastAPI dotycząca obsługi asynchronicznej i synchronicznej: fastapi.tiangolo.com/async/. Mieszanie
awaitz blokującymi wywołaniami w funkcjachasyncpowoduje zagłodzenie pętli zdarzeń. ↩ -
nh3 to oparty na Rust sanitizer HTML, następca biblioteki Bleach. Jest utrzymywany przez projekt PyO3 i zapewnia sanityzację HTML opartą na liście dozwolonych elementów. Patrz github.com/messense/nh3. ↩
-
Nagłówek
Varyjest zdefiniowany w RFC 9110 sekcja 12.5.5. Instruuje pamięci podręczne, aby przechowywały osobne odpowiedzi na podstawie określonych wartości nagłówków żądania. BezVary: HX-RequestCDN mógłby dostarczyć fragment HTMX jako pełną odpowiedź strony. Patrz httpwg.org/specs/rfc9110.html#field.vary. ↩↩ -
CSS Custom Properties (zmienne CSS) są obsługiwane w ponad 97% przeglądarek na świecie. Podlegają kaskadowości, dziedziczeniu i reagują na zapytania mediów w czasie wykonywania — możliwości, których zmienne preprocesorów nie posiadają. Źródło: caniuse.com/css-variables. ↩
-
Dokumentacja Google dotycząca hreflang: developers.google.com/search/docs/specialty/international/localized-versions. Wartość
x-defaultoznacza stronę zastępczą dla użytkowników, których język nie znajduje się na liście hreflang. ↩ -
Alpine.js wymaga
'unsafe-eval'w Content Security Policy dla swojego silnika ewaluacji wyrażeń. Wersja kompatybilna z CSP (@alpinejs/csp) eliminuje ten wymóg, ale ma pewne ograniczenia. Patrz alpinejs.dev/advanced/csp. ↩ -
Tokeny CSRF oparte na HMAC stosują wzorzec „Signed Double-Submit Cookie” opisany w ściągawce OWASP dotyczącej zapobiegania CSRF. Funkcja
hmac.compare_digestwykorzystuje porównanie w czasie stałym, aby zapobiec atakom kanałem bocznym opartym na pomiarze czasu. Patrz cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html. ↩ -
Technika asynchronicznego ładowania CSS za pomocą
media="print"jest udokumentowana przez zespół web.dev. Przeglądarka traktuje arkusz stylów jako nieblokujący renderowanie, ponieważ jest zadeklarowany dla mediów drukowanych. Procedura obsługionloadzmienia go na mediaallpo pobraniu. Patrz web.dev/articles/defer-non-critical-css. ↩ -
WebP zapewnia pliki o 25-35% mniejsze niż JPEG przy porównywalnej jakości wizualnej. Badanie Google dotyczące WebP: developers.google.com/speed/webp/docs/webp_study. ↩
-
103 Early Hints umożliwia serwerowi (lub CDN) wysłanie wstępnej odpowiedzi ze wskazówkami dotyczącymi wstępnego ładowania przed przygotowaniem ostatecznej odpowiedzi. Cloudflare obsługuje Early Hints dla nagłówków
Linkzrel=preload. Patrz developer.chrome.com/blog/early-hints. ↩ -
React 18 + ReactDOM waży około 42 KB po minifikacji i kompresji gzip. Z routerem, biblioteką zarządzania stanem i środowiskiem uruchomieniowym frameworka do budowania typowe aplikacje React dostarczają 100-300 KB kodu JavaScript frameworka. Źródło: bundlephobia.com/package/[email protected]. ↩↩
-
Polityka wersjonowania HTMX oraz zobowiązanie do kompatybilności wstecznej są udokumentowane na htmx.org/migration-guide-htmx-1/. Carson Gross sformułował zasadę kompatybilności wstecznej w książce Hypermedia Systems (2023) autorstwa Grossa, Stepinskiego i Cottera: hypermedia.systems. ↩