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

12 KiB
Raw Blame History

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.

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.jsjs/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 CSSimport '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 fontimport '@fontsource-variable/dm-sans/index.css' covers weights 100900 including italic axis. esbuild copies woff2 to fonts/ and updates CSS references.
  • DM Serif Displayimport '@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.jsjs/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 CSSimport '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:
    {% 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 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:
    {% 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 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