Files
intotheeast-com/docs/working/plans/2026-06-22-asset-pipeline.md
T
m038 bd906005e4 docs: add asset pipeline implementation plan
8 tasks: build scaffolding, main/map bundles, maplibre-utils extension,
template CDN cleanup, and end-to-end verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
2026-06-22 23:02:34 +02:00

38 KiB
Raw Blame History

Asset Pipeline & Frontend Reliability Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Eliminate all CDN dependencies, self-host fonts, and deduplicate shared JS logic into versioned bundles built via Docker.

Architecture: esbuild (via throwaway Docker Node container) produces two IIFE bundles — js/main.js (universal UI + fonts) and js/map.js (MapLibre + GPX utils) — plus extracted CSS in css-compiled/. Templates register assets via Grav's Asset Manager instead of hardcoded CDN tags. All duplicated inline JS moves to the main bundle; map init code stays in templates.

Tech Stack: esbuild 0.21+, Node 20 Alpine (Docker only), MapLibre GL 4, PhotoSwipe 5, Scrollama 3, @mapbox/togeojson 0.16, @fontsource-variable/dm-sans, @fontsource/dm-serif-display, Grav 2.0 Asset Manager.

Global Constraints

  • All commands run inside Docker — no local Node.js required
  • Output files (js/main.js, js/map.js, css-compiled/*.css, fonts/*.woff2) are committed to the user/ repo
  • node_modules/ is gitignored
  • Working directory for all file edits: user/themes/intotheeast/
  • All JS bundles use --format=iife so templates can reference maplibregl, MapUtils, toGeoJSON as window globals
  • Grav Asset Manager is used for all asset registration — no hardcoded <script>/<link> tags in templates
  • Map init code stays inline in templates (trip.html.twig, feed-map.html.twig, map.html.twig) — only CDN tags and duplicated utility JS move out
  • Dev server: http://localhost:8081

File Map

Action Path
Create user/themes/intotheeast/package.json
Create user/themes/intotheeast/js/src/main.js
Create user/themes/intotheeast/js/src/map.js
Create user/themes/intotheeast/css-compiled/ (by esbuild)
Create user/themes/intotheeast/fonts/ (by esbuild)
Modify Makefile — add build-assets target
Modify user/.gitignore — add themes/intotheeast/node_modules/
Modify user/themes/intotheeast/js/maplibre-utils.js — add parseGpxFiles, export haversineKm
Modify user/themes/intotheeast/css/style.css — remove Google Fonts @import
Modify user/themes/intotheeast/templates/partials/base.html.twig
Modify user/themes/intotheeast/templates/trip.html.twig
Modify user/themes/intotheeast/templates/partials/feed-map.html.twig
Modify user/themes/intotheeast/templates/map.html.twig
Modify user/themes/intotheeast/templates/story.html.twig

Task 1: Build scaffolding

Set up package.json, the Docker make build-assets target, and gitignore. Verify the Docker build completes.

Files:

  • Create: user/themes/intotheeast/package.json
  • Modify: Makefile (add build-assets target after existing build target)
  • Modify: user/.gitignore (add node_modules line)

Interfaces:

  • Produces: make build-assets command that runs npm ci && npm run build in Docker Node 20 Alpine

  • Step 1: Create package.json

Create user/themes/intotheeast/package.json:

{
  "private": true,
  "scripts": {
    "build": "esbuild js/src/main.js --bundle --minify --format=iife --outfile=js/main.js --loader:.woff2=file --loader:.woff=file --asset-names=../fonts/[name] && esbuild js/src/map.js --bundle --minify --format=iife --outfile=js/map.js && mkdir -p css-compiled fonts && mv js/main.css css-compiled/main.css && mv js/map.css css-compiled/map.css"
  },
  "dependencies": {
    "@fontsource-variable/dm-sans": "latest",
    "@fontsource/dm-serif-display": "latest",
    "@mapbox/togeojson": "^0.16.2",
    "maplibre-gl": "^4",
    "photoswipe": "^5",
    "scrollama": "^3"
  },
  "devDependencies": {
    "esbuild": "^0.21"
  }
}
  • Step 2: Add build-assets target to Makefile

Add after the existing build: target in Makefile:

build-assets:
	docker run --rm \
	  -v $(PWD)/user/themes/intotheeast:/app \
	  -w /app node:20-alpine \
	  sh -c "npm install && npm run build"
  • Step 3: Add node_modules to user/ gitignore

Add to user/.gitignore:

/themes/intotheeast/node_modules/
  • Step 4: Create placeholder source files so the build has something to process

Create user/themes/intotheeast/js/src/main.js:

// placeholder — replaced in Task 2

Create user/themes/intotheeast/js/src/map.js:

// placeholder — replaced in Task 4
  • Step 5: Run the build and verify it completes
make build-assets

Expected: Docker pulls node:20-alpine, runs npm install (generates package-lock.json), runs npm run build. Build will warn about empty entry points but should exit 0. Verify these files exist:

ls user/themes/intotheeast/js/main.js
ls user/themes/intotheeast/js/map.js
  • Step 6: Commit
git -C user add themes/intotheeast/package.json themes/intotheeast/package-lock.json themes/intotheeast/js/src/main.js themes/intotheeast/js/src/map.js .gitignore
git -C user commit -m "build: add esbuild scaffolding and Docker build-assets target"
git add Makefile
git commit -m "build: add build-assets make target"

Task 2: Main JS bundle — fonts, PhotoSwipe, all UI utilities

Write the full js/src/main.js. This is the single source of truth for all duplicated UI behaviour.

Files:

  • Modify: user/themes/intotheeast/js/src/main.js (replace placeholder)

Interfaces:

  • Consumes: nothing from other tasks

  • Produces:

    • js/main.js — IIFE bundle, no exports (all inits called on DOMContentLoaded)
    • css-compiled/main.css — PhotoSwipe CSS + @font-face rules for DM Sans variable + DM Serif Display
    • fonts/*.woff2 — copied from @fontsource packages by esbuild
  • Step 1: Write js/src/main.js

Replace user/themes/intotheeast/js/src/main.js with:

/* ── Fonts ───────────────────────────────────────────────── */
import '@fontsource-variable/dm-sans';
import '@fontsource/dm-serif-display/400.css';
import '@fontsource/dm-serif-display/400-italic.css';

/* ── PhotoSwipe ──────────────────────────────────────────── */
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
import 'photoswipe/style.css';

/* ── Scrollama (used by story.html.twig inline script) ────── */
import scrollama from 'scrollama';
window.scrollama = scrollama;

/* ── Photo strip: prev/next buttons + scroll-based dot sync ─ */
function initPhotoStrip() {
    document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
        strip.setAttribute('role', 'region');
        strip.setAttribute('aria-label', 'Photo strip');
        strip.setAttribute('tabindex', '0');

        var slideCount = parseInt(strip.dataset.slides, 10) || 1;
        var dots = strip.nextElementSibling;
        if (!dots || !dots.classList.contains('journal-photo-dots')) return;
        var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));

        strip.addEventListener('scroll', function () {
            var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
            dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
        }, { passive: true });

        if (slideCount < 2) return;

        var prev = document.createElement('button');
        prev.className = 'strip-prev';
        prev.setAttribute('aria-label', 'Previous photo');
        prev.textContent = '';
        prev.addEventListener('click', function () {
            strip.scrollBy({ left: -strip.offsetWidth, behavior: 'smooth' });
        });

        var next = document.createElement('button');
        next.className = 'strip-next';
        next.setAttribute('aria-label', 'Next photo');
        next.textContent = '';
        next.addEventListener('click', function () {
            strip.scrollBy({ left: strip.offsetWidth, behavior: 'smooth' });
        });

        var controls = document.createElement('div');
        controls.className = 'strip-controls';
        controls.appendChild(prev);
        controls.appendChild(next);
        var wrap = strip.closest('.journal-photo-wrap');
        (wrap || dots).insertAdjacentElement('afterend', controls);
    });
}

