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
38 KiB
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=iifeso templates can referencemaplibregl,MapUtils,toGeoJSONas 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(addbuild-assetstarget after existingbuildtarget) - Modify:
user/.gitignore(add node_modules line)
Interfaces:
-
Produces:
make build-assetscommand that runsnpm ci && npm run buildin 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-assetstarget 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 Displayfonts/*.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 bundlecss-compiled/map.css— MapLibre GL CSSwindow.maplibregl— MapLibre GL instancewindow.toGeoJSON— toGeoJSON converterwindow.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 7–9) -
Replaced hardcoded
<link>tags with{% do assets.addCss(...) %}calls -
Removed the
<script>photo strip block (lines 30–73) — now injs/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— fromjs/map.js(Task 4)window.MapUtils.parseGpxFiles(urls, cb)— fromjs/maplibre-utils.jsvia map bundle (Task 3)window.MapUtils.haversineKm(lat1, lng1, lat2, lng2)— from map bundle (Task 3)window.scrollama— fromjs/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 247–250:
<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:
- The filter bar IIFE (the block from
(function() {at line ~339 through to the closing})();at line ~371 — the one with.trip-filter-btnanddata-filter) - The sort toggle IIFE (from
(function() {containingtrip-sort-togglethrough its})();at line ~388) - The back-to-top block (from
document.addEventListener('DOMContentLoaded', function () {at line ~545 through its});at line ~561) - The entire
<script type="module">block at the bottom (lines ~566–626) 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 ~393–401):
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 ~403–485):
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 ~512–515):
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
makePanelTogglefunction 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 28–30:
<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 39–42:
<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
.scrollysections: 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: ~80–150 KB (PhotoSwipe + Scrollama + UI code)map.js: ~600–900 KB (MapLibre GL dominates)main.css: ~30–60 KB (PhotoSwipe + @font-face rules)map.css: ~80–120 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"