Files
intotheeast-com/docs/working/specs/2026-06-22-asset-pipeline-design.md
T
m038 dc01d943f3 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
2026-06-22 22:43:53 +02:00

226 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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