/* ── PhotoSwipe lightbox + IntersectionObserver dot sync ──── */
function initPhotoSwipe() {
    if (!document.querySelector('.pswp-gallery')) return;

    var lightbox = new PhotoSwipeLightbox({
        gallery: '.pswp-gallery',
        children: 'a.journal-photo-slide',
        pswpModule: PhotoSwipe
    });

    lightbox.on('afterOpen', function () {
        var pswp = lightbox.pswp;
        var keyDir = 0;
        var clearTimer = null;

        function onKey(e) {
            if (e.key === 'ArrowRight') keyDir = 1;
            else if (e.key === 'ArrowLeft') keyDir = -1;
            else keyDir = 0;
        }
        document.addEventListener('keydown', onKey, true);

        pswp.on('change', function () {
            if (!keyDir) return;
            var dir = keyDir;
            keyDir = 0;
            var el = pswp.currSlide && pswp.currSlide.container;
            if (!el) return;
            el.classList.remove('pswp-key-from-left', 'pswp-key-from-right');
            el.offsetWidth; /* force reflow */
            el.classList.add(dir > 0 ? 'pswp-key-from-right' : 'pswp-key-from-left');
            clearTimeout(clearTimer);
            clearTimer = setTimeout(function () {
                el.classList.remove('pswp-key-from-left', 'pswp-key-from-right');
            }, 400);
        });

        pswp.on('close', function () {
            document.removeEventListener('keydown', onKey, true);
            clearTimeout(clearTimer);
        });
    });

    lightbox.init();

    /* Per-strip: IntersectionObserver dot sync + expand button */
    document.querySelectorAll('.journal-photo-wrap').forEach(function (wrap) {
        var strip = wrap.querySelector('.journal-photo-strip');
        if (!strip) return;
        var slides = Array.from(strip.querySelectorAll('a.journal-photo-slide'));
        var expandBtn = wrap.querySelector('.journal-photo-expand');
        var article = wrap.closest('article');
        var dots = article ? Array.from(article.querySelectorAll('.journal-photo-dot')) : [];
        var visibleIdx = 0;

        var io = new IntersectionObserver(function (entries) {
            entries.forEach(function (e) {
                if (!e.isIntersecting) return;
                visibleIdx = slides.indexOf(e.target);
                dots.forEach(function (d) { d.classList.remove('is-active'); });
                if (dots[visibleIdx]) dots[visibleIdx].classList.add('is-active');
            });
        }, { root: strip, threshold: 0.5 });
        slides.forEach(function (s) { io.observe(s); });

        if (expandBtn && slides.length) {
            expandBtn.addEventListener('click', function () {
                slides[visibleIdx].dispatchEvent(
                    new MouseEvent('click', { bubbles: true, cancelable: true })
                );
            });
        }
    });
}

