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:
@@ -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 566–626) and `dailies.html.twig` (lines 66–128).
|
||||
- **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 30–73, 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 544–561) and `story.html.twig` (lines 140–156).
|
||||
- **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 100–900 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 7–9: Google Fonts `<link rel="preconnect">` and `<link href="fonts.googleapis.com/...">` tags
|
||||
- **Remove** lines 30–73: 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 247–250: MapLibre GL CSS link + MapLibre JS + toGeoJSON JS + maplibre-utils.js script tags
|
||||
- **Remove** lines 393–401: 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 566–626, `<script type="module">`)
|
||||
- Sort toggle IIFE (lines 373–388)
|
||||
- Filter bar IIFE (lines 339–371)
|
||||
- Back-to-top IIFE (lines 544–561)
|
||||
- Panel toggle `makePanelToggle` calls (lines 523–541)
|
||||
- **Keep** the map init block (`tripMap = new maplibregl.Map(...)` through to `tripMap.resize()`)
|
||||
- **Keep** the GPX parsing and cycling stats block (lines 403–542) — 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
|
||||
Reference in New Issue
Block a user