Disproven end-to-end on this branch (Grav 2.0.4 + form 9.1.10): file-upload returns 200, photo persists to disk, POST /post returns 200 with no error page. The form plugin's upload path is byte-identical 9.1.6->9.1.10, so the version was never the cause. The original failures were a test artifact -- upload fixtures named as dotfiles (.real-photo.jpg) are rejected by the form as 'Bad filename'. The note was also mis-scoped: a 'we're working on it, don't touch' reminder from the upgrade session belonged in that session's commit/plan, not as a standing project-wide directive that then told THIS rework branch to stand down. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
252 lines
18 KiB
Markdown
252 lines
18 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`
|
|
|
|
### 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 (per-environment override)
|
|
|
|
Production needs different Twig settings than dev, but **never change the
|
|
committed `user/config/system.yaml`** — `twig.cache: false` (and `debug`/
|
|
`auto_reload: true`) are the *intended dev values*, and committing prod values
|
|
there breaks local development for everyone.
|
|
|
|
Instead, prod values are a **per-environment override** deployed to the server
|
|
only, via Grav's per-environment config (`environment://config`, keyed on the
|
|
request hostname):
|
|
|
|
| Setting | Dev (committed) | Prod (override) | Why prod differs |
|
|
|---|---|---|---|
|
|
| `twig.cache` | `false` | `true` | Compile templates once and reuse |
|
|
| `twig.debug` | `true` | `false` | No debug functions in prod |
|
|
| `twig.auto_reload` | `true` | `false` | Don't stat templates every request |
|
|
|
|
- **Source of truth:** `deploy/env/prod/system.yaml` (version-controlled).
|
|
- **Deploy:** `make remote-apply-env-prod` — writes it to
|
|
`<webroot>/user/env/<hostname>/config/system.yaml` and clears cache. It
|
|
deep-merges over the committed `system.yaml`.
|
|
- **Not synced by content:** `user/env/` is outside the content repo's tracked
|
|
folders, so `content-push` / git-sync / `remote-fetch-content` do **not**
|
|
restore it. **Re-run `make remote-apply-env-prod` after any fresh install.**
|
|
- The hostname segment defaults to `REMOTE_HOST`; override with `WEB_HOST` in
|
|
`.env.<env>` if Grav sees a different host than the SSH host.
|
|
|
|
> **⚠️ Once `user/env/<hostname>/` exists, Grav's Admin saves ALL config there.**
|
|
> Creating the env override dir has a site-wide side effect: Grav's Admin panel
|
|
> writes **every** config change (system *and* plugin) into the active
|
|
> environment's config tree — e.g. editing a plugin on prod saves to
|
|
> `user/env/intotheeast.com/config/plugins/<name>.yaml`, **not**
|
|
> `user/config/plugins/<name>.yaml`. Consequences you must remember:
|
|
> - Config edited via **Admin on the server is server-only**: `user/env/` is
|
|
> outside the content repo's tracked folders, so it is **not committed** and
|
|
> **not synced by git-sync** (which syncs only `pages`/`config`/`themes`).
|
|
> Good for secrets — `git-sync.yaml` (token) safely lives at the env path —
|
|
> but it means prod Admin config edits silently do **not** reach Gitea/local.
|
|
> - When reading/writing server config, check **both** `user/config/...` and
|
|
> `user/env/<host>/config/...` (env wins). Server tooling must search the env
|
|
> path first — see `scripts/git-sync-toggle.sh` and `make remote-diag`.
|
|
> - Repo-authored config (`user/config/...` via `make content-push`) still
|
|
> applies everywhere; the env tree only holds per-host overrides + Admin-on-
|
|
> server edits. Full details: `docs/working/git-sync-notes.md`.
|
|
|
|
**Pre-launch smoke test required:** with the prod override applied, submit one
|
|
post via `/post` and confirm the entry appears in the trip page feed
|
|
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.
|