/* ── Sort button ─────────────────────────────────────────────
   btnId:        element id of the sort toggle button
   containerSel: CSS selector for the list container
   itemSel:      CSS selector for sortable items within container
   withLabel:    true = button shows "↑ Oldest first" / "↓ Newest first"
                 false = button shows "↑" / "↓" only
   ──────────────────────────────────────────────────────────── */
function initSortButton(btnId, containerSel, itemSel, withLabel) {
    var btn = document.getElementById(btnId);
    if (!btn) return;
    var container = document.querySelector(containerSel);
    if (!container) return;
    var sentinel = container.querySelector('#feed-filter-empty');
    var ascending = true;

    btn.addEventListener('click', function () {
        ascending = !ascending;
        var items = Array.from(container.querySelectorAll(itemSel));
        items.reverse().forEach(function (el) {
            if (sentinel) container.insertBefore(el, sentinel);
            else container.appendChild(el);
        });
        btn.textContent = withLabel
            ? (ascending ? '↑ Oldest first' : '↓ Newest first')
            : (ascending ? '↑' : '↓');
        btn.setAttribute('aria-label', ascending ? 'Sort: oldest first' : 'Sort: newest first');
        btn.classList.toggle('is-active', !ascending);
    });
}

/* ── Filter bar (trip page: All / Journal / Stories) ─────── */
function initFilterBar() {
    var filterBtns = document.querySelectorAll('.trip-filter-btn');
    if (!filterBtns.length) return;
    var cards = document.querySelectorAll('[data-type]');
    var filterEmpty = document.getElementById('feed-filter-empty');

    filterBtns.forEach(function (btn) {
        btn.addEventListener('click', function () {
            filterBtns.forEach(function (b) {
                b.classList.remove('is-active');
                b.setAttribute('aria-pressed', 'false');
            });
            btn.classList.add('is-active');
            btn.setAttribute('aria-pressed', 'true');

            var filter = btn.getAttribute('data-filter');
            var visible = 0;
            cards.forEach(function (card) {
                var show = filter === 'all' || card.getAttribute('data-type') === filter;
                card.style.display = show ? '' : 'none';
                if (show) visible++;
            });

            if (filterEmpty) {
                if (visible === 0) {
                    filterEmpty.textContent = filter === 'story'
                        ? 'No stories yet for this trip.'
                        : 'No entries yet.';
                    filterEmpty.style.display = '';
                } else {
                    filterEmpty.style.display = 'none';
                }
            }
        });
    });
}

