Files
intotheeast-com/CLAUDE.md
T
m038andClaude Opus 5 2fbfc884b9 docs(claude-md): fix four stale facts; document testing, dev commands, patches
Audit of the root CLAUDE.md (scored 76/100) found the architecture and
remote-ops coverage strong but the test workflow entirely undocumented and
several facts drifted from the tree.

Corrections (verified against the checkout):
- active_trip was japan-korea-2026; committed value is /trips/denmark-2026
  and no japan-korea trip folder exists. Also note the value is a route.
- Admin2 2.0.10 -> 2.0.12 (installed version).
- demo-load/demo-reset described as italy-only; the Makefile loops over every
  fixture trip under user/docs/demo/trips/.
- user/ gitignore claim omitted the three un-ignored site-owned plugins and
  the secret/env exclusions.

Additions:
- Section 3 "Testing": make test/test-config/test-post/test-ui, the
  auto-created testrunner account, Playwright layout, the auth.setup.js
  storageState dependency, and GRAV_BASE_URL for worktree servers.
- Local dev command table, plus which theme assets are build outputs
  (js/src -> bundles) versus hand-authored (css/style.css, css/tokens.css).
- Custom plugins: story-blocks and entry-actions alongside cache-on-save.
- Local plugin patches: install-plugins overwrites git-ignored third-party
  plugins; deploy/patches/ + apply-plugin-patches is the tracked fix path.
- travel-memories service on 8082; make pixelfed-import.

Every make target and file path referenced was verified to resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 20:28:53 +02:00

24 KiB
Raw Blame History

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.7 stable (baked into the custom Docker image via Dockerfile; server upgrades in place via bin/gpm self-upgrade)
  • Admin: Admin2 v2.0.12 (plugin slug: admin2, NOT admin)
  • GPM channel: stable — set in user/config/system.yamlgpm.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). A second service, travel-memories, runs at http://localhost:8082. Both ports and the container name are overridable via GRAV_PORT / TM_PORT / GRAV_CONTAINER — a worktree's .worktree-env sets these so isolated servers never collide (see "Dual-repo submodule structure").

Local dev commands

Command What it does
make setup One-shot first run: buildstartinstall-pluginsfix-perms
make start / make stop Bring the compose stack up / down (start-grav = Grav service only)
make build Rebuild the custom Docker image (after Dockerfile changes)
make build-assets Rebuild theme JS/CSS bundles in a node:20-alpine container
make install-plugins GPM-install everything in plugins.txt, then re-apply local patches
make fix-perms Fix ownership in the container after root-owned writes

make build-assets is mandatory after editing anything in user/themes/intotheeast/js/src/. Sources live in js/src/; esbuild writes the committed bundles — js/main.js, js/map.js, js/feed-actions.js, js/trip-publish.js, js/post/, and the CSS extracted into css-compiled/. Never hand-edit those. By contrast css/style.css and css/tokens.css are hand-authored sources, not build outputs. build-assets runs as your host UID (--user) so the outputs in the bind-mounted user/ tree are not root-owned.

Custom plugins

Three plugins are site-owned and tracked in the user/ repo (everything else under user/plugins/ is GPM-managed and git-ignored):

Plugin Role
cache-on-save Clears the page-tree cache on new-entry submits, and derives the write target from site.active_trip (onFormValidationProcessedsetData('parent', …))
story-blocks Storytelling shortcode blocks for long-form stories (depends on shortcode-core)
entry-actions Owner-only, active-trip-scoped journal entry actions (delete) via the Grav API

Local plugin patches

Third-party plugins live in the git-ignored user/plugins/, so local fixes to them do not travel with the content repo and are overwritten by make install-plugins or a fresh image build. Keep the fix as a tracked patch in deploy/patches/ instead:

  • make apply-plugin-patches — idempotent git apply (skips already-applied patches). make install-plugins runs it automatically as its last step
  • make remote-apply-plugin-patches-test / -prod — piped over SSH into patch -p1 --forward; also runs automatically after a remote plugin install
  • Details and the current patch list: deploy/patches/README.md

Trip entity architecture

