CLAUDE.md is loaded into context on every request, so every line has a recurring cost. Applies one rule to decide what earns its place: keep what changes behaviour (rules and gotchas Claude cannot discover before it acts); extract what merely describes code (Claude reads the code anyway, and prose about code silently drifts). The four stale facts fixed in the previous commit were all in the "describes code" class -- active_trip, the Admin2 version, demo-load's scope, the gitignore list. None were rules. That is the argument for moving this material next to what it documents. Extracted (kept as pointers): - entry-map + trip-feed-col parameter contracts (56 lines) -> reference/ architecture.md "Shared partial contracts". CLAUDE.md keeps only the invariants: single map path, must assign window.tripMap/homeMap, keep trip-feed-col single-purpose, initTripStats depends on MapUtils. - Prod override runbook (49 -> 9 lines) -> guides/deploy-cycle.md "The env override tree", incl. the Twig dev/prod table and WEB_HOST. CLAUDE.md keeps the two behavioural rules: never commit prod values, and Admin on the server writes to the env tree (so check both config paths, env wins). - GPX API routes, session auth and the Blob/FormData upload gotcha -> guides/gpx-manager.md "How the manager is wired". - Trip-switch procedure -> guides/trip-switching.md. CLAUDE.md keeps the one rule that matters: never re-add pageconfig.parent to post-form.md. - Also trimmed the dev-command table and custom-plugin table added in the previous commit; both largely restated the Makefile and blueprints. Fixed the guides being pointed into, so the pointers lead to truth: - trip-switching.md instructed editing a pageconfig.parent that no longer exists -- its whole "two files must be updated together" premise was obsolete and would have reintroduced the desync it warned about. - architecture.md: Grav 2.0.4->2.0.7, Admin2 2.0.10->2.0.12, corrected the posting pipeline to show cache-on-save injecting parent before the write, added entry-actions to the custom-plugin list. - japan-korea-2026 -> denmark-2026 across guides/reference (docs/solutions keeps its historical references intact -- those are incident records). Verified: every markdown link resolves, every referenced section heading exists, and each extracted item was confirmed present in its new home. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11 KiB
Architecture Overview
How the intotheeast site hangs together.
Stack
| Layer | Technology | Notes |
|---|---|---|
| CMS | Grav 2.0.7 stable | Flat-file PHP CMS; no database. Server upgrades in place via bin/gpm self-upgrade |
| Admin | Admin2 v2.0.12 | Plugin slug: admin2 (not admin) |
| GPM channel | stable |
Authoritative in user/config/system.yaml → gpm.releases; GRAV_CHANNEL=production in compose is cosmetic |
| Container | Docker (getgrav/grav base + custom Dockerfile) |
Grav 2.0 baked in at build time |
| PHP session | session.save_path = /tmp |
Set in php/php-local.ini |
| Dev URL | http://localhost:8081 | Mapped from container port 80 |
| Maps | MapLibre GL JS | Replaced Leaflet; one shared map path (MapUtils.initEntryMap) on trip + home |
| GPX rendering | toGeoJSON (bundled in js/map.js) |
Parses GPX → GeoJSON route layers client-side; no CDN |
Plugin roles
The posting pipeline is a chain of three plugins:
Browser POST /post
│
├─ Grav Form plugin (built-in)
│ └─ validates required fields; handles file uploads
│
├─ cache-on-save (custom) — onFormValidationProcessed, runs BEFORE the write
│ ├─ setData('parent', …) ← derived from site.active_trip
│ └─ sets pageconfig.overwrite_mode: edit when the hidden edit_path is filled,
│ false when empty (create a fresh dated folder)
│
├─ add-page-by-form (third-party, patched — see deploy/patches/)
│ └─ reads post-form.md config:
│ ├─ pageconfig.slug_field → slug from date + title
│ └─ pagefrontmatter → template: entry
│ └─ writes entry.md to user/pages/01.trips/<trip>/01.dailies/<slug>.entry/
│ └─ moves uploaded photos into the page folder
│
└─ cache-on-save (again, post-write)
└─ calls $grav['cache']->deleteAll() on every new-entry form submission
└─ ensures entries appear in feed immediately in both dev and prod mode
Other notable plugins:
| Plugin | Role |
|---|---|
login |
Auth for /post and /gpx-manager |
api (Grav API v1) |
Used by /gpx-manager to list/upload/delete GPX files |
admin2 |
Admin panel at /admin |
story-blocks (custom) |
Storytelling shortcode blocks for long-form stories (needs shortcode-core) |
entry-actions (custom) |
Owner-only, active-trip-scoped journal entry actions (delete) via the Grav API |
Plugin management model
Three categories, by how each plugin is installed and maintained:
- GPM-managed (
plugins.txt→make install-plugins): the marketplace plugins, includinglogin,form,admin2,api,flex-objects, shortcodes, etc. As of the 2.0.4 upgrade,admin2/api/flex-objectsmoved into this category — they were previously hand-extracted from the core bundle. Update withbin/gpm update(make remote-update-plugins-<env>on servers). - Custom, in-repo (
user/plugins/allowlisted inuser/.gitignore):cache-on-save,story-blocks,entry-actions. Versioned in the user repo. - Remote-only:
git-sync— installed and configured only on servers, never inplugins.txt, and disabled during upgrades.
Template hierarchy
All page templates extend base.html.twig:
templates/
├─ base.html.twig ← site shell: nav, fonts, CSS tokens
├─ default.html.twig ← extends base; generic page
├─ home.html.twig ← extends base; context-aware two-column layout
├─ trip.html.twig ← extends base; trip page with filter bar (All/Journal/Stories)
├─ entry.html.twig ← extends base; single journal entry (gallery, badges, map)
├─ story.html.twig ← extends base; single story (Ken Burns hero, shortcodes)
└─ gpx-manager.html.twig ← extends base; admin UI for GPX file management
The standalone dailies.html.twig, map.html.twig, stats.html.twig and stories.html.twig view templates were removed in the 2026-07-04 standalone-page cleanup — the trip page (trip.html.twig) consolidated the feed, inline map, and inline stats.
Partials live in templates/partials/ (plus macros in templates/macros/). Key partials: base.html.twig (site shell extended by all page templates), entry-map.html.twig (shared map column + initEntryMap call, used by trip + home), trip-feed-col.html.twig (feed column chrome, shared by trip + home), home-predeparture.html.twig, entry-journal.html.twig / entry-story.html.twig (feed cards), trip-publish-toggle.html.twig, and weather-icons.html.twig.
Shared partial contracts
Two partials are included by both trip.html.twig and the active branch of home.html.twig, via {% include … with {…} only %}. The only keyword means every value must be passed explicitly — the tables below are the contracts. The rules that govern them (single map path, required map globals, never hand-edit bundles) live in CLAUDE.md; these are the parameter details.
entry-map.html.twig
Renders the .home-map-col column (map div #{{ map_id }} + fullscreen button) and, when entries is non-empty, a thin <script> assigning window.{{ map_global }} from initEntryMap. Callers resolve header values (use_gpx / autoconnect) and pass them in.
| Parameter | Type | Trip passes | Home passes |
|---|---|---|---|
map_id |
string | 'trip-map' |
'home-map' |
map_global |
string | 'tripMap' |
'homeMap' |
entries |
array | [{lat, lng, slug, title, url, type?, force_connect, ...}] |
same |
card_prefix |
string | 'entry-' |
'entry-' |
story_markers |
bool | true (diamond markers) |
false |
gpx_urls |
array | gpx_urls |
home_gpx_urls |
use_gpx |
bool | page.header.use_gpx ?? true |
derived from trip.header |
autoconnect |
string | page.header.autoconnect ?? 'on' |
derived from trip.header |
gpx_source_prefix |
string | 'gpx' |
'home-gpx' |
journey_id |
string | 'trip-journey' |
'home-journey' |
trip-feed-col.html.twig
The column beside the map: date-range header, filter bar, stats/cycling panels, feed loop.
| Parameter | Type | Trip passes | Home-active passes |
|---|---|---|---|
trip_page |
Page | page |
trip |
all_items |
array | sorted by date, flag 4 (oldest→newest) | sorted by date, flag 3 (newest→oldest) |
journal_entries |
array | dailies children | dailies children |
journal_count / story_count |
int | counts | counts |
has_gpx |
bool | has_gpx |
home_gpx_urls|length > 0 |
gpx_urls |
array | gpx_urls |
home_gpx_urls |
gps_points |
array | gps_points |
gps_points |
show_sort |
bool | true |
false (home keeps its own feed order) |
trip_header_extras |
bool | true |
not passed (defaults false) |
trip_header_extras gates the trip-page-only header block (one-liner .home-trip-tagline, expandable .trip-header-desc, .trip-header-banner cover strip) rendered between the counts and the filter bar. home.html.twig omits it so those extras never leak onto the home route.
Sibling: home-predeparture.html.twig is the home-only "Coming soon" landing state, taking only trip_page. home.html.twig picks it with {% if all_items|length == 0 %} → home-predeparture {% else %} → trip-feed-col. Keep trip-feed-col single-purpose — do not fold the pre-departure branch back into it.
Stats/cycling JS glue: the partial emits an inline DOMContentLoaded script calling window.initTripStats({ gpxUrls, gpsPoints, hasGpx }) — one shared function in js/src/main.js. It no-ops when #stat-distance is absent, populates exact distance + cycling stats from GPX, and falls back to a ~-prefixed haversine estimate (or — for <2 points) when there is no GPX. It depends on window.MapUtils from map.js (loaded in the bottom asset group on both pages).
History: the map setup replaced an older three-variant arrangement (a
feed-map.html.twigpartial with its own inline init, plus a full-pagemap.html.twig), deleted in the 2026-07-04 standalone-page cleanup.
Trip entity structure
The site is organized around Trip entities. The active trip is set in user/config/site.yaml → active_trip, as a route (e.g. /trips/denmark-2026), not a bare slug.
user/pages/01.trips/
└─ denmark-2026/
├─ trip.md ← template: trip; title, date_start, cover_image, album_url
├─ *.gpx ← GPX route files (served as page media; auto-detected by trip.html.twig)
├─ 01.dailies/ ← journal entry children (container .md is routable:false)
└─ 04.stories/ ← story children (container .md is routable:false)
01.dailies/ and 04.stories/ are inert data containers — the trip page aggregates their children; visiting the container routes directly 404s/redirects. (The former 02.map/ and 03.stats/ folders were removed with their view templates.)
GPX data flow
GPX file uploaded to trip page media
│
▼
user/pages/01.trips/<slug>/*.gpx
│
▼
trip.html.twig / home.html.twig: trip_page.media.all → filter .gpx → entry-map partial
│
▼
MapLibre source: each GPX file parsed by toGeoJSON (bundled in js/map.js) → GeoJSON source
│
▼
Connector suppression: same-file 10km proximity check prevents spurious inter-track segments
│ (override with force_connect: true in trip frontmatter)
▼
Rendered as route polyline on map
Data flow for a post submission
1. User fills /post form and taps Submit
2. Grav Form plugin validates: title and content required
3. cache-on-save (onFormValidationProcessed) injects the write target:
parent ← derived from site.active_trip (e.g. /trips/denmark-2026/dailies)
overwrite_mode ← edit if edit_path filled, else false
4. add-page-by-form reads post-form.md:
pageconfig.slug_field: date,title
pagefrontmatter: template: entry
5. New page written to:
user/pages/01.trips/denmark-2026/01.dailies/
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
└─ entry.md
6. Photos moved into the same folder
7. cache-on-save calls $grav['cache']->deleteAll()
8. Browser: form shows success message
9. Feed at /trips/denmark-2026 immediately shows new entry
Key config files
| File | Purpose |
|---|---|
user/config/site.yaml |
active_trip route; site title/description |
user/config/system.yaml |
Twig cache, flex accounts/pages, language prefix |
user/config/media.yaml |
Registers .gpx as a valid media type |
user/plugins/api/api.yaml |
session_enabled: true for GPX manager auth |
user/themes/intotheeast/css/tokens.css |
Design tokens (colors, fonts, spacing) |
CLAUDE.md |
Project rules and always-loaded context for Claude |