/* ── Back to top ─────────────────────────────────────────── */
function initBackToTop(btnId) {
    var btn = document.getElementById(btnId);
    if (!btn) return;
    var threshold = window.innerHeight * 0.8;
    var shown = false;
    btn.addEventListener('click', function () {
        history.pushState(null, '', window.location.pathname + window.location.search);
        window.scrollTo({ top: 0, behavior: 'smooth' });
    });
    window.addEventListener('scroll', function () {
        var shouldShow = window.scrollY > threshold;
        if (shouldShow !== shown) {
            shown = shouldShow;
            btn.classList.toggle('is-visible', shown);
        }
    }, { passive: true });
}

/* ── Panel toggles (trip stats / cycling panels) ─────────── */
function initPanelToggles() {
    document.querySelectorAll('.trip-panel-toggle').forEach(function (toggle) {
        var blockId = toggle.getAttribute('aria-controls');
        var block = blockId ? document.getElementById(blockId) : null;
        if (!block) return;
        toggle.addEventListener('click', function () {
            var isOpen = block.classList.contains('is-open');
            block.classList.toggle('is-open', !isOpen);
            toggle.classList.toggle('is-active', !isOpen);
            toggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
        });
    });

    document.querySelectorAll('.trip-panel-close').forEach(function (btn) {
        var toggleBtn = document.getElementById(btn.getAttribute('data-toggle'));
        if (toggleBtn) btn.addEventListener('click', function () { toggleBtn.click(); });
    });
}

/* ── Boot ────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', function () {
    initPhotoStrip();
    initPhotoSwipe();
    initFilterBar();
    /* Sort buttons — each call is silent if its button/container isn't on this page */
    initSortButton('trip-sort-toggle', '.feed', '[data-type]', false);
    initSortButton('feed-sort-toggle', '.feed', '[data-type]', true);
    initSortButton('feed-sort-toggle', '.stories-grid', '.story-card', true);
    initBackToTop('story-totop');
    initBackToTop('trip-totop');
    initPanelToggles();
});
  • Step 2: Run build
make build-assets

Expected: exits 0. Verify output:

ls user/themes/intotheeast/js/main.js
ls user/themes/intotheeast/css-compiled/main.css
ls user/themes/intotheeast/fonts/

fonts/ should contain woff2 files from @fontsource packages.

  • Step 3: Verify font output in CSS
grep '@font-face' user/themes/intotheeast/css-compiled/main.css | head -5

