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>
208 lines
11 KiB
Markdown
208 lines
11 KiB
Markdown
# 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:
|
|
|
|
1. **GPM-managed** (`plugins.txt` → `make install-plugins`): the marketplace plugins, including `login`, `form`, `admin2`, `api`, `flex-objects`, shortcodes, etc. As of the 2.0.4 upgrade, `admin2`/`api`/`flex-objects` moved into this category — they were previously hand-extracted from the core bundle. Update with `bin/gpm update` (`make remote-update-plugins-<env>` on servers).
|
|
2. **Custom, in-repo** (`user/plugins/` allowlisted in `user/.gitignore`): `cache-on-save`, `story-blocks`, `entry-actions`. Versioned in the user repo.
|
|
3. **Remote-only**: `git-sync` — installed and configured only on servers, **never** in `plugins.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.twig` partial with its own inline init, plus a full-page `map.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 |
|