docs: add asset pipeline and frontend reliability design spec

Covers esbuild via Docker, self-hosted CDN deps and fonts, JS
deduplication strategy, Grav-idiomatic output structure, and
template changes for trip.html.twig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
This commit is contained in:
2026-06-22 22:43:53 +02:00
parent be673b2135
commit dc01d943f3
@@ -0,0 +1,225 @@
# Asset Pipeline & Frontend Reliability Design
**Date:** 2026-06-22
**Status:** Approved for implementation
## Problem
The theme's frontend has three compounding reliability risks:
1. **CDN dependency** — MapLibre GL, PhotoSwipe, Scrollama, toGeoJSON, and Google Fonts all load from external CDNs. A CDN outage or rate-limit takes the site down visually.
2. **Duplicated JS logic** — The same behaviour is copy-pasted across multiple templates. Changing anything means finding and editing every copy.
3. **No central loading strategy** — CDN `<script>` and `<link>` tags are scattered across templates, some loading the same library multiple times in different contexts.
| Duplicated logic | Templates | Copies |
|---|---|---|
| PhotoSwipe lightbox init (~55 lines) | `trip.html.twig`, `dailies.html.twig` | 2 |
| Photo strip IntersectionObserver + expand btn | `trip.html.twig`, `dailies.html.twig` | 2 |
| Sort feed button | `trip.html.twig`, `dailies.html.twig`, `stories.html.twig` | 3 |
| Back-to-top button | `trip.html.twig`, `story.html.twig` | 2 |
| `haversineKm()` function | `maplibre-utils.js`, `trip.html.twig` | 2 |
| Fullscreen map toggle | `feed-map.html.twig`, `trip.html.twig` | 2 |
| MapLibre CDN script tags | `trip.html.twig`, `map.html.twig`, `feed-map.html.twig` | 3 |
## Goals
- Eliminate all CDN dependencies (JS, CSS, fonts) — site works with no internet access to external hosts
- Every shared behaviour lives in exactly one place
- No new framework complexity — the fix is organisation, not replacement
- Grav-idiomatic output structure (follows Quark theme conventions)
- Build runs via Docker — no local Node.js required
## Non-goals
- Refactoring map initialisation code across `trip.html.twig`, `feed-map.html.twig`, `map.html.twig` — deferred (see below)
- CSS framework adoption — the existing token system is sound
- Changes to `dailies.html.twig`, `stories.html.twig`, `map.html.twig` beyond removing CDN tags — those pages are not in active use
## Architecture
### Build system
A single `make build-assets` command runs a throwaway Docker Node 20 Alpine container, mounts the theme directory, runs `npm ci && npm run build`, and exits. Output files are committed to the repo and served directly by Grav.
```makefile
build-assets:
docker run --rm \
-v $(PWD)/user/themes/intotheeast:/app \
-w /app node:20-alpine \
sh -c "npm ci && npm run build"
```
The build command (in `package.json`) runs esbuild twice — once per entry point — and outputs IIFE bundles so they work as regular scripts without `type="module"`.
### Output structure (Grav-idiomatic)
Follows the Quark reference theme convention (`css-compiled/` for generated CSS, `fonts/` at theme root):
```
user/themes/intotheeast/
├── package.json ← new
├── package-lock.json ← new, committed
├── node_modules/ ← gitignored
├── js/
│ ├── src/
│ │ ├── main.js ← new entry point
│ │ └── map.js ← new entry point
│ ├── main.js ← esbuild output, committed
│ ├── map.js ← esbuild output, committed
│ └── maplibre-utils.js ← existing, unchanged, imported by src/map.js
├── css-compiled/ ← new, Grav convention for generated CSS
│ ├── main.css ← PhotoSwipe CSS extracted by esbuild
│ └── map.css ← MapLibre GL CSS extracted by esbuild
├── fonts/ ← new, self-hosted woff2 files
│ └── *.woff2 ← copied by esbuild from @fontsource packages
├── css/
│ ├── tokens.css ← unchanged
│ └── style.css ← updated: remove Google Fonts @import, add @font-face
└── templates/
└── partials/
└── base.html.twig ← updated (see Template changes)
```
### npm dependencies
```json
{
"dependencies": {
"maplibre-gl": "^4",
"photoswipe": "^5",
"scrollama": "^3",
"@mapbox/togeojson": "^0.16.2",
"@fontsource-variable/dm-sans": "latest",
"@fontsource/dm-serif-display": "latest"
},
"devDependencies": {
"esbuild": "^0.21"
}
}
```
### Bundle 1: `js/src/main.js` → `js/main.js`
Loads on every page. Imports and initialises:
- **PhotoSwipe 5** — lightbox init with keyboard arrow navigation and CSS transition classes. Currently copy-pasted verbatim in `trip.html.twig` (lines 566626) and `dailies.html.twig` (lines 66128).
- **PhotoSwipe CSS** — `import 'photoswipe/dist/photoswipe.css'` → extracted to `css-compiled/main.css` by esbuild.
- **Photo strip** — scroll dots sync (IntersectionObserver) + prev/next buttons + expand button. Currently in `base.html.twig` (lines 3073, IIFE) and duplicated in `trip.html.twig` + `dailies.html.twig` (the IntersectionObserver variant).
- **Sort feed button** — flip ascending/descending, update button text + aria-label, toggle `.is-active`. Currently in `trip.html.twig`, `dailies.html.twig`, `stories.html.twig` with slightly different container selectors. Unified as `initSortButton(btnId, containerSelector, itemSelector)`.
- **Back-to-top button** — scroll threshold show/hide + smooth scroll. Currently in `trip.html.twig` (lines 544561) and `story.html.twig` (lines 140156).
- **Panel toggles** — open/close collapsible stat and cycling panels. Currently inline in `trip.html.twig`.
- **Scrollama** — imported here so `story.html.twig` can use it without a CDN tag. Only activates if `.scrolly` elements exist on the page.
- **DM Sans variable font** — `import '@fontsource-variable/dm-sans/index.css'` covers weights 100900 including italic axis. esbuild copies woff2 to `fonts/` and updates CSS references.
- **DM Serif Display** — `import '@fontsource/dm-serif-display/400.css'` and `import '@fontsource/dm-serif-display/400-italic.css'`.
All initialisers are called unconditionally — each guards with `if (!document.querySelector(...)) return` so they are silent no-ops on pages where the relevant elements don't exist.
### Bundle 2: `js/src/map.js` → `js/map.js`
Loads only on pages with a map. Imports:
- **maplibre-gl** — attached to `window.maplibregl` so existing template inline map init scripts can reference it unchanged.
- **MapLibre GL CSS** — `import 'maplibre-gl/dist/maplibre-gl.css'` → extracted to `css-compiled/map.css`.
- **@mapbox/togeojson** — attached to `window.toGeoJSON`.
- **`../maplibre-utils.js`** — existing file, unchanged. Attaches `window.MapUtils`.
The existing map initialisation code in `trip.html.twig`, `feed-map.html.twig`, and `map.html.twig` references `maplibregl`, `toGeoJSON`, and `MapUtils` as globals — this bundle provides them without changing any of that code.
### Template changes
#### `base.html.twig`
- **Remove** lines 79: Google Fonts `<link rel="preconnect">` and `<link href="fonts.googleapis.com/...">` tags
- **Remove** lines 3073: inline `<script>` block (photo strip IIFE) — moves to `main.js`
- **Add** Asset Manager registrations:
```twig
{% do assets.addCss('theme://css-compiled/main.css') %}
{% do assets.addJs('theme://js/main.js', {group: 'bottom'}) %}
```
- **Add** block for map pages:
```twig
{% block map_assets %}{% endblock %}
```
#### `trip.html.twig`
- **Remove** lines 247250: MapLibre GL CSS link + MapLibre JS + toGeoJSON JS + maplibre-utils.js script tags
- **Remove** lines 393401: duplicate `haversineKm()` function — requires `haversineKm` to be added to `MapUtils` exports in `maplibre-utils.js` first, then the local `parseGpxFiles` caller updated to use `MapUtils.haversineKm`
- **Remove** the following inline `<script>` blocks (all move to `main.js`):
- PhotoSwipe init (lines 566626, `<script type="module">`)
- Sort toggle IIFE (lines 373388)
- Filter bar IIFE (lines 339371)
- Back-to-top IIFE (lines 544561)
- Panel toggle `makePanelToggle` calls (lines 523541)
- **Keep** the map init block (`tripMap = new maplibregl.Map(...)` through to `tripMap.resize()`)
- **Keep** the GPX parsing and cycling stats block (lines 403542) — this is trip-specific logic, not shared
- **Fill** the new map_assets block:
```twig
{% block map_assets %}
{% do assets.addCss('theme://css-compiled/map.css') %}
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
{% endblock %}
```
- **Add** PhotoSwipe CSS registration (currently a hardcoded `<link>` at line 4):
```twig
{# PhotoSwipe CSS now bundled in css-compiled/main.css — remove line 4 #}
```
**Expected result:** `trip.html.twig` goes from ~627 lines to ~300 lines — Twig data logic + map init + GPX stats block only.
#### `feed-map.html.twig`, `map.html.twig`
- Remove CDN `<script>` and `<link>` tags for MapLibre, toGeoJSON, maplibre-utils
- Fill `{% block map_assets %}` with the same Asset Manager registrations as trip.html.twig
- Map init code unchanged
#### `story.html.twig`
- Remove Scrollama CDN `<script>` tag (line 72) — Scrollama now bundled in `main.js`
- No other changes; all its inline JS is story-specific
### Font self-hosting
`@fontsource-variable/dm-sans` provides the variable font (weight + italic axes, single woff2 file). `@fontsource/dm-serif-display` provides static 400 and 400-italic. esbuild copies woff2 files to `fonts/` and generates `@font-face` declarations in the extracted CSS.
`css/style.css` — remove the Google Fonts `@import` if present; font-family declarations remain unchanged since the CSS custom property names (`--font-display`, `--font-ui`) stay the same.
### Git strategy
- `package.json`, `package-lock.json` — committed
- `node_modules/` — gitignored (add to `user/` repo `.gitignore`)
- `js/main.js`, `js/map.js` — committed (Grav serves these directly)
- `css-compiled/main.css`, `css-compiled/map.css` — committed
- `fonts/*.woff2` — committed
- `js/src/` — committed (source of truth for the bundles)
Run `make build-assets` after updating any npm dependency. No need to run it for template or CSS changes.
## Deferred
### Map initialisation refactor
The three map init blocks (`trip.html.twig`, `feed-map.html.twig`, `map.html.twig`) are intentionally different:
- **trip**: sidebar column, journal + story markers, click scrolls feed card, fullscreen targets `.home-map-col`
- **feed-map**: mini-map partial, one content type, click scrolls or navigates, Twig-interpolated JS variable names
- **map**: full-page, click navigates to entry URL, has zoom controls
Only `trip.html.twig` is in active use. The refactor — extracting a shared config-driven init into `maplibre-utils.js` — is deferred until the other pages become relevant.
### `dailies.html.twig` and `stories.html.twig` JS cleanup
Sort button, filter bar, and other inline JS remain in these templates for now. They will be cleaned up (calling shared functions from `main.js` instead of re-implementing) when those pages are actively developed.
## Testing
After `make build-assets`:
1. Trip overview page loads — map renders, GPX track visible, markers clickable
2. Sort button (↑/↓) reverses feed order
3. Filter bar (All / Journal / Stories) shows/hides cards correctly
4. Stats and Cycling panels open and close
5. Back-to-top button appears after scrolling and scrolls to top
6. Journal photo strip — dots sync on scroll, prev/next navigate, expand opens PhotoSwipe
7. Story page — hero scroll effect, scroll-cue hides, title fades into nav, back-to-top works
8. No requests to `cdn.jsdelivr.net`, `fonts.googleapis.com`, or `fonts.gstatic.com` in browser network tab