Expected: multiple @font-face rules referencing ../fonts/*.woff2 paths.

  • Step 4: Commit
git -C user add themes/intotheeast/js/src/main.js themes/intotheeast/js/main.js themes/intotheeast/css-compiled/main.css themes/intotheeast/fonts/
git -C user commit -m "build: add main.js bundle — fonts, PhotoSwipe, UI utilities"

Task 3: Extend maplibre-utils.js — parseGpxFiles + export haversineKm

Move parseGpxFiles from trip.html.twig into the shared utility and expose haversineKm publicly so template code can call both as MapUtils.*.

Files:

  • Modify: user/themes/intotheeast/js/maplibre-utils.js

Interfaces:

  • Consumes: haversineKm (already defined privately in maplibre-utils.js at line 184)

  • Produces:

    • MapUtils.haversineKm(lat1, lng1, lat2, lng2)number (km)
    • MapUtils.parseGpxFiles(urls, callback)urls: string[], callback({ distance, eleGain, eleLoss, highest, lowest, movingTime, avgSpeed } | { error: string })void
  • Step 1: Add parseGpxFiles function to maplibre-utils.js

In user/themes/intotheeast/js/maplibre-utils.js, add the following block immediately before the global.MapUtils = { line (currently line 333):

  /*
   * Parse one or more GPX files and compute aggregate cycling statistics.
   * urls:     array of GPX file URL strings
   * callback: called once with { distance, eleGain, eleLoss, highest, lowest, movingTime, avgSpeed }
   *           or { error: 'no files' } if urls is empty.
   *
   * distance/eleGain/eleLoss in raw units (km / metres).
   * movingTime: "H:MM" string. avgSpeed: km/h number.
   */
  function parseGpxFiles(urls, callback) {
    var pending = urls.length;
    var fileResults = new Array(urls.length);
    if (pending === 0) { callback({ error: 'no files' }); return; }

    urls.forEach(function (url, idx) {
      fetch(url)
        .then(function (r) { return r.text(); })
        .then(function (text) {
          var xml  = new DOMParser().parseFromString(text, 'text/xml');
          var pts  = [];
          xml.querySelectorAll('trkpt').forEach(function (pt) {
            var eleEl  = pt.querySelector('ele');
            var timeEl = pt.querySelector('time');
            pts.push({
              lat:  parseFloat(pt.getAttribute('lat')),
              lon:  parseFloat(pt.getAttribute('lon')),
              ele:  eleEl  ? parseFloat(eleEl.textContent)  : NaN,
              time: timeEl ? timeEl.textContent              : null
            });
          });
          fileResults[idx] = pts;
          if (--pending === 0) computeAndCallback();
        })
        .catch(function (err) {
          console.warn('GPX load failed:', url, err);
          fileResults[idx] = [];
          if (--pending === 0) computeAndCallback();
        });
    });

    function computeAndCallback() {
      var totalDistance = 0, totalEleGain = 0, totalEleLoss = 0;
      var globalHighest = NaN, globalLowest = NaN, totalMovingTime = 0;

      fileResults.forEach(function (pts) {
        if (!pts || pts.length < 2) return;
        /* Include first point of each file in elevation range */
        if (!isNaN(pts[0].ele)) {
          if (isNaN(globalHighest) || pts[0].ele > globalHighest) globalHighest = pts[0].ele;
          if (isNaN(globalLowest)  || pts[0].ele < globalLowest)  globalLowest  = pts[0].ele;
        }
        for (var i = 1; i < pts.length; i++) {
          var p0 = pts[i - 1], p1 = pts[i];
          totalDistance += haversineKm(p0.lat, p0.lon, p1.lat, p1.lon);
          if (!isNaN(p0.ele) && !isNaN(p1.ele)) {
            var dEle = p1.ele - p0.ele;
            if (dEle > 0) totalEleGain += dEle;
            if (dEle < 0) totalEleLoss += (-dEle);
            if (isNaN(globalHighest) || p1.ele > globalHighest) globalHighest = p1.ele;
            if (isNaN(globalLowest)  || p1.ele < globalLowest)  globalLowest  = p1.ele;
          }
          if (p0.time && p1.time) {
            var dtHrs = (Date.parse(p1.time) - Date.parse(p0.time)) / 3600000;
            if (dtHrs > 0 && (haversineKm(p0.lat, p0.lon, p1.lat, p1.lon) / dtHrs) >= 1) {
              totalMovingTime += dtHrs;
            }
          }
        }
      });

      var avgSpeed  = totalMovingTime > 0 ? totalDistance / totalMovingTime : 0;
      var movHours  = Math.floor(totalMovingTime);
      var movMins   = Math.round((totalMovingTime - movHours) * 60);
      if (movMins === 60) { movHours++; movMins = 0; }

      callback({
        distance:   totalDistance,
        eleGain:    totalEleGain,
        eleLoss:    totalEleLoss,
        highest:    globalHighest,
        lowest:     globalLowest,
        movingTime: movHours + ':' + (movMins < 10 ? '0' : '') + movMins,
        avgSpeed:   avgSpeed
      });
    }
  }
  • Step 2: Add haversineKm and parseGpxFiles to MapUtils exports

