Once user/env/<host>/ exists, Grav Admin saves ALL config (system + plugin) into that env tree, not user/config/ — so prod's git-sync.yaml lives at the env path, and Admin-on-server config edits are server-only (not committed, not synced). Documented the footgun in CLAUDE.md §1 (session-loaded) and rewrote git-sync-notes.md: env-path location, v3.4.4 folders format, gitignore table (incl. versions.yaml + security-private.php TODO), and fresh-server enable procedure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Np4cMQLF77i664CAQXySzU
19 KiB
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 anddocs/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 viabin/gpm self-upgrade) - Admin: Admin2 v2.0.10 (plugin slug:
admin2, NOTadmin) - GPM channel:
stable— set inuser/config/system.yaml→gpm.releases(authoritative).GRAV_CHANNEL=productionindocker-compose.ymlis cosmetic/consistency only - Plugin management:
admin2,api, andflex-objectsare now GPM-managed viaplugins.txt(installed bymake install-plugins), no longer hand-extracted from the core bundle.git-syncstays remote-only — never inplugins.txt - Docker image:
getgrav/gravwithGRAV_CHANNEL=production - PHP session:
session.save_path = /tmpset inphp/php-local.ini
Known issue (2026-07-04): Form 9.1.10 regressed the
filepondupload field — on the post-submit re-render,filepond.html.twigrunsmergeon a string and 500s. The journal entry still saves correctly; only the browser re-render errors. This breaks the 6post.spec.jsUI 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) and04.stories/(stories). The former02.map/and03.stats/standalone views were removed (2026-07-04, seedocs/working/plans/2026-07-04-standalone-page-cleanup.md) — map and stats now render inline on the trip page 01.dailies/and04.stories/areroutable:falsedata containers — visiting/trips/<slug>/dailiesor/storiesdirectly 404s/redirects; their children (entries/stories) render at their own detail URLs and are aggregated by the trip page- Site nav in
base.html.twighas Home + Past Trips only — does not link to trip sub-sections - Post form parent (
post-form.md→pageconfig.parent) must be kept in sync withactive_trip - The trip page (
trip.html.twig) uses a client-side filter bar (All content / Journal / Stories). The standalone/dailies,/map,/stats,/storiesview 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 thetrip-feed-colpartial (see "Shared trip-feed-col partial" below) - Stats are shown inline on the trip page via a toggle (the standalone
/statsview 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.twigand the active branch ofhome.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.twigpartial with its own inline init, plus a full-pagemap.html.twig). Those were deleted in the 2026-07-04 standalone-page cleanup; the2026-06-27-map-init-consolidationplan had already moved trip + home ontoinitEntryMap.
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.twigand the active branch ofhome.html.twig(both via{% include ... with {...} only %}) - Sibling:
partials/home-predeparture.html.twig— the home-only "Coming soon" landing state.home.html.twigpicks it with{% if all_items|length == 0 %}→home-predeparture{% else %}→trip-feed-col. Keeptrip-feed-colsingle-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: truein 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: trueinuser/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}
- List:
- Slugification: filenames are slugified client-side before upload (spaces/special chars → hyphens, lowercase); the file is sliced to a plain
Blobso the third argument toFormData.appendis always used as the filename - Media type:
.gpxis registered inuser/config/media.yamlso 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 pushuser/to Gitea (triggers production pull via webhook)make content-pull— pull latest from Gitea to localplugins.txtis manually maintained — installing a plugin via Admin does NOT update itmake demo-load— load demo content intoitaly-2026-demotrip (12 journal entries + 4 stories + 7 GPX files); source inuser/docs/demo/trips/italy-2026-demo/make demo-reset— remove the entireitaly-2026-demopages 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 andmake content-pushcadence. The outer repo pins an exactuser/commit via theusergitlink. - 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 fromuser/'s publishedmain(prefer the merge-to-main commit, not a squash-away branch tip), and pushuser/before the outer repo (superproject references a child SHA that must already exist upstream). The pin is dev-side coordination only — production pullsuser/via the content webhook independently. M user/m useris normal.M= pin differs fromuser/HEAD (bump pending/intentional).m= submodule working tree dirty (e.g. local-testingconfig/site.yaml). Neither is an error — do not "fix" them by committing the gitlink or thesite.yaml.- Worktrees for parallel work. A worktree off
maingets its ownuser/(git submodule update --init user) and can run its own dev server (docker compose -p itte-<feature> up— the./usermount is relative, so each worktree serves its own content). Tooling worktrees live under.worktrees/(excluded via.git/info/exclude). To add a commit tomainwhile the main checkout is on another branch, use a throwawaymainworktree rather thangit 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.yamland clears cache. It deep-merges over the committedsystem.yaml. - Not synced by content:
user/env/is outside the content repo's tracked folders, socontent-push/ git-sync /remote-fetch-contentdo not restore it. Re-runmake remote-apply-env-prodafter any fresh install. - The hostname segment defaults to
REMOTE_HOST; override withWEB_HOSTin.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 touser/env/intotheeast.com/config/plugins/<name>.yaml, notuser/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 onlypages/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/...anduser/env/<host>/config/...(env wins). Server tooling must search the env path first — seescripts/git-sync-toggle.shandmake remote-diag.- Repo-authored config (
user/config/...viamake 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
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.