Files
intotheeast-com/CLAUDE.md
T
m038andClaude Opus 4.8 fb9a47ea0c docs: record Grav 2.0.4 upgrade; GPM-managed plugins; prod runbook
Update CLAUDE.md + architecture.md stack to Grav 2.0.4 stable, admin2 2.0.10,
gpm.releases: stable, and the three-category plugin-management model
(GPM-managed / custom-in-repo / remote-only git-sync). Add execution outcome,
Phase 3 prod fresh-install runbook (documentation-only), and mark plan Status
complete. Note the Form 9.1.10 filepond regression as a known issue owned by
the separate form-to-page rework.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
2026-07-04 19:39:00 +02:00

214 lines
16 KiB
Markdown

# CLAUDE.md
## 0. Project specifics
**Only ever write changes in this folder (travel-blog-intotheeast/) or its subfolders.**
### Folder explanation
- **./**: Grav CMS dev environment for intotheeast travel blog
- **scripts/**: Server install and maintenance scripts
- **user/**: Site content, config, pages, and theme — its own git repo (`intotheeast-com-content.git`), tracked by the outer repo as a **git submodule** (pinned commit). See "Dual-repo submodule structure" below and `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`
- **docs/**: All plans, specs, and project documentation (moved here from `user/docs/` on 2026-06-19)
- **docs/solutions/**: documented solutions to past problems (bugs, patterns, workflow gotchas), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in a documented area
- **CONCEPTS.md** (repo root): shared domain vocabulary (Trip, Entry, Story, Active Trip). Relevant when orienting to the codebase or discussing domain concepts
### Current stack
- **Grav:** 2.0.4 stable (baked into the custom Docker image via `Dockerfile`; server upgrades in place via `bin/gpm self-upgrade`)
- **Admin:** Admin2 v2.0.10 (plugin slug: `admin2`, NOT `admin`)
- **GPM channel:** `stable` — set in `user/config/system.yaml``gpm.releases` (authoritative). `GRAV_CHANNEL=production` in `docker-compose.yml` is cosmetic/consistency only
- **Plugin management:** `admin2`, `api`, and `flex-objects` are now **GPM-managed via `plugins.txt`** (installed by `make install-plugins`), no longer hand-extracted from the core bundle. `git-sync` stays **remote-only** — never in `plugins.txt`
- **Docker image:** `getgrav/grav` with `GRAV_CHANNEL=production`
- **PHP session:** `session.save_path = /tmp` set in `php/php-local.ini`
> Known issue (2026-07-04): Form 9.1.10 regressed the `filepond` upload field — on the post-submit re-render, `filepond.html.twig` runs `merge` on a string and 500s. The journal entry still saves correctly; only the browser re-render errors. This breaks the 6 `post.spec.js` UI specs. Being fixed separately in the form-to-page/image-upload rework — **do not** work around it here.
### Dev server
The Docker dev server runs at **http://localhost:8081** (mapped from container port 80 in `docker-compose.yml`).
### Trip entity architecture
The site is structured around Trip entities. Key facts:
- Active trip is set in `user/config/site.yaml``active_trip: japan-korea-2026`
- Trip pages live at `user/pages/01.trips/<slug>/`
- Each trip has two content subfolders: `01.dailies/` (journal entries) and `04.stories/` (stories). The former `02.map/` and `03.stats/` standalone views were **removed** (2026-07-04, see `docs/working/plans/2026-07-04-standalone-page-cleanup.md`) — map and stats now render inline on the trip page
- `01.dailies/` and `04.stories/` are `routable:false` **data containers** — visiting `/trips/<slug>/dailies` or `/stories` directly 404s/redirects; their children (entries/stories) render at their own detail URLs and are aggregated by the trip page
- Site nav in `base.html.twig` has Home + Past Trips only — does not link to trip sub-sections
- Post form parent (`post-form.md``pageconfig.parent`) **must be kept in sync** with `active_trip`
- The trip page (`trip.html.twig`) uses a **client-side filter bar** (All content / Journal / Stories). The standalone `/dailies`, `/map`, `/stats`, `/stories` view pages no longer exist — do NOT try to re-create them or link to them. This filter bar + stats chrome is shared with the home active-trip view via the `trip-feed-col` partial (see "Shared trip-feed-col partial" below)
- Stats are shown inline on the trip page via a toggle (the standalone `/stats` view was removed)
- GPX route files live as media on the trip page itself, parsed client-side via toGeoJSON (bundled into `js/map.js`) and drawn on the trip/home map
- Manage GPX files (view/upload/delete) at `/gpx-manager` — requires admin login; filenames are auto-slugified on upload
### One map path: `MapUtils.initEntryMap` + the `entry-map` partial
There is a **single** map code path on the site. The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js` (bundled into `js/map.js` via `make build-assets` — never hand-edit `js/map.js`). It builds the MapLibre map, places markers/popups, fits bounds, draws the GPX journey, and wires the fullscreen toggle.
The map **markup + invocation** is shared via one partial:
- **Partial:** `user/themes/intotheeast/templates/partials/entry-map.html.twig`
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
It renders the `.home-map-col` column (map div `#{{ map_id }}` + fullscreen button) and, when `entries` is non-empty, a thin `<script>` that assigns `window.{{ map_global }}` from `initEntryMap`. Callers resolve header values (use_gpx / autoconnect) and pass them in.
**Parameters:**
| 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'` |
The map globals `window.tripMap` / `window.homeMap` are asserted by the Playwright map specs, so any surface using this partial must keep assigning them.
> History: this replaced the old three-variant setup (a `feed-map.html.twig` partial with its own inline init, plus a full-page `map.html.twig`). Those were deleted in the 2026-07-04 standalone-page cleanup; the `2026-06-27-map-init-consolidation` plan had already moved trip + home onto `initEntryMap`.
### Shared trip-feed-col partial
The home page's active-trip view and the trip page render the **same feed-col chrome** (date-range header, filter bar, stats/cycling panels, feed loop) via one shared Twig partial. This is separate from the `entry-map` partial above — it is the column **beside** the map, not the map.
- **Partial:** `user/themes/intotheeast/templates/partials/trip-feed-col.html.twig`
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
- **Sibling:** `partials/home-predeparture.html.twig` — the home-only "Coming soon" landing state. `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.
**Parameters (`trip-feed-col`):**
| 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, no sort button) |
`home-predeparture` takes only `trip_page`.
**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` (rebuild with `make build-assets`; never hand-edit `js/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).
### GPX file management
GPX files are stored as page media on the trip page (`user/pages/01.trips/<slug>/`). They are picked up automatically by `trip.html.twig` (and `home.html.twig`) via `trip_page.media.all`, filtered to `.gpx`, and passed to the shared `entry-map` partial.
The GPX manager page (`user/pages/03.gpx-manager/`) provides a browser UI at `/gpx-manager`:
- **Auth:** enforced by Login plugin via `access.admin.login: true` in frontmatter — shows login form if not authenticated
- **Template:** `user/themes/intotheeast/templates/gpx-manager.html.twig`
- **API:** uses Grav API v1 with session cookie auth (`session_enabled: true` in `user/plugins/api/api.yaml`)
- List: `GET /api/v1/pages{route}/media`
- Upload: `POST /api/v1/pages{route}/media` (multipart)
- Delete: `DELETE /api/v1/pages{route}/media/{filename}`
- **Slugification:** filenames are slugified client-side before upload (spaces/special chars → hyphens, lowercase); the file is sliced to a plain `Blob` so the third argument to `FormData.append` is always used as the filename
- **Media type:** `.gpx` is registered in `user/config/media.yaml` so Grav serves and tracks these files
To add GPX files without the browser UI, drop them directly into `user/pages/01.trips/<slug>/` and run `make content-push`.
### Switching to a new trip
Two places hardcode the active trip slug. Grav's config and page frontmatter are static YAML — no variable substitution is possible, so these cannot read from `site.yaml` automatically. **Both must be updated together** when starting a new trip, or entries will be posted to the wrong folder.
| File | Key | Example value |
|---|---|---|
| `user/config/site.yaml` | `active_trip` | `italy-2027` |
| `user/pages/02.post/post-form.md` | `pageconfig.parent` | `/trips/italy-2027/dailies` |
Note: `system.yaml` `home.alias` is permanently set to `/home` (the real home page) and does **not** need to change when switching trips.
After updating, also create the new trip's page tree under `user/pages/01.trips/<new-slug>/` with the two content subfolders `01.dailies/` and `04.stories/` (each with an inert `routable:false` container `.md`), plus the trip's `trip.md`. Do **not** recreate `02.map/` or `03.stats/` — those standalone views were retired.
### Environment
**Never read `.env`, `.env.prod`, or `.env.test`** — they contain sensitive credentials. You may pass them to commands (e.g. `docker compose`, `make`) but never read their contents directly. Ask the user if you need environment-specific information.
### Remote operations
Always use `make` commands for anything on the production server (`make remote-install-plugins`, `make remote-clean`, etc.) — never SSH directly since credentials live in `.env`. If a remote operation isn't covered by an existing `make` command, either ask the user to run it manually or suggest adding a new `make` command if it seems reusable.
### Content sync
- `make content-push` — commit and push `user/` to Gitea (triggers production pull via webhook)
- `make content-pull` — pull latest from Gitea to local
- `plugins.txt` is manually maintained — installing a plugin via Admin does NOT update it
- `make demo-load` — load demo content into `italy-2026-demo` trip (12 journal entries + 4 stories + 7 GPX files); source in `user/docs/demo/trips/italy-2026-demo/`
- `make demo-reset` — remove the entire `italy-2026-demo` pages folder and clear cache (full reset; re-run demo-load to restore)
### User repo gitignore
Only these folders are tracked in the `user/` Git repo: `pages/`, `config/`, `accounts/`, `themes/`. The `plugins/` and `data/` folders are excluded.
### Dual-repo submodule structure
`user/` is a **git submodule** of the outer repo (`.gitmodules` at the root; git dir absorbed into `.git/modules/user`). Full workflow: `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`. The essentials:
- **Two repos, two cadences.** Outer repo = dev environment (tests/docs/scripts/Docker). `user/` = content + theme, with its own remote and `make content-push` cadence. The outer repo pins an exact `user/` commit via the `user` gitlink.
- **Pointer-bump convention.** Routine content changes → **do not** bump the pin (leave it stale; harmless). At the **end of a cross-repo feature** → bump the pin once to the finished `user/` commit. Pin a commit reachable from `user/`'s published `main` (prefer the merge-to-main commit, not a squash-away branch tip), and **push `user/` before the outer repo** (superproject references a child SHA that must already exist upstream). The pin is dev-side coordination only — production pulls `user/` via the content webhook independently.
- **`M user` / `m user` is normal.** `M` = pin differs from `user/` HEAD (bump pending/intentional). `m` = submodule working tree dirty (e.g. local-testing `config/site.yaml`). Neither is an error — do not "fix" them by committing the gitlink or the `site.yaml`.
- **Worktrees for parallel work.** A worktree off `main` gets its own `user/` (`git submodule update --init user`) and can run its own dev server (`docker compose -p itte-<feature> up` — the `./user` mount is relative, so each worktree serves its own content). Tooling worktrees live under `.worktrees/` (excluded via `.git/info/exclude`). To add a commit to `main` while the main checkout is on another branch, use a throwaway `main` worktree rather than `git checkout main`.
## 1. Environment modes
### Rule: do not switch modes during development
**Never toggle between development and production mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than temporarily flipping a mode flag to work around it. Mode switches introduce inconsistent state and make bugs harder to reproduce.
### Development mode (current)
Active settings in `user/config/system.yaml`:
| Setting | Dev value | Why |
|---|---|---|
| `twig.cache` | `false` | Theme file edits take effect immediately; no stale compile errors |
With these settings, Grav rebuilds templates on every request. This is intentionally slower but means you never need to flush cache after editing a `.html.twig` file.
### Production mode (not yet configured)
Before going live, change in `user/config/system.yaml`:
| Setting | Prod value | Why |
|---|---|---|
| `twig.cache` | `true` | Templates compiled once and reused; safe because theme files don't change at runtime |
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in the trip page feed at `/trips/italy-2026-demo` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
### What the cache-on-save plugin handles
The custom plugin at `user/plugins/cache-on-save/` clears Grav's page-tree cache on every `new-entry` form submission. This ensures new posts appear in the tracker feed immediately in both modes — it does not depend on whether Twig caching is on or off.
## 2. Local development setup
Full setup guide: [`docs/guides/local-setup.md`](docs/guides/local-setup.md)
### Superpowers skill paths
Specs: `docs/working/specs/YYYY-MM-DD-<topic>-design.md`
Plans: `docs/working/plans/YYYY-MM-DD-<topic>.md`
The brainstorming and writing-plans skills default to `docs/superpowers/`; these lines override that default.
### Plan status convention
Every plan in `docs/working/plans/` must have a `**Status:**` line immediately after the title heading:
| Status | Meaning |
|---|---|
| `📋 Not started` | Plan written; work not yet begun |
| `🔄 In progress — <note>` | Actively being worked on |
| `⏸️ Deferred — <reason>` | Intentionally postponed |
| `✅ Complete (YYYY-MM-DD)` | Done |
| `❌ Abandoned — <reason>` | Won't implement |
**When asked what's open:** surface `Not started` and `In progress` plans. Show `Deferred` plans but label them clearly. Omit `Complete` and `Abandoned` unless explicitly asked.
**When finishing a plan:** update the `**Status:**` field in the plan file to `✅ Complete (YYYY-MM-DD)` before closing the session. This applies whether execution was done by Claude directly, via the superpowers:executing-plans skill, or via superpowers:subagent-driven-development.