Find the global.MapUtils = { block (currently the last block in the file) and add both new entries:

  global.MapUtils = {
    MAP_STYLE:            MAP_STYLE,
    ACCENT:               ACCENT,
    haversineKm:          haversineKm,
    parseGpxFiles:        parseGpxFiles,
    addJourneyLine:       addJourneyLine,
    addJourneySegments:   addJourneySegments,
    buildJourneySegments: buildJourneySegments,
    renderGpxJourney:     renderGpxJourney,
    createDotMarker:      createDotMarker,
    createStoryMarker:    createStoryMarker
  };
  • Step 3: Commit
git -C user add themes/intotheeast/js/maplibre-utils.js
git -C user commit -m "feat: add parseGpxFiles and export haversineKm from MapUtils"

Task 4: Map JS bundle

Write js/src/map.js to bundle MapLibre GL, toGeoJSON, and maplibre-utils as a single file, attaching them as window globals for existing template inline scripts.

Files:

  • Modify: user/themes/intotheeast/js/src/map.js (replace placeholder)

Interfaces:

  • Consumes: MapUtils.parseGpxFiles, MapUtils.haversineKm (from Task 3 — already in maplibre-utils.js which this imports)

  • Produces:

    • js/map.js — IIFE bundle
    • css-compiled/map.css — MapLibre GL CSS
    • window.maplibregl — MapLibre GL instance
    • window.toGeoJSON — toGeoJSON converter
    • window.MapUtils — all MapUtils functions (set by maplibre-utils.js side effect)
  • Step 1: Write js/src/map.js

Replace user/themes/intotheeast/js/src/map.js with:

import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import toGeoJSON from '@mapbox/togeojson';

/* maplibre-utils.js attaches MapUtils to window as a side effect */
import '../maplibre-utils.js';

window.maplibregl = maplibregl;
window.toGeoJSON  = toGeoJSON;
  • Step 2: Run build
make build-assets

Expected: exits 0. Verify:

ls user/themes/intotheeast/js/map.js
ls user/themes/intotheeast/css-compiled/map.css

css-compiled/map.css should be non-empty (~100KB+) as it contains full MapLibre GL styles.

  • Step 3: Commit
git -C user add themes/intotheeast/js/src/map.js themes/intotheeast/js/map.js themes/intotheeast/css-compiled/map.css
git -C user commit -m "build: add map.js bundle — MapLibre GL, toGeoJSON, MapUtils"

Task 5: base.html.twig — Asset Manager + remove Google Fonts + remove photo strip script

Register the universal bundle via Grav's Asset Manager, remove Google Fonts external requests, remove the inline photo strip script, and add the map_assets block for map pages to fill.

Files:

  • Modify: user/themes/intotheeast/templates/partials/base.html.twig
  • Modify: user/themes/intotheeast/css/style.css

Interfaces:

  • Produces: {% block map_assets %}{% endblock %} — filled by trip.html.twig, feed-map.html.twig, map.html.twig in later tasks

  • Step 1: Update base.html.twig

Replace the entire file content of user/themes/intotheeast/templates/partials/base.html.twig with:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}</title>
    {% do assets.addCss('theme://css/tokens.css') %}
    {% do assets.addCss('theme://css/style.css') %}
    {% do assets.addCss('theme://css-compiled/main.css') %}
    {% do assets.addJs('theme://js/main.js', {group: 'bottom'}) %}
    {{ assets.css()|raw }}
    {{ assets.js()|raw }}
</head>
<body class="{% if page.template == 'map' %}map-page{% endif %}{% if page.template == 'home' or page.template == 'trip' %} home-page{% endif %}{% if page.template == 'story' %} template-story{% endif %}">
    <a class="skip-link" href="#main-content">Skip to main content</a>
    <header class="site-header">
        <a class="site-title" href="{{ base_url_absolute }}">into the east</a>
        {% block nav %}
        <nav class="site-nav" aria-label="Main navigation">
            <a href="{{ base_url_absolute }}"{% if page.template == 'home' %} aria-current="page"{% endif %}>Home</a>
            <a href="{{ base_url_absolute }}/trips"{% if page.template == 'trips' %} aria-current="page"{% endif %}>Past Trips</a>
        </nav>
        {% endblock %}
    </header>
    <main class="site-main" id="main-content">
        {% block content %}{% endblock %}
    </main>
    {% block map_assets %}{% endblock %}
    {{ assets.js('bottom')|raw }}
</body>
</html>

Key changes from original:

  • Removed <link rel="preconnect" href="https://fonts.googleapis.com"> (lines 79)

  • Replaced hardcoded <link> tags with {% do assets.addCss(...) %} calls

  • Removed the <script> photo strip block (lines 3073) — now in js/main.js

  • Added {% block map_assets %}{% endblock %} before {{ assets.js('bottom')|raw }}

  • Step 2: Remove Google Fonts @import from style.css if present

Check whether css/style.css contains a Google Fonts import:

grep -n 'googleapis\|fonts.g' user/themes/intotheeast/css/style.css

If any line is found, remove it. The font-family declarations using --font-display and --font-ui stay unchanged — they reference the CSS custom properties defined in tokens.css, which will work with the self-hosted fonts in css-compiled/main.css.

  • Step 3: Load the dev server and verify the page renders

Open http://localhost:8081 in a browser. The page should load with correct fonts (DM Sans for body, DM Serif Display for headings). Open browser DevTools → Network tab → filter by "google" — no requests to fonts.googleapis.com or fonts.gstatic.com should appear.

If fonts look wrong, check that css-compiled/main.css is served by opening http://localhost:8081/user/themes/intotheeast/css-compiled/main.css.

  • Step 4: Commit
git -C user add themes/intotheeast/templates/partials/base.html.twig themes/intotheeast/css/style.css
git -C user commit -m "feat: register assets via Asset Manager, remove Google Fonts, remove inline photo strip script"

Task 6: trip.html.twig — full JS cleanup and map bundle registration

