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
12 KiB
Asset Pipeline & Frontend Reliability Design
Date: 2026-06-22 Status: Approved for implementation
Problem
The theme's frontend has three compounding reliability risks:
- 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.
- Duplicated JS logic — The same behaviour is copy-pasted across multiple templates. Changing anything means finding and editing every copy.
- 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.twigbeyond 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.
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
{
"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) anddailies.html.twig(lines 66–128). - PhotoSwipe CSS —
import 'photoswipe/dist/photoswipe.css'→ extracted tocss-compiled/main.cssby esbuild. - Photo strip — scroll dots sync (IntersectionObserver) + prev/next buttons + expand button. Currently in
base.html.twig(lines 30–73, IIFE) and duplicated intrip.html.twig+dailies.html.twig(the IntersectionObserver variant). - Sort feed button — flip ascending/descending, update button text + aria-label, toggle
.is-active. Currently intrip.html.twig,dailies.html.twig,stories.html.twigwith slightly different container selectors. Unified asinitSortButton(btnId, containerSelector, itemSelector). - Back-to-top button — scroll threshold show/hide + smooth scroll. Currently in
trip.html.twig(lines 544–561) andstory.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.twigcan use it without a CDN tag. Only activates if.scrollyelements 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 tofonts/and updates CSS references. - DM Serif Display —
import '@fontsource/dm-serif-display/400.css'andimport '@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.maplibreglso existing template inline map init scripts can reference it unchanged. - MapLibre GL CSS —
import 'maplibre-gl/dist/maplibre-gl.css'→ extracted tocss-compiled/map.css. - @mapbox/togeojson — attached to
window.toGeoJSON. ../maplibre-utils.js— existing file, unchanged. Attacheswindow.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 tomain.js - Add Asset Manager registrations:
{% do assets.addCss('theme://css-compiled/main.css') %} {% do assets.addJs('theme://js/main.js', {group: 'bottom'}) %} - Add block for map pages:
{% 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 — requireshaversineKmto be added toMapUtilsexports inmaplibre-utils.jsfirst, then the localparseGpxFilescaller updated to useMapUtils.haversineKm - Remove the following inline
<script>blocks (all move tomain.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
makePanelTogglecalls (lines 523–541)
- PhotoSwipe init (lines 566–626,
- Keep the map init block (
tripMap = new maplibregl.Map(...)through totripMap.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:
{% 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):{# 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 inmain.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— committednode_modules/— gitignored (add touser/repo.gitignore)js/main.js,js/map.js— committed (Grav serves these directly)css-compiled/main.css,css-compiled/map.css— committedfonts/*.woff2— committedjs/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:
- Trip overview page loads — map renders, GPX track visible, markers clickable
- Sort button (↑/↓) reverses feed order
- Filter bar (All / Journal / Stories) shows/hides cards correctly
- Stats and Cycling panels open and close
- Back-to-top button appears after scrolling and scrolls to top
- Journal photo strip — dots sync on scroll, prev/next navigate, expand opens PhotoSwipe
- Story page — hero scroll effect, scroll-cue hides, title fades into nav, back-to-top works
- No requests to
cdn.jsdelivr.net,fonts.googleapis.com, orfonts.gstatic.comin browser network tab