bd906005e4
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
968 lines
38 KiB
Markdown
968 lines
38 KiB
Markdown
# 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 7–9)
|
||
- Replaced hardcoded `<link>` tags with `{% do assets.addCss(...) %}` calls
|
||
- Removed the `<script>` photo strip block (lines 30–73) — 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 247–250:
|
||
```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 ~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):
|
||
```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 ~403–485):
|
||
```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 ~512–515):
|
||
```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 28–30:
|
||
```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 39–42:
|
||
```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`: ~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**
|
||
|
||
```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"
|
||
```
|