This is the largest template change. Remove all duplicated JS blocks, CDN tags, and the haversineKm duplicate. Wire MapUtils.parseGpxFiles and MapUtils.haversineKm in the GPX stats block.

Files:

  • Modify: user/themes/intotheeast/templates/trip.html.twig

Interfaces:

  • Consumes:

    • window.maplibregl — from js/map.js (Task 4)
    • window.MapUtils.parseGpxFiles(urls, cb) — from js/maplibre-utils.js via map bundle (Task 3)
    • window.MapUtils.haversineKm(lat1, lng1, lat2, lng2) — from map bundle (Task 3)
    • window.scrollama — from js/main.js (Task 2, though not used on this page)
  • Step 1: Remove the CDN script/link tags and PhotoSwipe CSS link

Remove these lines from trip.html.twig:

  • Line 4: <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.css">

  • Lines 247250:

    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
    <script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
    <script src="{{ url('theme://js/maplibre-utils.js') }}"></script>
    
  • Step 2: Add map_assets block immediately after {% block content %}

After the opening {% block content %} line, add:

{% block map_assets %}
{% do assets.addCss('theme://css-compiled/map.css') %}
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
{% endblock %}
  • Step 3: Remove the duplicated JS blocks

Remove the following <script> blocks entirely from trip.html.twig. These are now handled by js/main.js:

  1. The filter bar IIFE (the block from (function() { at line ~339 through to the closing })(); at line ~371 — the one with .trip-filter-btn and data-filter)
  2. The sort toggle IIFE (from (function() { containing trip-sort-toggle through its })(); at line ~388)
  3. The back-to-top block (from document.addEventListener('DOMContentLoaded', function () { at line ~545 through its }); at line ~561)
  4. The entire <script type="module"> block at the bottom (lines ~566626) containing the PhotoSwipe lightbox and IntersectionObserver photo strip code

Note: the makePanelToggle code lives inside the GPX stats IIFE (which stays) — that is handled in Step 5, not here.

  • Step 4: Update the GPX stats block to use MapUtils

