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

968 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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`:
```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`:
```javascript
// placeholder — replaced in Task 2
```
Create `user/themes/intotheeast/js/src/map.js`:
```javascript
// placeholder — replaced in Task 4
```
- [ ] **Step 5: Run the build and verify it completes**
```bash
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:
```bash
ls user/themes/intotheeast/js/main.js
ls user/themes/intotheeast/js/map.js
```
- [ ] **Step 6: Commit**
```bash
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:
```javascript
/* ── 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**
```bash
make build-assets
```
Expected: exits 0. Verify output:
```bash
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**
```bash
grep '@font-face' user/themes/intotheeast/css-compiled/main.css | head -5
```
Expected: multiple `@font-face` rules referencing `../fonts/*.woff2` paths.
- [ ] **Step 4: Commit**
```bash
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):
```javascript
/*
* 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:
```javascript
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**
```bash
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:
```javascript
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**
```bash
make build-assets
```
Expected: exits 0. Verify:
```bash
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**
```bash
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:
```twig
<!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:
```bash
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**
```bash
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:
```html
<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:
```twig
{% 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):
```javascript
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):
```javascript
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):
```javascript
parseGpxFiles(GPX_URLS, function(result) {
```
Replace with:
```javascript
MapUtils.parseGpxFiles(GPX_URLS, function(result) {
```
Find (Mode B haversine call, lines ~512515):
```javascript
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:
```javascript
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**
```bash
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:
```html
<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">`):
```twig
{% 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:
```html
<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 %}`:
```twig
{% 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:
```html
<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**
```bash
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**
```bash
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**
```bash
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"
```