The site is structured around Trip entities. Key facts:

  • Active trip is set in user/config/site.yamlactive_trip (currently /trips/denmark-2026). The value is a route, not a bare slug
  • 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
  • New journal entries are written to the active trip's dailies — the write target is derived from site.active_trip at submit time by the cache-on-save plugin (post-form.md no longer hardcodes pageconfig.parent)
  • 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)
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, and .trip-header-banner cover strip) that renders between the counts and the filter bar. trip.html.twig passes true; home.html.twig omits it so those extras never leak onto the home route (the only include keeps it off by default).

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

The active trip lives in one place now: site.active_trip. The post form no longer hardcodes a pageconfig.parent — the cache-on-save plugin derives the write target from site.active_trip at submit time (onFormValidationProcessedsetData('parent', …)), so there is nothing to keep in sync.

File Key Example value How to edit
user/config/site.yaml active_trip /trips/italy-2027 Admin → Configuration → Site → Active Trip (page-picker rooted at /trips; blueprint at user/blueprints/config/site.yaml)

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.

For a full upgrade/deploy through local → test → prod (ordered steps, smoke checklist, rollback), follow the runbook at docs/guides/deploy-cycle.md.

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 every fixture trip under user/docs/demo/trips/ into the pages tree (currently italy-2026-demo and no-photos-demo). Add a new fixture by dropping a trip folder there; no Makefile edit needed
  • make demo-reset — remove the demo trips' pages folders and clear cache (full reset; re-run demo-load to restore)
  • make pixelfed-import — import posts from Pixelfed via scripts/pixelfed-import.py

User repo gitignore

Only these folders are tracked in the user/ Git repo: pages/, config/, accounts/, themes/. The plugins/ and data/ folders are excluded — except the three site-owned plugins, which are un-ignored explicitly (see "Custom plugins" below). Also ignored: the test accounts, italy-2026-demo pages, secrets (config/plugins/git-sync.yaml, config/security.yaml, api-private.php), and the whole env/ override tree.

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 — use the make targets, don't do it by hand. make worktree-new NAME=<feature> (from the main checkout) creates the outer worktree off main, initialises its own user/ submodule, branches both, and starts an isolated dev server (own container name + auto-assigned port 8090+, persisted in a git-ignored .worktree-env so every make/compose command in that worktree targets its own server). make worktree-rm NAME=<feature> tears it down cleanly (compose down → submodule deinitworktree removeprune) — skipping the deinit is what leaves orphaned .worktrees/ dirs. Worktrees live under .worktrees/ (excluded via .git/info/exclude). A fresh worktree's user/ is empty until the submodule init runs, and M user/m user is normal (see above) — do not "fix" either. 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.yamltwig.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

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.

3. Testing

The dev server must be running (make start) — every suite drives the live site over HTTP.

Command Scope
make test Everything: test-configtest-posttest-ui
make test-config Form/config sanity via scripts/test-form-config.sh
make test-post End-to-end post submission via scripts/test-post.sh
make test-ui Playwright suite (npx playwright test)
  • Test account is automatic. test-post and test-ui depend on test-account, which creates a testrunner admin (password Testpass1234) inside the container if absent. It is git-ignored — never commit it, and keep the password free of shell/Make/URL-special characters since several consumers interpolate it.
  • Playwright layout: config at playwright.config.js, specs under tests/ui/ (a11y, auth, dailies, gpx, home, maps, nav, post, stories, trip), shared helpers in tests/ui/helpers.js, global setup/teardown in tests/.
  • Auth is a dependency project. auth.setup.js runs first and writes tests/.auth/user.json; the chromium project reuses it as storageState. Don't add per-test logins.
  • Base URL: defaults to http://localhost:8081; override with GRAV_BASE_URL (required when testing a worktree's isolated server on 8090+).
  • Single spec / focused run: npx playwright test tests/ui/maps (add --headed to watch). retries: 0 and screenshots-on-failure only, so a failure is a real failure.
  • window.tripMap / window.homeMap are asserted by the map specs — any surface using the entry-map partial must keep assigning them (see "One map path" above).