In the remaining GPX stats IIFE (the block starting with (function() { that references HAS_GPX, parseGpxFiles, and haversineKm):

Replace the local haversineKm function definition (lines ~393401):

function haversineKm(lat1, lng1, lat2, lng2) {
    var R = 6371;
    ...
}

Delete this entire function — it is now MapUtils.haversineKm.

Replace the local parseGpxFiles function definition (lines ~403485):

function parseGpxFiles(urls, callback) {
    ...
}

Delete this entire function — it is now MapUtils.parseGpxFiles.

Update the two call sites to use MapUtils:

Find (Mode A call, line ~491):

    parseGpxFiles(GPX_URLS, function(result) {

Replace with:

    MapUtils.parseGpxFiles(GPX_URLS, function(result) {

Find (Mode B haversine call, lines ~512515):

        total += haversineKm(
            parseFloat(STATS_GPS[i-1][0]), parseFloat(STATS_GPS[i-1][1]),
            parseFloat(STATS_GPS[i][0]),   parseFloat(STATS_GPS[i][1])
        );

Replace with:

        total += MapUtils.haversineKm(
            parseFloat(STATS_GPS[i-1][0]), parseFloat(STATS_GPS[i-1][1]),
            parseFloat(STATS_GPS[i][0]),   parseFloat(STATS_GPS[i][1])
        );
  • Step 5: Remove the makePanelToggle function from the GPX stats IIFE

Inside the GPX stats IIFE, find and remove:

  • The makePanelToggle function definition
  • The two makePanelToggle(...) calls
  • The document.querySelectorAll('.trip-panel-close').forEach(...) block

These are now handled by initPanelToggles() in main.js.

  • Step 6: Open the trip page and verify all features

Open http://localhost:8081/trips/japan-korea-2026 (or the current active trip URL). Verify:

  • Map loads and markers are visible
  • GPX track renders
  • Sort button (↑/↓) reverses feed order
  • Filter bar (All / Journal / Stories) shows and hides cards
  • Stats panel opens and closes
  • Cycling panel opens and closes (if GPX present)
  • Back-to-top button appears after scrolling
  • Journal photo strip: dots sync, prev/next work, expand opens lightbox
  • No console errors

Open DevTools → Network tab → reload. Filter by "cdn.jsdelivr" — zero results expected.

  • Step 7: Commit
git -C user add themes/intotheeast/templates/trip.html.twig
git -C user commit -m "refactor: trip.html.twig — remove CDN tags, deduplicate JS, use MapUtils.parseGpxFiles"

Task 7: Remaining template CDN cleanup

Remove CDN tags from feed-map.html.twig, map.html.twig, and story.html.twig. These pages are not in active use but should not make CDN requests when visited.

Files:

  • Modify: user/themes/intotheeast/templates/partials/feed-map.html.twig

  • Modify: user/themes/intotheeast/templates/map.html.twig

  • Modify: user/themes/intotheeast/templates/story.html.twig

  • Step 1: feed-map.html.twig — remove CDN tags, add map_assets block

In user/themes/intotheeast/templates/partials/feed-map.html.twig:

Remove lines 2830:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>

Add the map assets block at the very top of the {% if map_entries|length > 0 %} block (before the <div class="feed-map-wrap">):

{% block map_assets %}
{% do assets.addCss('theme://css-compiled/map.css') %}
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
{% endblock %}
  • Step 2: map.html.twig — remove CDN tags, add map_assets block

In user/themes/intotheeast/templates/map.html.twig:

Remove lines 3942:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mapbox/togeojson@0.16.2/togeojson.min.js"></script>
<script src="{{ url('theme://js/maplibre-utils.js') }}"></script>

Add after {% block content %}:

{% block map_assets %}
{% do assets.addCss('theme://css-compiled/map.css') %}
{% do assets.addJs('theme://js/map.js', {group: 'bottom'}) %}
{% endblock %}
  • Step 3: story.html.twig — remove Scrollama CDN tag

In user/themes/intotheeast/templates/story.html.twig:

Remove line 72:

<script src="https://cdn.jsdelivr.net/npm/scrollama@3/build/scrollama.min.js"></script>

Scrollama is now bundled in main.js and exposed as window.scrollama. The existing inline script that calls scrollama() will work unchanged.

  • Step 4: Commit
git -C user add themes/intotheeast/templates/partials/feed-map.html.twig themes/intotheeast/templates/map.html.twig themes/intotheeast/templates/story.html.twig
git -C user commit -m "refactor: remove CDN tags from feed-map, map, story templates"

Task 8: End-to-end verification — no external requests, all features intact

Files: None modified. Verification only.

  • Step 1: Verify zero external requests on the trip page

Open http://localhost:8081/trips/japan-korea-2026. Open DevTools → Network tab → reload.

Check these domains appear zero times:

  • cdn.jsdelivr.net

  • fonts.googleapis.com

  • fonts.gstatic.com

  • Step 2: Verify trip page features

  • Map renders with markers and GPX track

  • Marker click scrolls to entry card and flashes it

  • Fullscreen map toggle expands/collapses

  • Filter bar: All / Journal / Stories each filter correctly

  • Sort toggle (↑/↓) reverses feed order

  • Stats panel opens and closes (click "Stats ▾" button)

  • Cycling panel opens and closes if GPX present ("Cycling ▾" button)

  • GPX distance figure populates in stats grid

  • Cycling stats grid populates (distance, gain, loss, highest, lowest, moving time, avg speed)

  • Back-to-top button appears after scrolling down; click scrolls to top

  • Journal photo strip: swipe/scroll dots sync; buttons navigate; expand button opens PhotoSwipe; arrow keys advance; click outside closes

  • Step 3: Verify story page

Open a story URL (e.g. http://localhost:8081/trips/italy-2026-demo/stories/sorano-rock-and-time):

  • Hero image loads

  • Scroll overlay darkens/lightens on scroll

  • Story title fades into nav bar as hero scrolls out

  • Back-to-top button appears and works

  • If page has .scrolly sections: they animate on scroll

  • No console errors

  • Step 4: Check built file sizes

ls -lh user/themes/intotheeast/js/main.js user/themes/intotheeast/js/map.js user/themes/intotheeast/css-compiled/main.css user/themes/intotheeast/css-compiled/map.css

Rough expected sizes (minified):

  • main.js: ~80150 KB (PhotoSwipe + Scrollama + UI code)
  • map.js: ~600900 KB (MapLibre GL dominates)
  • main.css: ~3060 KB (PhotoSwipe + @font-face rules)
  • map.css: ~80120 KB (MapLibre GL styles)

If map.js is unexpectedly small (<100 KB), MapLibre GL may not have bundled — check that import maplibregl from 'maplibre-gl' is in js/src/map.js.

  • Step 5: Final commit
git -C user add -A
git -C user status  # confirm only expected files
git -C user commit -m "chore: verify asset pipeline — all CDN deps eliminated"
git add -A
git commit -m "chore: complete asset pipeline — self-hosted deps, deduplicated JS"