Compare commits
22
Commits
2fbfc884b9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb22c368ba | ||
|
|
143ec135c0 | ||
|
|
e106da0206 | ||
|
|
cfe070efec | ||
|
|
3250ad366a | ||
|
|
4450bd6eec | ||
|
|
28bbd41868 | ||
|
|
d57041d316 | ||
|
|
b02f27f559 | ||
|
|
6398542845 | ||
|
|
e79275a3ab | ||
|
|
f9ab3b1561 | ||
|
|
1f4e2aeba5 | ||
|
|
cdae34a706 | ||
|
|
285e61573e | ||
|
|
5edaf3ee1e | ||
|
|
9ec2349cd6 | ||
|
|
829325c9c7 | ||
|
|
839a4d0e69 | ||
|
|
ed6e43ae51 | ||
|
|
a517331d1b | ||
|
|
01c3e72c8f |
@@ -1,305 +1,76 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
## 0. Project specifics
|
Rules, gotchas, and entry points — the things that must change what you do *before* you open a file. Everything descriptive lives next to the code:
|
||||||
|
|
||||||
**Only ever write changes in this folder (travel-blog-intotheeast/) or its subfolders.**
|
| Need | Read |
|
||||||
|
|
||||||
### 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.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`). 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: `build` → `start` → `install-plugins` → `fix-perms` |
|
| How the site hangs together — stack, plugin roles, templates, partial contracts, data flows | [`docs/reference/architecture.md`](docs/reference/architecture.md) |
|
||||||
| `make start` / `make stop` | Bring the compose stack up / down (`start-grav` = Grav service only) |
|
| Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) |
|
||||||
| `make build` | Rebuild the custom Docker image (after `Dockerfile` changes) |
|
| Doing something operational — posting, writing stories, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
|
||||||
| `make build-assets` | Rebuild theme JS/CSS bundles in a `node:20-alpine` container |
|
| Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) |
|
||||||
| `make install-plugins` | GPM-install everything in `plugins.txt`, then re-apply local patches |
|
| A bug or workflow trap already hit and written up | [`docs/solutions/`](docs/solutions/) — grep the `module`/`tags`/`problem_type` frontmatter; check when working in a documented area |
|
||||||
| `make fix-perms` | Fix ownership in the container after root-owned writes |
|
| Folder map, prerequisites, the full `make` command list | [`README.md`](README.md) |
|
||||||
|
|
||||||
**`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.
|
The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
|
||||||
|
|
||||||
### Custom plugins
|
## Hard rules
|
||||||
|
|
||||||
Three plugins are site-owned and tracked in the `user/` repo (everything else under `user/plugins/` is GPM-managed and git-ignored):
|
- **Only ever write inside `travel-blog-intotheeast/`** or its subfolders.
|
||||||
|
- **Never read `.env`, `.env.prod`, `.env.test`** — they hold credentials. Pass them to commands (`make`, `docker compose`) but never read them; ask the user if you need a value.
|
||||||
|
- **Never SSH to a server directly** — use the `make remote-*` targets, since credentials live in `.env`. If no target covers what you need, ask the user to run it or propose a new target.
|
||||||
|
- **Never hand-edit build output** — sources and outputs share folders under `user/themes/intotheeast/` (paths below are relative to it), so know which is which. Run `make build-assets` after editing any source.
|
||||||
|
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
|
||||||
|
- `css-compiled/` and `fonts/` are generated by esbuild from the `js/src/` entrypoints' CSS and font imports (fontsource, photoswipe, maplibre-gl) — **not** from `css/`. `css/style.css` and `css/tokens.css` are hand-authored and served directly (`partials/base.html.twig`), so editing them needs no rebuild.
|
||||||
|
- `templates/partials/weather-icons.html.twig` is generated (source: `scripts/gen-weather-icons.js`).
|
||||||
|
- **Never toggle dev↔prod mode mid-session.** If a caching or config issue appears, fix it at the application level (plugin, template logic) rather than flipping a mode flag — mode switches leave inconsistent state and make bugs harder to reproduce.
|
||||||
|
|
||||||
| Plugin | Role |
|
## Dev environment
|
||||||
|---|---|
|
|
||||||
| `cache-on-save` | Clears the page-tree cache on `new-entry` submits, and derives the write target from `site.active_trip` (`onFormValidationProcessed` → `setData('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
|
- Dev server: **http://localhost:8081** (`make setup` on a first run, `make start` / `make stop` after). A second service, `travel-memories`, runs on :8082. A worktree gets its own container and port `8090+` from its `.worktree-env` — pass `GRAV_BASE_URL` when pointing tests at one.
|
||||||
|
- `user/config/system.yaml` is committed with **dev** values (`twig.cache: false`), so templates recompile per request and no cache flush is needed after editing a `.html.twig`. Prod values live in `deploy/env/prod/system.yaml` and **never** in `user/config/`.
|
||||||
|
- ⚠️ **Once `user/env/<hostname>/` exists on a server, Grav's Admin saves ALL config there** — system *and* plugin. So (a) config edited via Admin on the server is server-only and silently never reaches Gitea or local; (b) when reading or writing server config, check **both** `user/config/…` and `user/env/<host>/config/…` — **env wins**, so look there first. Mechanics: [`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md).
|
||||||
|
- The Admin plugin slug is **`admin2`**, not `admin`.
|
||||||
|
- `plugins.txt` is maintained by hand — installing a plugin via Admin does **not** update it. `git-sync` is **remote-only** and must never appear in it.
|
||||||
|
- Everything under `user/plugins/` is git-ignored and gets overwritten by `make install-plugins` — **except** the three site-owned plugins (`cache-on-save`, `story-blocks`, `entry-actions`). So a fix to a third-party plugin must be a tracked patch in `deploy/patches/`, never an in-place edit: [`deploy/patches/README.md`](deploy/patches/README.md).
|
||||||
|
|
||||||
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:
|
## Content and trips
|
||||||
|
|
||||||
- `make apply-plugin-patches` — idempotent `git apply` (skips already-applied patches). `make install-plugins` runs it automatically as its last step
|
- The active trip lives in **one** place: `user/config/site.yaml` → `active_trip`, and its value is a **route** (`/trips/denmark-2026`), not a bare slug.
|
||||||
- `make remote-apply-plugin-patches-test` / `-prod` — piped over SSH into `patch -p1 --forward`; also runs automatically after a remote plugin install
|
- `cache-on-save` derives the post write target from `active_trip` at submit time. **Never re-add a `pageconfig.parent` to `post-form.md`** — a static parent would override it and reintroduce the old silent-desync bug. Switching trips: [`docs/guides/trip-switching.md`](docs/guides/trip-switching.md).
|
||||||
- Details and the current patch list: `deploy/patches/README.md`
|
- The standalone `/dailies`, `/map`, `/stats` and `/stories` trip views were **deleted** (2026-07-04) — map, stats, and filtering all render inline on the trip page. Do not re-create them or link to them. `01.dailies/` and `04.stories/` are `routable:false` data containers whose children are aggregated by the trip page.
|
||||||
|
- GPX routes are page media on the trip page, auto-detected — no manual linking. Manage them at `/gpx-manager` (admin login): [`docs/guides/gpx-manager.md`](docs/guides/gpx-manager.md).
|
||||||
|
- `make content-push` commits and pushes `user/` to Gitea, which triggers the production pull; `make content-pull` is the reverse.
|
||||||
|
|
||||||
### Trip entity architecture
|
## Two shared partials — the rules
|
||||||
|
|
||||||
The site is structured around Trip entities. Key facts:
|
Trip and home render the same map and feed chrome through two shared partials, both included `with {…} only`. Parameter contracts: [`docs/reference/architecture.md`](docs/reference/architecture.md) → "Shared partial contracts". What must not break:
|
||||||
- Active trip is set in `user/config/site.yaml` → `active_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
|
- **`partials/entry-map.html.twig` is the only path for a *display* map** — the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js` (a hand-authored file, imported by `js/src/map.js`). Do not add another display-map implementation; an older three-variant setup was deliberately consolidated away.
|
||||||
|
- **One sanctioned exception: `js/src/location-map.js`**, the `/post` form's pin *editor* (one draggable marker, no popups/GPX/bounds-fitting, `maplibre-gl` lazy-imported so a GPS-only submit never fetches it). It shares exactly one thing with the display path — `MAP_STYLE` from `js/src/map-style.js`, imported by both so the basemap cannot drift. Do not fold it into `initEntryMap`, and do not add a *third* path.
|
||||||
|
- It must keep assigning **`window.tripMap` / `window.homeMap`** — the Playwright map specs assert those globals.
|
||||||
|
- **Keep `trip-feed-col.html.twig` single-purpose.** Its sibling `partials/home-predeparture.html.twig` is the home-only "Coming soon" state — do **not** fold the pre-departure branch back into it.
|
||||||
|
|
||||||
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.
|
## Dual-repo submodule structure
|
||||||
|
|
||||||
The map **markup + invocation** is shared via one partial:
|
`user/` is a git submodule with its own Gitea remote and its own cadence; the outer repo pins an exact commit. Full workflow, worktree mechanics, teardown: [`docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`](docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md).
|
||||||
|
|
||||||
- **Partial:** `user/themes/intotheeast/templates/partials/entry-map.html.twig`
|
- **`M user` / `m user` is normal, not an error.** `M` = the pin differs from `user/` HEAD; `m` = the submodule working tree is dirty (e.g. a local-testing `site.yaml`). Do not "fix" either by committing the gitlink or that `site.yaml`.
|
||||||
- **Used by:** `trip.html.twig` and the active branch of `home.html.twig` (both via `{% include ... with {...} only %}`)
|
- **Don't bump the pin for routine content changes.** Bump it once at the end of a cross-repo feature, to a commit reachable from `user/`'s published `main`, and **push `user/` before the outer repo**.
|
||||||
|
- **Use `make worktree-new NAME=<x>` / `make worktree-rm NAME=<x>`** — never a hand-rolled `git worktree add`. The targets initialise the submodule and an isolated dev server; skipping the deinit on teardown is what leaves orphaned `.worktrees/` dirs.
|
||||||
|
|
||||||
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.
|
## Testing
|
||||||
|
|
||||||
**Parameters:**
|
`make test` runs everything (`test-config` → `test-post` → `test-ui`). **The dev server must be running** — every suite drives the live site over HTTP. Layout, helpers, and per-suite commands: [`docs/reference/testing.md`](docs/reference/testing.md).
|
||||||
|
|
||||||
| Parameter | Type | Trip passes | Home passes |
|
- **Auth is a dependency project.** `auth.setup.js` writes `tests/.auth/user.json`, which the `chromium` project reuses as `storageState`. Never add per-test logins.
|
||||||
|---|---|---|---|
|
- The `testrunner` admin account is created automatically and is git-ignored — never commit it, and keep its password free of shell/Make/URL-special characters, since several consumers interpolate it.
|
||||||
| `map_id` | string | `'trip-map'` | `'home-map'` |
|
- `retries: 0`, so a failing test is a real failure, not flake.
|
||||||
| `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.
|
## Working docs
|
||||||
|
|
||||||
> 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`.
|
Specs go in `docs/working/specs/YYYY-MM-DD-<topic>-design.md`, plans in `docs/working/plans/YYYY-MM-DD-<topic>.md`. These paths override the `docs/superpowers/` default used by the brainstorming and writing-plans skills.
|
||||||
|
|
||||||
### Shared trip-feed-col partial
|
Every plan needs a `**Status:**` line immediately after its title heading: `📋 Not started` · `🔄 In progress — <note>` · `⏸️ Deferred — <reason>` · `✅ Complete (YYYY-MM-DD)` · `❌ Abandoned — <reason>`.
|
||||||
|
|
||||||
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.
|
- **When asked what's open:** surface `Not started` and `In progress`; show `Deferred` but label it clearly; omit `Complete` and `Abandoned` unless explicitly asked.
|
||||||
|
- **When finishing a plan:** set its status to `✅ Complete (YYYY-MM-DD)` before closing the session — whether you executed it directly or via the executing-plans / subagent-driven-development skills.
|
||||||
- **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 (`onFormValidationProcessed` → `setData('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`](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 deinit` → `worktree remove` → `prune`) — 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.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.
|
|
||||||
|
|
||||||
## 3. Testing
|
|
||||||
|
|
||||||
**The dev server must be running** (`make start`) — every suite drives the live site over HTTP.
|
|
||||||
|
|
||||||
| Command | Scope |
|
|
||||||
|---|---|
|
|
||||||
| `make test` | Everything: `test-config` → `test-post` → `test-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).
|
|
||||||
|
|||||||
@@ -54,9 +54,15 @@ $(foreach t,$(REMOTE_TARGETS),$(foreach e,$(ENVS),$(eval $(call make-env-target,
|
|||||||
GRAV_TEST_USER ?= testrunner
|
GRAV_TEST_USER ?= testrunner
|
||||||
GRAV_TEST_PASS ?= Testpass1234
|
GRAV_TEST_PASS ?= Testpass1234
|
||||||
|
|
||||||
|
# The password is handed to the container through `docker exec -e` (the bare
|
||||||
|
# form, which forwards the already-exported variable) rather than interpolated
|
||||||
|
# into the `sh -c` string. Interpolating it meant any shell-special character in
|
||||||
|
# GRAV_TEST_PASS was re-parsed by the container's shell — a `.env` password
|
||||||
|
# containing one produced `sh: 2: <fragment>: not found` and no test account.
|
||||||
|
# The recipe is now indifferent to the password's contents.
|
||||||
test-account:
|
test-account:
|
||||||
@docker exec $(GRAV_CONTAINER) sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|
@docker exec -e GRAV_TEST_PASS $(GRAV_CONTAINER) sh -c 'test -f /var/www/html/user/accounts/$(GRAV_TEST_USER).yaml \
|
||||||
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$(GRAV_TEST_PASS)" \
|
|| php bin/plugin login new-user -u $(GRAV_TEST_USER) -p "$$GRAV_TEST_PASS" \
|
||||||
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
|
-e $(GRAV_TEST_USER)@example.test -N "Test Runner" -P b --admin-type both -s enabled -n'
|
||||||
|
|
||||||
test-config:
|
test-config:
|
||||||
@@ -65,6 +71,13 @@ test-config:
|
|||||||
test-post: test-account
|
test-post: test-account
|
||||||
@bash scripts/test-post.sh
|
@bash scripts/test-post.sh
|
||||||
|
|
||||||
|
# Pinned to THIS checkout's port, not playwright.config.js's :8081 default. In a
|
||||||
|
# worktree that default silently pointed the suite at the main checkout's server,
|
||||||
|
# so entries were created in main's user/ while the specs asserted and cleaned up
|
||||||
|
# in the worktree's — leaving ui-test entries behind in real trip content.
|
||||||
|
# tests/global-setup.js now also hard-fails on that mismatch.
|
||||||
|
GRAV_BASE_URL ?= http://localhost:$(GRAV_PORT)
|
||||||
|
|
||||||
test-ui: test-account
|
test-ui: test-account
|
||||||
@npx playwright test
|
@npx playwright test
|
||||||
|
|
||||||
@@ -98,8 +111,18 @@ build-assets:
|
|||||||
-w /app node:20-alpine \
|
-w /app node:20-alpine \
|
||||||
sh -c "npm install && npm run build"
|
sh -c "npm install && npm run build"
|
||||||
|
|
||||||
|
# In a worktree this degrades to start-grav. The travel-memories service declares
|
||||||
|
# `env_file: .env`, and worktree-new does not create a .env, so a plain
|
||||||
|
# `docker compose up -d` there dies with "env file ... not found" — leaving the
|
||||||
|
# worktree with no server at all, which is how test runs ended up silently
|
||||||
|
# targeting the main checkout.
|
||||||
start:
|
start:
|
||||||
docker compose up -d
|
@if [ -f .worktree-env ]; then \
|
||||||
|
echo "→ worktree: starting the grav service only (travel-memories needs a .env, which worktrees have none)"; \
|
||||||
|
docker compose up -d grav; \
|
||||||
|
else \
|
||||||
|
docker compose up -d; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Grav service only — used by `make worktree-new` (a worktree rarely needs the
|
# Grav service only — used by `make worktree-new` (a worktree rarely needs the
|
||||||
# travel-memories service, and this keeps its footprint minimal).
|
# travel-memories service, and this keeps its footprint minimal).
|
||||||
@@ -177,6 +200,12 @@ worktree-rm: guard-name
|
|||||||
-git -C "$(WT_DIR)" submodule deinit -f user
|
-git -C "$(WT_DIR)" submodule deinit -f user
|
||||||
git worktree remove --force "$(WT_DIR)"
|
git worktree remove --force "$(WT_DIR)"
|
||||||
git worktree prune
|
git worktree prune
|
||||||
|
# The deinit above is required (a populated user/ blocks `worktree remove`),
|
||||||
|
# but worktrees SHARE .git/config — so it also strips submodule.user.url for
|
||||||
|
# the MAIN checkout, leaving `git submodule status` there showing `-` (not
|
||||||
|
# initialised) even though user/ is intact. Re-register it; init is
|
||||||
|
# idempotent and touches config only, never the working tree.
|
||||||
|
git submodule init
|
||||||
@echo "Removed $(WT_DIR). If feat/$(NAME) is merged, drop it: git branch -d feat/$(NAME)"
|
@echo "Removed $(WT_DIR). If feat/$(NAME) is merged, drop it: git branch -d feat/$(NAME)"
|
||||||
|
|
||||||
# ── Demo content ──────────────────────────────────────────────────────────────
|
# ── Demo content ──────────────────────────────────────────────────────────────
|
||||||
@@ -185,6 +214,13 @@ demo-load:
|
|||||||
# Load every fixture trip under docs/demo/trips/ into the pages tree.
|
# Load every fixture trip under docs/demo/trips/ into the pages tree.
|
||||||
# Source uses dailies/ + 04.stories/; dailies/ maps to 01.dailies/ on copy.
|
# Source uses dailies/ + 04.stories/; dailies/ maps to 01.dailies/ on copy.
|
||||||
# All copies are `|| true` so a fixture absent from an older user/ is skipped.
|
# All copies are `|| true` so a fixture absent from an older user/ is skipped.
|
||||||
|
#
|
||||||
|
# ⚠️ A fixture whose folder name matches a REAL trip's slug is copied straight
|
||||||
|
# over that live page — docs/demo/trips/italy-2025/ collides with the real
|
||||||
|
# italy-2025 trip on purpose (the fixture supplies its GPX + dailies). So any
|
||||||
|
# field the fixture's trip.md omits gets silently deleted from real content on
|
||||||
|
# every test run: it had been dropping the trip's tagline that way. Keep a
|
||||||
|
# colliding fixture's trip.md byte-identical to the live page.
|
||||||
docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
|
docker exec $(GRAV_CONTAINER) bash -c 'for src in /var/www/html/user/docs/demo/trips/*/; do \
|
||||||
slug=$$(basename "$$src"); dst=/var/www/html/user/pages/01.trips/$$slug; \
|
slug=$$(basename "$$src"); dst=/var/www/html/user/pages/01.trips/$$slug; \
|
||||||
mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \
|
mkdir -p "$$dst/01.dailies" "$$dst/04.stories"; \
|
||||||
|
|||||||
@@ -10,10 +10,29 @@ Two git repos:
|
|||||||
|
|
||||||
| Repo | Contents | Location |
|
| Repo | Contents | Location |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| `intotheeast.com` (this repo) | Docker setup, Makefile, scripts, plugins.txt | `./` |
|
| `intotheeast.com` (this repo) | Docker setup, Makefile, scripts, tests, docs, plugins.txt | `./` |
|
||||||
| `intotheeast.com-content` | Site config, pages, theme | `user/` (standalone git repo) |
|
| `intotheeast.com-content` | Site config, pages, theme | `user/` (git submodule) |
|
||||||
|
|
||||||
The `user/` directory is a standalone git repo — its changes are pushed/pulled independently to Gitea. The Grav Sync plugin on the server automatically pulls from Gitea when content is pushed.
|
`user/` is tracked by this repo as a **git submodule** — it has its own Gitea remote and its own push/pull cadence (`make content-push` / `make content-pull`), and this repo pins an exact `user/` commit. The Git Sync plugin on the server pulls from Gitea automatically when content is pushed. A persistent `M user` / `m user` in `git status` is normal, not a problem; see [`docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md`](docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md).
|
||||||
|
|
||||||
|
### Folder map
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `user/` | Site content, config, pages, theme (the content submodule) |
|
||||||
|
| `user/themes/intotheeast/js/src/` | JS sources — esbuild inputs; run `make build-assets` after editing. Note `js/maplibre-utils.js` and `js/nav.js` are *also* sources, despite sitting beside the generated bundles |
|
||||||
|
| `deploy/env/` | Per-environment Grav config overrides (e.g. prod Twig settings) |
|
||||||
|
| `deploy/patches/` | Tracked patches for third-party plugins, which are otherwise git-ignored |
|
||||||
|
| `scripts/` | Server install and maintenance scripts |
|
||||||
|
| `tests/` | Playwright suite — see [`docs/reference/testing.md`](docs/reference/testing.md) |
|
||||||
|
| `php/` | Local PHP ini overrides |
|
||||||
|
| `docs/` | All project documentation — start at [`docs/README.md`](docs/README.md) |
|
||||||
|
| `docs/guides/` | Operational how-tos (posting, story authoring, GPX, trip switching, setup, deploy cycle) |
|
||||||
|
| `docs/reference/` | Stable facts: architecture, design system, testing |
|
||||||
|
| `docs/solutions/` | Write-ups of bugs and workflow traps already hit, with YAML frontmatter (`module`, `tags`, `problem_type`) |
|
||||||
|
| `docs/working/` | Specs, plans, backlog, QA — work in flight |
|
||||||
|
| `CONCEPTS.md` | Shared domain vocabulary (Trip, Entry, Story, Active Trip) |
|
||||||
|
| `CLAUDE.md` | Rules and gotchas loaded into every Claude Code session |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,13 +101,42 @@ make content-push # push local user/ commits → Gitea
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
|
| `make setup` | First run: build → start → install plugins → fix perms |
|
||||||
| `make start` | Start the local Docker container |
|
| `make start` | Start the local Docker container |
|
||||||
| `make stop` | Stop the local Docker container |
|
| `make stop` | Stop the local Docker container |
|
||||||
| `make setup` | Start container and install all plugins from plugins.txt |
|
| `make install-plugins` | (Re)install plugins from plugins.txt, then apply local plugin patches |
|
||||||
| `make install-plugins` | (Re)install plugins from plugins.txt in the local container |
|
| `make apply-plugin-patches` | Idempotently re-apply the patches in `deploy/patches/` |
|
||||||
| `make content-push` | Push local `user/` commits to Gitea |
|
| `make fix-perms` | Reset file ownership inside the container |
|
||||||
|
| `make build-assets` | Run esbuild over `user/themes/intotheeast/js/src/` — **required** after editing any JS source |
|
||||||
|
| `make content-push` | Push local `user/` commits to Gitea (triggers the production pull) |
|
||||||
| `make content-pull` | Pull latest `user/` content from Gitea |
|
| `make content-pull` | Pull latest `user/` content from Gitea |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `make test` | Everything: `test-config` → `test-post` → `test-ui` |
|
||||||
|
| `make test-config` | Form/config sanity checks |
|
||||||
|
| `make test-post` | End-to-end post submission |
|
||||||
|
| `make test-ui` | Playwright suite |
|
||||||
|
|
||||||
|
Details and conventions: [`docs/reference/testing.md`](docs/reference/testing.md).
|
||||||
|
|
||||||
|
### Demo content and imports
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `make demo-load` | Copy every fixture trip under `user/docs/demo/trips/` into the pages tree (add a fixture by dropping a folder there — no Makefile edit needed) |
|
||||||
|
| `make demo-reset` | Remove those demo trips from the pages tree and clear cache |
|
||||||
|
| `make pixelfed-import` | Import posts from Pixelfed via `scripts/pixelfed-import.py` |
|
||||||
|
|
||||||
|
### Parallel work
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `make worktree-new NAME=<feature>` | Create a worktree with its own `user/` checkout and an isolated dev server on port `8090+` |
|
||||||
|
| `make worktree-rm NAME=<feature>` | Tear one down cleanly (compose down → submodule deinit → worktree remove → prune) |
|
||||||
|
|
||||||
### Remote credentials
|
### Remote credentials
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./user:/var/www/html/user
|
- ./user:/var/www/html/user
|
||||||
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
|
- ./php/php-local.ini:/usr/local/etc/php/conf.d/php-local.ini
|
||||||
|
# Grav stages form uploads in tmp/forms/<session>/ before the submit moves
|
||||||
|
# them into the page folder. The image declares /var/www/html as a VOLUME,
|
||||||
|
# so without this it lives in an ANONYMOUS volume that is discarded on any
|
||||||
|
# `docker compose up` that recreates the container — dropping the photos of
|
||||||
|
# a post that was filled in but not yet submitted. Naming it gives the
|
||||||
|
# staging area its own lifecycle.
|
||||||
|
- grav_tmp:/var/www/html/tmp
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
travel-memories:
|
travel-memories:
|
||||||
@@ -24,3 +31,6 @@ services:
|
|||||||
- ./user/pages:/app/pages
|
- ./user/pages:/app/pages
|
||||||
env_file: .env
|
env_file: .env
|
||||||
user: "${UID}:${GID}"
|
user: "${UID}:${GID}"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
grav_tmp:
|
||||||
|
|||||||
+3
-2
@@ -8,14 +8,15 @@
|
|||||||
- [Switching to a new trip](guides/trip-switching.md)
|
- [Switching to a new trip](guides/trip-switching.md)
|
||||||
- [Rebuilding local dev from scratch](guides/local-setup.md)
|
- [Rebuilding local dev from scratch](guides/local-setup.md)
|
||||||
|
|
||||||
**Checking project status?** → [`working/`](working/)
|
**Checking project status?** → [`working/`](working/) — [what's in there + the plan status convention](working/README.md)
|
||||||
- [Backlog](working/backlog.md)
|
- [Backlog](working/backlog.md)
|
||||||
- [Production todo](working/production-todo.md)
|
- [Bugs and fixes](working/bugs-and-fixes.md)
|
||||||
- [QA results](working/qa/results.md)
|
- [QA results](working/qa/results.md)
|
||||||
|
|
||||||
**Design or architecture decisions?** → [`reference/`](reference/)
|
**Design or architecture decisions?** → [`reference/`](reference/)
|
||||||
- [Design system](reference/design-system.md)
|
- [Design system](reference/design-system.md)
|
||||||
- [Architecture overview](reference/architecture.md)
|
- [Architecture overview](reference/architecture.md)
|
||||||
|
- [Testing](reference/testing.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,52 @@ servers use. See `docs/solutions/tooling-decisions/upgrade-local-grav-core-rebui
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## The env override tree (`user/env/<host>/`)
|
||||||
|
|
||||||
|
Prod needs different Twig settings than dev. These are **never** committed to
|
||||||
|
`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 they ship as a per-environment override via
|
||||||
|
Grav's `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`.
|
||||||
|
- **Hostname segment** defaults to `REMOTE_HOST`; override with `WEB_HOST` in
|
||||||
|
`.env.<env>` if Grav sees a different host than the SSH host.
|
||||||
|
- **Not restored by anything.** `user/env/` is outside the content repo's tracked
|
||||||
|
folders, so `content-push` / git-sync / `remote-fetch-content` do **not** bring
|
||||||
|
it back. **Re-run `make remote-apply-env-<env>` after any fresh install.**
|
||||||
|
|
||||||
|
### Side effect: Admin writes ALL config into the env tree
|
||||||
|
|
||||||
|
Once `user/env/<hostname>/` exists, Grav's Admin saves **every** config change
|
||||||
|
(system *and* plugin) there — e.g. editing a plugin on prod writes
|
||||||
|
`user/env/intotheeast.com/config/plugins/<name>.yaml`, **not**
|
||||||
|
`user/config/plugins/<name>.yaml`. Consequences:
|
||||||
|
|
||||||
|
- Config edited via **Admin on the server is server-only**: the env tree is not
|
||||||
|
committed and not synced by git-sync (which syncs only `pages`/`config`/
|
||||||
|
`themes`), so prod Admin edits silently never reach Gitea or local. This is
|
||||||
|
*good* for secrets — `git-sync.yaml` (token), the JWT and CSRF salt safely
|
||||||
|
live there — but it means config drift is invisible to the repo.
|
||||||
|
- When reading or 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 holds only per-host overrides + Admin-on-server edits.
|
||||||
|
|
||||||
|
Full details: `docs/working/git-sync-notes.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Phase 0 — Local (author + prove the change)
|
## Phase 0 — Local (author + prove the change)
|
||||||
|
|
||||||
1. Make the change in the repo:
|
1. Make the change in the repo:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ The GPX manager at `/gpx-manager` requires admin login (redirects to login form
|
|||||||
Drop the file directly into the trip folder and push:
|
Drop the file directly into the trip folder and push:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp your-route.gpx /path/to/user/pages/01.trips/japan-korea-2026/
|
cp your-route.gpx /path/to/user/pages/01.trips/denmark-2026/
|
||||||
make content-push
|
make content-push
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -83,3 +83,25 @@ GPX files are registered as a valid media type in `user/config/media.yaml`, so G
|
|||||||
```
|
```
|
||||||
|
|
||||||
No manual linking is needed — upload and it appears.
|
No manual linking is needed — upload and it appears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the manager is wired
|
||||||
|
|
||||||
|
| Piece | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Page | `user/pages/03.gpx-manager/` |
|
||||||
|
| Template | `user/themes/intotheeast/templates/gpx-manager.html.twig` |
|
||||||
|
| Auth | Login plugin, via `access.admin.login: true` in the page frontmatter — renders the login form when unauthenticated |
|
||||||
|
| API | Grav API v1 with **session cookie** auth (`session_enabled: true` in `user/plugins/api/api.yaml`) |
|
||||||
|
|
||||||
|
API calls the page makes:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/pages{route}/media # list
|
||||||
|
POST /api/v1/pages{route}/media # upload (multipart)
|
||||||
|
DELETE /api/v1/pages{route}/media/{filename} # delete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upload gotcha:** the selected file is sliced into a plain `Blob` before `FormData.append`, so the third argument is always honoured as the filename. Appending the original `File` lets the browser keep the unslugified name and the slugification is silently ignored.
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ Two ways to post: the **mobile form** at `/post` (quick, phone-friendly) or the
|
|||||||
```
|
```
|
||||||
Browser → /post (post-form.md)
|
Browser → /post (post-form.md)
|
||||||
└─ Grav Form plugin validates fields
|
└─ Grav Form plugin validates fields
|
||||||
|
└─ cache-on-save injects parent from site.active_trip
|
||||||
└─ add-page-by-form plugin
|
└─ add-page-by-form plugin
|
||||||
├─ reads pageconfig.parent (/trips/<active_trip>/dailies)
|
|
||||||
├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>/entry.md
|
├─ writes user/pages/01.trips/<active_trip>/01.dailies/<slug>/entry.md
|
||||||
└─ moves uploaded photos into the page folder
|
└─ moves uploaded photos into the page folder
|
||||||
└─ cache-on-save plugin
|
└─ cache-on-save plugin
|
||||||
@@ -53,7 +53,7 @@ Example: `2026-07-20-0930-first-day-in-kyoto.entry`
|
|||||||
|
|
||||||
**Entry folder structure:**
|
**Entry folder structure:**
|
||||||
```
|
```
|
||||||
user/pages/01.trips/japan-korea-2026/01.dailies/
|
user/pages/01.trips/denmark-2026/01.dailies/
|
||||||
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
|
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
|
||||||
├─ entry.md ← frontmatter + markdown body
|
├─ entry.md ← frontmatter + markdown body
|
||||||
├─ temple.jpg ← hero image (or set hero_image in frontmatter)
|
├─ temple.jpg ← hero image (or set hero_image in frontmatter)
|
||||||
@@ -103,7 +103,7 @@ Every entry supports these frontmatter fields:
|
|||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
**Entry doesn't appear in feed after submit**
|
**Entry doesn't appear in feed after submit**
|
||||||
→ Check that `active_trip` in `user/config/site.yaml` matches the parent in `user/pages/02.post/post-form.md` (`pageconfig.parent`). If they're out of sync, entries go to the wrong folder. See [trip switching guide](trip-switching.md).
|
→ Check `active_trip` in `user/config/site.yaml` — the write target is derived from it at submit time, so a wrong value sends entries to the wrong trip's dailies. See [trip switching guide](trip-switching.md).
|
||||||
|
|
||||||
**Get Weather button shows an error**
|
**Get Weather button shows an error**
|
||||||
→ Fill in Lat/Lng first (tap Get Location or enter manually). Open-Meteo requires coordinates.
|
→ Fill in Lat/Lng first (tap Get Location or enter manually). Open-Meteo requires coordinates.
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# Writing a Story
|
||||||
|
|
||||||
|
A Story is a long-form, hand-crafted piece with an immersive layout — distinct from an Entry, which is a quick dated post from the road (see [`CONCEPTS.md`](../../CONCEPTS.md)). Stories get a Ken Burns hero, scroll-driven sections, galleries and pull quotes.
|
||||||
|
|
||||||
|
The admin editor gives you a **plain markdown textarea** for the body. There is no block picker — the layout vocabulary is a set of shortcodes you type by hand. This guide is that vocabulary; the story edit form also carries a condensed copy of it on its **Blocks** tab, so you don't need this file open while writing.
|
||||||
|
|
||||||
|
Admin lives at **`/admin`** (the plugin slug is `admin2`, but the route is `/admin`). A story's edit URL looks like `/admin/pages/edit/trips/<trip>/stories/<slug>`.
|
||||||
|
|
||||||
|
Stories live at:
|
||||||
|
|
||||||
|
```
|
||||||
|
user/pages/01.trips/<trip>/04.stories/<slug>/story.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`04.stories/stories.md` is a `routable: false` container — its children are aggregated onto the trip page. There is no standalone `/stories` view; don't create one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Create the page
|
||||||
|
|
||||||
|
1. Admin → **Pages** → add a page under the trip's **Stories** folder
|
||||||
|
2. Set page template to **story** — this loads [`user/themes/intotheeast/blueprints/story.yaml`](../../user/themes/intotheeast/blueprints/story.yaml). You get the story-specific **Content / Blocks / Location / Publishing** tabs plus the inherited **Options / Advanced / Security** tabs
|
||||||
|
3. Fill in Title and Start Date (both required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Upload the images first
|
||||||
|
|
||||||
|
Upload every image the story needs via the **Images** field at the bottom of the Content tab, before writing the body.
|
||||||
|
|
||||||
|
> There is no separate *Media* tab — the uploader is a field on the Content tab, labelled **Images**. It arrives via inheritance: `story.yaml` declares `'@extends': {type: default, context: blueprints://pages}`, which merges in Grav's default page form. `story.yaml` and `trip.yaml` did not originally extend it, so neither form could upload anything, which is why the demo story images had to be placed on the filesystem. Both now inherit, matching [`entry.yaml`](../../user/themes/intotheeast/blueprints/entry.yaml). `home.yaml` is still standalone, deliberately — the home page has no per-page media.
|
||||||
|
|
||||||
|
Every shortcode refers to images by **bare filename** — the `story-blocks` plugin prefixes the page URL at render time ([`story-blocks.php:22`](../../user/plugins/story-blocks/story-blocks.php)), so you write:
|
||||||
|
|
||||||
|
```
|
||||||
|
image="photo-1.jpg" ✅
|
||||||
|
image="/images/photo-1.jpg" ❌ don't path it
|
||||||
|
```
|
||||||
|
|
||||||
|
The demo stories use a `hero.jpg` / `photo-1.jpg` / `photo-2.jpg` naming convention. Worth copying — it keeps the shortcodes readable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Frontmatter fields
|
||||||
|
|
||||||
|
All of these come from the form tabs, so you rarely type them by hand. Listed here because the body shortcodes are *not* the whole story — the hero in particular is frontmatter, not a tag.
|
||||||
|
|
||||||
|
| Field | Tab | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | Content | Required |
|
||||||
|
| `date` | Content | Required. Start date |
|
||||||
|
| `end_date` | Content | Optional — leave blank for a single-day story |
|
||||||
|
| `hero_image` | Content | **The hero. Filename only**, from the Images field. Missing → grey placeholder, story still renders |
|
||||||
|
| `hero_alt` | Content | Falls back to the title if empty |
|
||||||
|
| `location_name`, `location_country` | Location | Shown in the hero meta line and the opener |
|
||||||
|
| `lat`, `lng` | Location | Decimal degrees — places the story marker on the trip map |
|
||||||
|
| `transport_mode` | Location | walking / bicycle / bus / train / car |
|
||||||
|
| `force_connect` | Location | Always draw a connector line from the previous marker |
|
||||||
|
| `published` | Options | Grav's standard toggle, from the inherited form |
|
||||||
|
| `featured` | Publishing | Show as a homepage highlight when not travelling |
|
||||||
|
|
||||||
|
The Ken Burns pan on the hero is automatic — no parameter for it.
|
||||||
|
|
||||||
|
If you hand-write frontmatter, use `date: '2026-09-03'`. Admin2 saves its own serialization (`29-07-2026 19:51`); both parse fine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The body: six shortcodes
|
||||||
|
|
||||||
|
Defined in [`user/plugins/story-blocks/shortcodes/`](../../user/plugins/story-blocks/shortcodes/). Plain prose between them renders as a normal reading column — you don't need a shortcode to write paragraphs.
|
||||||
|
|
||||||
|
### Wrapping tags
|
||||||
|
|
||||||
|
**`scrolly-section`** — text panels scroll over a pinned, slowly panning image. The centrepiece block.
|
||||||
|
|
||||||
|
```
|
||||||
|
[scrolly-section image="hero.jpg" alt="Description of the image" caption="Optional caption"]
|
||||||
|
The first panel. Scrolls into view over the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The second panel. A markdown `---` starts a new panel.
|
||||||
|
[/scrolly-section]
|
||||||
|
```
|
||||||
|
|
||||||
|
Panels are split on the `<hr>` that `---` produces ([`story.html.twig:234`](../../user/themes/intotheeast/templates/story.html.twig)). `caption` is optional. Under `prefers-reduced-motion` all panels render active with no pinning.
|
||||||
|
|
||||||
|
**`pull-quote`** — large extracted quote, optionally over a background image.
|
||||||
|
|
||||||
|
```
|
||||||
|
[pull-quote image="photo-1.jpg" alt="Description of the image"]
|
||||||
|
The quote itself. Markdown works in here.
|
||||||
|
[/pull-quote]
|
||||||
|
```
|
||||||
|
|
||||||
|
Drop `image`/`alt` entirely for the plain no-image variant.
|
||||||
|
|
||||||
|
### Self-closing tags
|
||||||
|
|
||||||
|
Note the ` /]` — these take no content.
|
||||||
|
|
||||||
|
**`chapter-break`** — full-width section transition over a background image.
|
||||||
|
|
||||||
|
```
|
||||||
|
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Description" /]
|
||||||
|
```
|
||||||
|
|
||||||
|
`number` is optional; the demo stories use roman numerals.
|
||||||
|
|
||||||
|
**`snap-gallery`** — swipeable multi-image carousel with dots.
|
||||||
|
|
||||||
|
```
|
||||||
|
[snap-gallery images="hero.jpg,photo-1.jpg" captions="First caption,Second caption" alts="First alt,Second alt" /]
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ See the comma gotcha below.
|
||||||
|
|
||||||
|
**`full-bleed`** — single image edge-to-edge, as a visual pause.
|
||||||
|
|
||||||
|
```
|
||||||
|
[full-bleed image="photo-2.jpg" alt="Description" caption="Optional" credit="Optional" /]
|
||||||
|
```
|
||||||
|
|
||||||
|
**`image-caption`** — photo at a chosen width with caption beneath.
|
||||||
|
|
||||||
|
```
|
||||||
|
[image-caption image="photo-2.jpg" alt="Description" caption="Optional" credit="Optional" width="column" /]
|
||||||
|
```
|
||||||
|
|
||||||
|
`width` accepts `column` (default), `full`, `bleed`. Anything else falls back to `column`.
|
||||||
|
|
||||||
|
`full-bleed` and `image-caption` are implemented but not yet used by any story — the demos only exercise the other four.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
### snap-gallery splits on commas — captions cannot contain them
|
||||||
|
|
||||||
|
`images`, `captions` and `alts` are each split on `,` and zipped by index. **A comma inside a caption shifts every caption after it.**
|
||||||
|
|
||||||
|
This is already live in the demo content. `04.stories/04.florence-without-a-map/story.md` has:
|
||||||
|
|
||||||
|
```
|
||||||
|
captions="The Arno at noon — greener than expected, the bridges older than you remember,Via dei Servi: …"
|
||||||
|
```
|
||||||
|
|
||||||
|
Two images, but three comma-separated pieces — so slide 1 gets "The Arno at noon — greener than expected", slide 2 gets " the bridges older than you remember", and the Via dei Servi text is silently dropped.
|
||||||
|
|
||||||
|
Use em dashes or semicolons in gallery captions. There is no escaping mechanism.
|
||||||
|
|
||||||
|
### Self-closing tags need the space before `/]`
|
||||||
|
|
||||||
|
`[chapter-break … /]` — not `[chapter-break …/]` or `[chapter-break …]`.
|
||||||
|
|
||||||
|
### A typo'd shortcode fails silently
|
||||||
|
|
||||||
|
An unrecognised tag name or a malformed parameter list renders as literal text or vanishes — no error, no warning. Preview the page after every block; there is no in-editor validation.
|
||||||
|
|
||||||
|
### `---` outside a scrolly-section is just a horizontal rule
|
||||||
|
|
||||||
|
The panel-splitting behaviour only applies *inside* `[scrolly-section]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worked example
|
||||||
|
|
||||||
|
The best reference to copy from is [`user/pages/01.trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md`](../../user/pages/01.trips/italy-2026-demo/04.stories/01.sorano-rock-and-time/story.md) — it combines `scrolly-section`, `chapter-break` and `pull-quote` in one story.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 'Sorano: Rock and Time'
|
||||||
|
date: '2026-09-03'
|
||||||
|
location_name: Sorano
|
||||||
|
location_country: Italy
|
||||||
|
lat: 42.683
|
||||||
|
lng: 11.715
|
||||||
|
hero_image: hero.jpg
|
||||||
|
hero_alt: Medieval town of Sorano clinging to pale tufa cliffs at dusk
|
||||||
|
published: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Opening prose. Renders as a normal reading column.
|
||||||
|
|
||||||
|
[scrolly-section image="hero.jpg" alt="Sorano seen from the approach road" caption="Sorano — tufa cliff town"]
|
||||||
|
First panel over the pinned image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Second panel.
|
||||||
|
[/scrolly-section]
|
||||||
|
|
||||||
|
More prose between blocks.
|
||||||
|
|
||||||
|
[chapter-break image="photo-1.jpg" title="After Dark" number="II" alt="Narrow medieval alley at dusk" /]
|
||||||
|
|
||||||
|
[pull-quote image="photo-1.jpg" alt="Stone alley lit by a single lantern"]
|
||||||
|
A town built on rock, carved from rock, returning slowly to rock.
|
||||||
|
[/pull-quote]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Publish
|
||||||
|
|
||||||
|
1. Set **Published** on the Publishing tab
|
||||||
|
2. Optionally set **Featured highlight** to surface it on the homepage between trips
|
||||||
|
3. Push the content:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make content-push
|
||||||
|
```
|
||||||
|
|
||||||
|
That commits and pushes `user/` to Gitea, which triggers the production pull. See [`deploy-cycle.md`](deploy-cycle.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Blocks tab
|
||||||
|
|
||||||
|
The story edit form has a **Blocks** tab holding a paste-ready example of each shortcode plus the comma warning. It's built from `type: spacer` fields in `story.yaml`, whose `text` admin2 renders as HTML (confirmed against admin2 2.0.12 — see its CHANGELOG entry for issue #91).
|
||||||
|
|
||||||
|
If you edit those fields, note two things: the `text` values are YAML double-quoted scalars, so HTML attribute quotes must be escaped (`\"`) — and `<h4>` is flattened by admin2's CSS reset, which is why the headings use `<strong>` in a styled `<p>` instead. Long `<pre>` content needs `white-space:pre-wrap`, or it overflows underneath the Page Info sidebar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why it's a raw textarea
|
||||||
|
|
||||||
|
The admin2 story editor is a plain markdown field by design-so-far, not by limitation. Background and the options considered: [`docs/research/story-editing.md`](../research/story-editing.md). Note that its conclusion that admin2 cannot host a custom editor is **out of date** — admin-next ships a plugin field-component surface (`admin-next/fields/{type}.js` + `onApiBlueprintResolved`) that the installed api 1.0.9 / admin2 2.0.12 support. Nobody has built it yet.
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
# Switching to a New Trip
|
# Switching to a New Trip
|
||||||
|
|
||||||
When you start a new trip, **two files must be updated together** — if only one is changed, new entries will be posted to the wrong folder silently (no error, wrong trip).
|
The active trip lives in **one** place: `user/config/site.yaml` → `active_trip`. Set it, create the new page tree, push.
|
||||||
|
|
||||||
|
> **Changed 2026-07:** this used to require editing two files in lockstep (`site.yaml` **and** `post-form.md` → `pageconfig.parent`), and they silently desynced. The `cache-on-save` plugin now derives the write target from `site.active_trip` at submit time (`onFormValidationProcessed` → `setData('parent', …)`), so `post-form.md` no longer carries a `parent` at all. **Do not re-add one** — it would override the derived target and reintroduce the desync.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Update `user/config/site.yaml` → `active_trip`
|
- [ ] Update `user/config/site.yaml` → `active_trip`
|
||||||
- [ ] Update `user/pages/02.post/post-form.md` → `pageconfig.parent`
|
|
||||||
- [ ] Create the new trip page tree (see below)
|
- [ ] Create the new trip page tree (see below)
|
||||||
- [ ] Run `make content-push` to push the changes to production
|
- [ ] Run `make content-push` to push the changes to production
|
||||||
|
|
||||||
@@ -15,38 +16,28 @@ When you start a new trip, **two files must be updated together** — if only on
|
|||||||
|
|
||||||
## Step 1 — Update site.yaml
|
## Step 1 — Update site.yaml
|
||||||
|
|
||||||
In `user/config/site.yaml`, set `active_trip` to the new trip slug:
|
In `user/config/site.yaml`, set `active_trip` to the new trip's **route**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
active_trip: japan-korea-2026 # ← change this
|
active_trip: /trips/denmark-2026 # ← change this
|
||||||
```
|
```
|
||||||
|
|
||||||
The slug must exactly match the folder name under `user/pages/01.trips/`.
|
The final segment must exactly match the folder name under `user/pages/01.trips/`.
|
||||||
|
|
||||||
|
You can also set this from Admin → Configuration → Site → **Active Trip** (a page-picker rooted at `/trips`; blueprint at `user/blueprints/config/site.yaml`).
|
||||||
|
|
||||||
|
> `system.yaml` → `home.alias` is permanently `/home` (the real home page) and does **not** change when switching trips.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2 — Update post-form.md
|
## Step 2 — Create the new trip page tree
|
||||||
|
|
||||||
In `user/pages/02.post/post-form.md`, set `pageconfig.parent` to the new dailies path:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
pageconfig:
|
|
||||||
parent: /trips/japan-korea-2026/dailies # ← change this
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why both?** Grav's config and page frontmatter are static YAML — no variable substitution is possible, so `post-form.md` can't read from `site.yaml` automatically. They must match manually.
|
|
||||||
|
|
||||||
**What breaks if they're out of sync:** `active_trip` controls which trip page is featured on the home page and trip page. `pageconfig.parent` controls where new entries land. If they differ, new posts go to the old trip's dailies folder while the home page shows the new trip — entries appear to vanish.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3 — Create the new trip page tree
|
|
||||||
|
|
||||||
Create the two content subfolders under `user/pages/01.trips/<new-slug>/`:
|
Create the two content subfolders under `user/pages/01.trips/<new-slug>/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
user/pages/01.trips/japan-korea-2026/
|
user/pages/01.trips/denmark-2026/
|
||||||
├─ trip.md ← title, date_start, date_end, cover_image, album_url
|
├─ trip.md ← title, date_start, date_end, cover_image, album_url
|
||||||
|
├─ *.gpx ← route files (optional; page media, auto-detected)
|
||||||
├─ 01.dailies/
|
├─ 01.dailies/
|
||||||
│ └─ dailies.md ← inert container: template: default, routable: false, visible: false
|
│ └─ dailies.md ← inert container: template: default, routable: false, visible: false
|
||||||
└─ 04.stories/
|
└─ 04.stories/
|
||||||
@@ -55,13 +46,13 @@ user/pages/01.trips/japan-korea-2026/
|
|||||||
|
|
||||||
Copy these files from an existing trip and update the frontmatter (especially `title` and `date_start` in `trip.md`).
|
Copy these files from an existing trip and update the frontmatter (especially `title` and `date_start` in `trip.md`).
|
||||||
|
|
||||||
> The `02.map/` and `03.stats/` standalone views were retired (2026-07-04) — the map and stats render inline on the trip page. The `01.dailies/` and `04.stories/` folders now exist only as data containers holding the entry/story children; their own routes are non-routable.
|
> The `02.map/` and `03.stats/` standalone views were retired (2026-07-04) — the map and stats render inline on the trip page. The `01.dailies/` and `04.stories/` folders now exist only as data containers holding the entry/story children; their own routes are non-routable. Do **not** recreate `02.map/` or `03.stats/`.
|
||||||
|
|
||||||
Fields in `trip.md` to update:
|
Fields in `trip.md` to update:
|
||||||
|
|
||||||
| Field | Example | Notes |
|
| Field | Example | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `title` | `Japan & Korea 2026` | Displayed in nav and trip header |
|
| `title` | `Denmark 2026` | Displayed in nav and trip header |
|
||||||
| `date_start` | `2026-07-15` | Used for "X days on the road" stat |
|
| `date_start` | `2026-07-15` | Used for "X days on the road" stat |
|
||||||
| `date_end` | *(leave blank while travelling)* | Set when you return |
|
| `date_end` | *(leave blank while travelling)* | Set when you return |
|
||||||
| `cover_image` | `cover.jpg` | Shown on the trips listing page |
|
| `cover_image` | `cover.jpg` | Shown on the trips listing page |
|
||||||
@@ -69,7 +60,7 @@ Fields in `trip.md` to update:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 4 — Push
|
## Step 3 — Push
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make content-push
|
make content-push
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ How the intotheeast site hangs together.
|
|||||||
|
|
||||||
| Layer | Technology | Notes |
|
| Layer | Technology | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| CMS | Grav 2.0.4 stable | Flat-file PHP CMS; no database. Server upgrades in place via `bin/gpm self-upgrade` |
|
| 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.10 | Plugin slug: `admin2` (not `admin`) |
|
| 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 |
|
| 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 |
|
| 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` |
|
| PHP session | `session.save_path = /tmp` | Set in `php/php-local.ini` |
|
||||||
@@ -29,15 +29,19 @@ Browser POST /post
|
|||||||
├─ Grav Form plugin (built-in)
|
├─ Grav Form plugin (built-in)
|
||||||
│ └─ validates required fields; handles file uploads
|
│ └─ validates required fields; handles file uploads
|
||||||
│
|
│
|
||||||
├─ add-page-by-form (third-party, patched)
|
├─ 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:
|
│ └─ reads post-form.md config:
|
||||||
│ ├─ pageconfig.parent → target folder (e.g. /trips/japan-korea-2026/dailies)
|
|
||||||
│ ├─ pageconfig.slug_field → slug from date + title
|
│ ├─ pageconfig.slug_field → slug from date + title
|
||||||
│ └─ pagefrontmatter → template: entry, published: true
|
│ └─ pagefrontmatter → template: entry
|
||||||
│ └─ writes entry.md to user/pages/01.trips/<trip>/01.dailies/<slug>.entry/
|
│ └─ writes entry.md to user/pages/01.trips/<trip>/01.dailies/<slug>.entry/
|
||||||
│ └─ moves uploaded photos into the page folder
|
│ └─ moves uploaded photos into the page folder
|
||||||
│
|
│
|
||||||
└─ cache-on-save (custom, user/plugins/cache-on-save/)
|
└─ cache-on-save (again, post-write)
|
||||||
└─ calls $grav['cache']->deleteAll() on every new-entry form submission
|
└─ calls $grav['cache']->deleteAll() on every new-entry form submission
|
||||||
└─ ensures entries appear in feed immediately in both dev and prod mode
|
└─ ensures entries appear in feed immediately in both dev and prod mode
|
||||||
```
|
```
|
||||||
@@ -49,17 +53,36 @@ Other notable plugins:
|
|||||||
| `login` | Auth for /post and /gpx-manager |
|
| `login` | Auth for /post and /gpx-manager |
|
||||||
| `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files |
|
| `api` (Grav API v1) | Used by /gpx-manager to list/upload/delete GPX files |
|
||||||
| `admin2` | Admin panel at /admin |
|
| `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
|
### Plugin management model
|
||||||
|
|
||||||
Three categories, by how each plugin is installed and maintained:
|
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).
|
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`. Versioned in the user repo.
|
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.
|
3. **Remote-only**: `git-sync` — installed and configured only on servers, **never** in `plugins.txt`, and disabled during upgrades.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Asset pipeline
|
||||||
|
|
||||||
|
`make build-assets` runs the theme's `npm run build` (esbuild) in a throwaway `node:20-alpine` container, as the host uid so outputs land in the tracked theme tree owned by you rather than root.
|
||||||
|
|
||||||
|
| Source | → Output |
|
||||||
|
|---|---|
|
||||||
|
| `js/src/main.js` | `js/main.js` + `css-compiled/main.css` + `fonts/` (font files via the `woff2` loader) |
|
||||||
|
| `js/src/map.js` | `js/map.js` + `css-compiled/map.css` — bundles `maplibre-gl`, `@mapbox/togeojson`, and `js/maplibre-utils.js` |
|
||||||
|
| `js/src/feed-actions.js` | `js/feed-actions.js` |
|
||||||
|
| `js/src/trip-publish.js` | `js/trip-publish.js` |
|
||||||
|
| `js/src/post-form.js` | `js/post/` (ESM + code splitting) + `css-compiled/post-form.css` |
|
||||||
|
| `scripts/gen-weather-icons.js` | `templates/partials/weather-icons.html.twig` (Lucide SVGs inlined into a Twig map) |
|
||||||
|
|
||||||
|
**The trap:** `js/` holds both bundles *and* hand-authored sources. `js/maplibre-utils.js` (the `MapUtils` map engine, a plain IIFE imported by `js/src/map.js`) and `js/nav.js` are sources despite sitting beside the minified bundles. `css/style.css` and `css/tokens.css` are hand-authored too — only `css-compiled/` is generated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Template hierarchy
|
## Template hierarchy
|
||||||
|
|
||||||
All page templates extend `base.html.twig`:
|
All page templates extend `base.html.twig`:
|
||||||
@@ -77,17 +100,64 @@ templates/
|
|||||||
|
|
||||||
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.
|
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), and `weather-icons.html.twig`.
|
Site nav (in `base.html.twig`) is deliberately minimal — **Home + Past Trips only**. It does not link to trip sub-sections, because those standalone views no longer exist.
|
||||||
|
|
||||||
|
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
|
## Trip entity structure
|
||||||
|
|
||||||
The site is organized around Trip entities. The active trip is set in `user/config/site.yaml` → `active_trip`.
|
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/
|
user/pages/01.trips/
|
||||||
└─ japan-korea-2026/
|
└─ denmark-2026/
|
||||||
├─ trip.md ← template: trip; title, date_start, cover_image, album_url
|
├─ 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)
|
├─ *.gpx ← GPX route files (served as page media; auto-detected by trip.html.twig)
|
||||||
├─ 01.dailies/ ← journal entry children (container .md is routable:false)
|
├─ 01.dailies/ ← journal entry children (container .md is routable:false)
|
||||||
@@ -126,18 +196,20 @@ Rendered as route polyline on map
|
|||||||
```
|
```
|
||||||
1. User fills /post form and taps Submit
|
1. User fills /post form and taps Submit
|
||||||
2. Grav Form plugin validates: title and content required
|
2. Grav Form plugin validates: title and content required
|
||||||
3. add-page-by-form reads post-form.md:
|
3. cache-on-save (onFormValidationProcessed) injects the write target:
|
||||||
pageconfig.parent: /trips/japan-korea-2026/dailies
|
parent ← derived from site.active_trip (e.g. /trips/denmark-2026/dailies)
|
||||||
pageconfig.slug: {date}-{title|slugify}
|
overwrite_mode ← edit if edit_path filled, else false
|
||||||
pagefrontmatter: template: entry, published: true
|
4. add-page-by-form reads post-form.md:
|
||||||
4. New page written to:
|
pageconfig.slug_field: date,title
|
||||||
user/pages/01.trips/japan-korea-2026/01.dailies/
|
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/
|
└─ 2026-07-20-0930-first-day-in-kyoto.entry/
|
||||||
└─ entry.md
|
└─ entry.md
|
||||||
5. Photos moved into the same folder
|
6. Photos moved into the same folder
|
||||||
6. cache-on-save calls $grav['cache']->deleteAll()
|
7. cache-on-save calls $grav['cache']->deleteAll()
|
||||||
7. Browser: form shows success message
|
8. Browser: form shows success message
|
||||||
8. Feed at /trips/japan-korea-2026 immediately shows new entry
|
9. Feed at /trips/denmark-2026 immediately shows new entry
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -146,9 +218,13 @@ Rendered as route polyline on map
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `user/config/site.yaml` | `active_trip` slug; site title/description |
|
| `user/config/site.yaml` | `active_trip` route; site title/description |
|
||||||
| `user/config/system.yaml` | Twig cache, flex accounts/pages, language prefix |
|
| `user/config/system.yaml` | Twig cache, flex accounts/pages, language prefix |
|
||||||
| `user/config/media.yaml` | Registers `.gpx` as a valid media type |
|
| `user/config/media.yaml` | Registers `.gpx` as a valid media type |
|
||||||
| `user/plugins/api/api.yaml` | `session_enabled: true` for GPX manager auth |
|
| `user/plugins/api/api.yaml` | `session_enabled: true` for GPX manager auth |
|
||||||
| `user/themes/intotheeast/css/tokens.css` | Design tokens (colors, fonts, spacing) |
|
| `user/themes/intotheeast/css/tokens.css` | Design tokens (colors, fonts, spacing) |
|
||||||
| `CLAUDE.md` | Project rules and always-loaded context for Claude |
|
| `CLAUDE.md` | Project rules and always-loaded context for Claude |
|
||||||
|
|
||||||
|
### What the `user/` repo tracks
|
||||||
|
|
||||||
|
Only `pages/`, `config/`, `accounts/`, and `themes/` are versioned in the content repo. `plugins/` and `data/` are ignored — **except** the three custom plugins, un-ignored explicitly in `user/.gitignore`. Also ignored: the test accounts, the demo-trip pages, secrets (`config/plugins/git-sync.yaml`, `config/security.yaml`, `api-private.php`), and the whole `env/` override tree. Read `user/.gitignore` for the authoritative list.
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
Every suite drives the **live site over HTTP**, so the dev server must be running (`make start`) before any of them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Scope |
|
||||||
|
|---|---|
|
||||||
|
| `make test` | Everything: `test-config` → `test-post` → `test-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`) |
|
||||||
|
| `make test-account` | Creates the `testrunner` admin if absent (a dependency of `test-post` and `test-ui`) |
|
||||||
|
|
||||||
|
Focused runs bypass `make`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/ui/maps # one suite
|
||||||
|
npx playwright test tests/ui/maps --headed # watch it
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
playwright.config.js ← config (testDir: ./tests/ui)
|
||||||
|
tests/
|
||||||
|
├─ global-setup.js ← runs once before all projects
|
||||||
|
├─ global-teardown.js ← runs once after
|
||||||
|
├─ fixtures/
|
||||||
|
└─ ui/
|
||||||
|
├─ helpers.js ← shared helpers; import from here rather than re-rolling
|
||||||
|
├─ auth/ ← includes auth.setup.js (see below)
|
||||||
|
├─ a11y/ dailies/ gpx/ home/
|
||||||
|
├─ maps/ nav/ post/ stories/ trip/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config facts
|
||||||
|
|
||||||
|
| Setting | Value | Why it matters |
|
||||||
|
|---|---|---|
|
||||||
|
| `baseURL` | `process.env.GRAV_BASE_URL \|\| 'http://localhost:8081'` | Set `GRAV_BASE_URL` to test a worktree's isolated server on `8090+` |
|
||||||
|
| `retries` | `0` | A failing test is a real failure, not flake — do not paper over it with retries |
|
||||||
|
| `timeout` | `30_000` | Per test |
|
||||||
|
| `screenshot` | `only-on-failure` | Video off; artifacts stay small |
|
||||||
|
| `reporter` | `line` | |
|
||||||
|
|
||||||
|
### Auth is a dependency project
|
||||||
|
|
||||||
|
Two Playwright projects, in order:
|
||||||
|
|
||||||
|
1. **`setup`** — matches `auth.setup.js`, logs in once, writes `tests/.auth/user.json`.
|
||||||
|
2. **`chromium`** — `dependencies: ['setup']`, consumes that file as `storageState`.
|
||||||
|
|
||||||
|
So every test in `chromium` starts already authenticated. **Never add a per-test login** — it duplicates the setup project and slows the suite.
|
||||||
|
|
||||||
|
### The test account
|
||||||
|
|
||||||
|
`make test-account` creates a `testrunner` admin (via `bin/plugin login new-user`, admin type `both`) inside the container if `user/accounts/testrunner.yaml` is missing. It is git-ignored.
|
||||||
|
|
||||||
|
- Never commit it.
|
||||||
|
- Keep the password free of shell/Make/URL-special characters — it is interpolated by the Makefile, `scripts/test-post.sh`, and the Playwright setup, and a special character breaks at least one of them.
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
---
|
||||||
|
title: CLAUDE.md content tiering — rules stay, descriptions move out
|
||||||
|
date: 2026-07-24
|
||||||
|
category: conventions
|
||||||
|
module: documentation
|
||||||
|
problem_type: convention
|
||||||
|
component: documentation
|
||||||
|
severity: medium
|
||||||
|
applies_when:
|
||||||
|
- "Deciding whether new content belongs in CLAUDE.md or a docs/ subfolder"
|
||||||
|
- "CLAUDE.md has grown and needs a reduction pass"
|
||||||
|
- "Writing a rule that references specific file paths, bundle names, or other enumerable facts"
|
||||||
|
- "Extracting descriptive content out of CLAUDE.md into docs/reference or docs/guides"
|
||||||
|
tags: [claude-md, documentation-conventions, context-management, staleness, tiering, agent-instructions]
|
||||||
|
---
|
||||||
|
|
||||||
|
# CLAUDE.md content tiering — rules stay, descriptions move out
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`CLAUDE.md` at the root of this repo is loaded into every single session, before any file is opened. It had grown to 255 lines of mixed content: rules, stack version numbers, plugin role tables, `make` command tables, folder maps, template hierarchies, and descriptions of how the asset pipeline worked. Nobody had ever asked whether a line earned its place in permanent context.
|
||||||
|
|
||||||
|
Four rounds of work over one session took it to 74 lines. The interesting part was not the size reduction — it was what the audits revealed about *which kinds of sentences go stale*, and the fact that the first honest audit made the file **bigger**.
|
||||||
|
|
||||||
|
| Round | Commit | Lines | What happened |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `2fbfc88` | 255 → **305** | Audit scored the file 76/100, fixed 4 stale facts, and *added* genuinely missing sections (testing, dev commands, plugin patches) |
|
||||||
|
| 2 | `ed6e43a` | 305 → **179** | Descriptive content extracted to `docs/` |
|
||||||
|
| 3 | `839a4d0` | 179 → **74** (17,057 → 8,544 chars) | Rules-only cut; created `docs/reference/testing.md`, grew `README.md` |
|
||||||
|
| — | `9ec2349` | +52 | `docs/working/README.md` added; the plan-status *rule* stayed in CLAUDE.md, the *explanation* moved out |
|
||||||
|
| 4 | `285e615` | 74 → **74** | Build-output rule restated as an invariant. 3 lines → 3 lines, 156 chars saved. Not a size change — a staleness fix |
|
||||||
|
|
||||||
|
The four stale facts from round 1, verbatim from `2fbfc88`'s commit body:
|
||||||
|
|
||||||
|
- `active_trip: japan-korea-2026` — the committed value was `/trips/denmark-2026` and **no `japan-korea` trip folder existed**
|
||||||
|
- `Admin2 v2.0.10` — installed version was `v2.0.12`
|
||||||
|
- `make demo-load` described as italy-only — the Makefile loops over every fixture under `user/docs/demo/trips/`
|
||||||
|
- the `user/` gitignore claim omitted the three un-ignored site-owned plugins and the secret/`env/` exclusions
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
### 1. Apply the operational test to every line
|
||||||
|
|
||||||
|
> **Does this line change what Claude does on a task where it wouldn't otherwise open the relevant file?**
|
||||||
|
|
||||||
|
If no, it is a *description* — move it to `docs/`. Claude reads the code anyway; prose about the code just drifts alongside it.
|
||||||
|
|
||||||
|
Corollary: **version numbers are pure drift with no behavioral payload.** `Grav 2.0.7`, `Admin2 v2.0.12`, and the GPM-channel paragraph were all dropped. What survived is version-free:
|
||||||
|
|
||||||
|
> The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
|
||||||
|
|
||||||
|
"No database" stays because it *does* change behavior — an agent that believes there is a database goes looking for migrations, an ORM, and a query layer that do not exist.
|
||||||
|
|
||||||
|
### 2. Tier content by when it gets read
|
||||||
|
|
||||||
|
| Content | Home | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Rules, gotchas, invariants | `CLAUDE.md` | Worthless unless already in context |
|
||||||
|
| How the code works | `docs/reference/` | Claude reads the code anyway; prose drifts |
|
||||||
|
| How to do a task | `docs/guides/` | Read at task start, on demand |
|
||||||
|
| A trap already hit, with symptoms | `docs/solutions/` | Retrieved by symptom, indexed by frontmatter |
|
||||||
|
| Setup, folder map, commands | `README.md` | For humans; Claude has the Makefile |
|
||||||
|
|
||||||
|
CLAUDE.md keeps a six-row entry-point table pointing at each destination — the routing is a rule, the content behind it is not.
|
||||||
|
|
||||||
|
### 3. Gotchas are the one category that cannot be extracted
|
||||||
|
|
||||||
|
Every other content type has a natural trigger that opens the file:
|
||||||
|
|
||||||
|
| Type | Trigger that gets it read |
|
||||||
|
|---|---|
|
||||||
|
| Description | Agent opens the code |
|
||||||
|
| Procedure | Agent starts the task |
|
||||||
|
| Incident write-up | Agent recognizes a symptom |
|
||||||
|
| **Gotcha / exception** | **none — it must already be in context** |
|
||||||
|
|
||||||
|
A file you only open once you suspect an exception exists is a file you open **too late**. A proposed `docs/exceptions/` directory was therefore recommended against. Supporting arithmetic: the whole rules surface is ~40 lines / ~2,200 tokens, so a second file saves ~1k tokens while adding a lookup step, and `docs/solutions/` (indexed by `module` / `tags` / `problem_type`) already fills the read-on-demand role for "have we hit this before?".
|
||||||
|
|
||||||
|
### 4. State invariants, not enumerations
|
||||||
|
|
||||||
|
An enumerated list is falsified by the next addition, silently. An inverted statement of the same fact survives it. This is what `285e615` did — same three lines, no size change, but now staleness-proof.
|
||||||
|
|
||||||
|
### 5. Verify the destination before extracting
|
||||||
|
|
||||||
|
Every extraction target was confirmed to already exist and already cover the topic:
|
||||||
|
|
||||||
|
- pointer bumps and worktree mechanics → `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` (already covered them)
|
||||||
|
- the `user/env/<host>/` override tree → `docs/guides/deploy-cycle.md` (already covered it)
|
||||||
|
- source→output asset table → `docs/reference/architecture.md` → "Asset pipeline" (section added to receive it, lines 69-82)
|
||||||
|
- test-suite descriptions → `docs/reference/testing.md` (**created**, 67 lines — no destination existed)
|
||||||
|
- folder map + `make` tables → `README.md` (179 → 227 lines)
|
||||||
|
|
||||||
|
Nothing extracted became homeless. Related fix in the same pass: `docs/working/git-sync-notes.md` pointed at "CLAUDE.md §1", a section number that no longer existed after renumbering — **cross-references into an instruction file must point at stable headings, never numbers.**
|
||||||
|
|
||||||
|
### 6. Know when to stop
|
||||||
|
|
||||||
|
At 74 lines the section sizes were even — Hard rules 9, Dev environment 8, Content and trips 7, Two shared partials 7, Dual-repo submodule 7, Testing 7, Working docs 7, intro + entry-point table 15. No fat pocket remained. Roughly 8 more lines *could* have gone (the `travel-memories` :8082 port, a parenthetical Twig-recompile aside, tightening two bullets) for ~250 tokens out of ~2,200 — while deleting actual rules.
|
||||||
|
|
||||||
|
**The trim is strongly positive while what leaves is descriptions, and turns negative once only rules remain.** Round 3 therefore ended with a "we're at the floor" verdict plus one robustness fix (`285e615`), not another cut.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
**Every stale fact found across all four rounds was a description of code or config. Not one was a rule.** Two of them had been written by Claude itself days earlier. Descriptions drift because the code moves and the prose does not; rules do not drift because they encode intent rather than state. The tiering above is not an aesthetic preference — it is the only conclusion the evidence supports.
|
||||||
|
|
||||||
|
**A wrong path in an always-loaded file is worse than an absent one.** CLAUDE.md claimed the map engine lived at `js/src/maplibre-utils.js`. That file does not exist. The real path is `user/themes/intotheeast/js/maplibre-utils.js` — a hand-authored source sitting *next to* the generated bundles in `js/`, imported by `js/src/map.js` as `../maplibre-utils.js`. The wrong path survived rounds 1 and 2 (`2fbfc88` line 76, `ed6e43a` line 64) and was only fixed in `839a4d0`.
|
||||||
|
|
||||||
|
An absent fact makes an agent go look. A wrong fact makes it act confidently in the wrong place. Here the wrong place was `js/map.js` — a minified esbuild bundle. The failure mode is a hand-edit that survives until the next `make build-assets` silently reverts it.
|
||||||
|
|
||||||
|
This is also the decisive argument against `docs/exceptions/`: **the maplibre-utils mistake happened because the path was wrong, not because it was missing.** Had that rule lived in `docs/exceptions/assets.md`, the bundle would have been hand-edited with the agent never knowing the file existed.
|
||||||
|
|
||||||
|
**What survived the cut is the sanity check on the criterion.** A rule stays when being wrong about it is expensive *and* the correct behavior is not derivable from reading a file:
|
||||||
|
|
||||||
|
- the Admin plugin slug is `admin2`, not `admin` — nothing in the tree announces this before you've already guessed wrong
|
||||||
|
- `plugins.txt` is hand-maintained; installing a plugin via Admin does **not** update it
|
||||||
|
- once `user/env/<hostname>/` exists on a server, Grav's Admin writes **all** config there — system *and* plugin — and env wins, so server config must be read from both trees
|
||||||
|
- `active_trip` is a **route** (`/trips/denmark-2026`), not a bare slug
|
||||||
|
- never re-add a `pageconfig.parent` to `post-form.md` — a static parent overrides the `active_trip`-derived write target and reintroduces a silent-desync bug
|
||||||
|
- the standalone `/dailies`, `/map`, `/stats`, `/stories` trip views were deleted 2026-07-04 and must not be re-created or linked
|
||||||
|
|
||||||
|
Each of those is a landmine an agent steps on *before* it has cause to open the relevant file.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Auditing or editing any always-loaded instruction file — `CLAUDE.md`, `AGENTS.md`, system prompts, agent definitions
|
||||||
|
- When a stale fact is found in an instruction file: fix it, then ask why that *category* of sentence was there at all
|
||||||
|
- Before adding a line to `CLAUDE.md` — run the operational test first, and route to the tiering table if it fails
|
||||||
|
- Before writing an enumerated list of files, paths, plugins, or bundles into an instruction file — try inverting it into an invariant and verify the inverted form against the actual directory listing
|
||||||
|
- Before extracting content out of an instruction file — confirm the destination exists and covers the topic, or create it in the same commit
|
||||||
|
- When tempted to create a new read-on-demand directory for exceptions or gotchas — don't; they only work in-context
|
||||||
|
- When a reduction pass stops finding descriptions and starts deleting rules — stop and record a floor verdict instead of cutting further
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Enumerated list → invariant (`285e615`)
|
||||||
|
|
||||||
|
**Before** — 3 lines, falsified by adding a fifth bundle:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **Never hand-edit build output**, and know which files those are — sources and outputs
|
||||||
|
share folders under `user/themes/intotheeast/` (all paths below are relative to it).
|
||||||
|
`make build-assets` is mandatory after editing any source, and it writes:
|
||||||
|
- **Generated (never edit):** `js/main.js`, `js/map.js`, `js/feed-actions.js`,
|
||||||
|
`js/trip-publish.js`, `js/post/`, `css-compiled/`, `fonts/`, and
|
||||||
|
`templates/partials/weather-icons.html.twig`.
|
||||||
|
- **Hand-authored sources:** everything in `js/src/`, plus `js/maplibre-utils.js` and
|
||||||
|
`js/nav.js` (which sit *next to* the bundles in `js/`), `css/style.css`,
|
||||||
|
`css/tokens.css`, and `scripts/gen-weather-icons.js`.
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** — 3 lines, 156 chars shorter, still true after the next bundle is added:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **Never hand-edit build output** — sources and outputs share folders under
|
||||||
|
`user/themes/intotheeast/` (paths below are relative to it), so know which is which.
|
||||||
|
Run `make build-assets` after editing any source.
|
||||||
|
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
|
||||||
|
- `css-compiled/` and `fonts/` are generated (sources: `css/style.css`, `css/tokens.css`);
|
||||||
|
so is `templates/partials/weather-icons.html.twig` (source: `scripts/gen-weather-icons.js`).
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification that made this safe: `ls js/` returns exactly the 4 bundles + `post/` + `maplibre-utils.js` + `nav.js` + `src/`. The inverted form is exactly true today and stays true as bundles are added. The full enumerated source→output table now lives in `docs/reference/architecture.md` → "Asset pipeline", where drift is cheap because the table is read next to the code it describes.
|
||||||
|
|
||||||
|
### Description → extracted; rule → kept
|
||||||
|
|
||||||
|
**Before** (round 1 addition, later cut) — a description of the build, in permanent context:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**`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.
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** — the `--user` mechanism and the esbuild pipeline moved to `docs/reference/architecture.md` line 71; only the never-edit rule and the source/output discriminator remain in `CLAUDE.md`.
|
||||||
|
|
||||||
|
### Wrong path → right path (`839a4d0`)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js`.
|
||||||
|
+the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js`
|
||||||
|
+(a hand-authored file, imported by `js/src/map.js`)
|
||||||
|
```
|
||||||
|
|
||||||
|
`js/src/maplibre-utils.js` never existed. The parenthetical is not padding — it is the whole reason the rule is in an always-loaded file: `js/` is the bundle directory, so a hand-authored source living there is exactly the fact an agent cannot infer.
|
||||||
|
|
||||||
|
### Rule stays, explanation leaves (`9ec2349`)
|
||||||
|
|
||||||
|
The plan-status convention needed both a machine-actionable rule and a human-readable explanation of the five states. They went to different files:
|
||||||
|
|
||||||
|
- `CLAUDE.md` keeps the one-line rule — every plan needs a `**Status:**` line immediately after its title, plus what to surface when asked what's open, plus set `✅ Complete (YYYY-MM-DD)` before closing a session
|
||||||
|
- `docs/working/README.md` (52 lines) holds the explanation of the states, the directory layout, and the human-facing reference
|
||||||
|
|
||||||
|
Same convention, split by *when each half needs to be in context*.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [`docs/README.md`](../../README.md) — the existing "always-loaded rules → CLAUDE.md" vs "stable facts → reference/" split that this learning sharpens into an actionable test
|
||||||
|
- [`docs/working/plans/2026-06-21-documentation-restructure.md`](../../working/plans/2026-06-21-documentation-restructure.md) — the prior restructure that created the extraction destinations (`reference/architecture.md` and siblings) this pass relied on and re-applied
|
||||||
|
- [`docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`](../integration-issues/stale-grav-version-blocks-api-plugin-install.md) — sibling instance of version numbers rotting, in the deploy-config domain rather than the instruction-file domain
|
||||||
|
- [`docs/reference/architecture.md`](../../reference/architecture.md) → "Asset pipeline" — where the enumerated source→output table now lives
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# docs/working/ — work in flight
|
||||||
|
|
||||||
|
Everything here is a live working document: specs being built from, plans being executed, notes from sessions in progress. Once something is finished it stays (as a record) rather than being deleted — the `**Status:**` line is how you tell the difference.
|
||||||
|
|
||||||
|
Stable facts belong in [`../reference/`](../reference/); how-to procedures in [`../guides/`](../guides/); write-ups of bugs already solved in [`../solutions/`](../solutions/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's in here
|
||||||
|
|
||||||
|
| Path | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `specs/` | Design docs — the *what* and *why*, written before a plan. Named `YYYY-MM-DD-<topic>-design.md` |
|
||||||
|
| `plans/` | Implementation plans — the ordered *how*, with a status line. Named `YYYY-MM-DD-<topic>.md` |
|
||||||
|
| `milestones/` | Milestone scope documents (`milestone-1.md` … ) |
|
||||||
|
| `qa/` | Test plans, QA results, readiness audits |
|
||||||
|
| `handovers/` | Session handover notes — context for picking up unfinished work |
|
||||||
|
| `learnings/` | Retrospective notes worth keeping but not yet promoted to `../solutions/` |
|
||||||
|
| `backlog.md` | Unscheduled ideas and wishes |
|
||||||
|
| `bugs-and-fixes.md` | Running log of bugs found and what fixed them |
|
||||||
|
| `summary.md` | Project summary / current state |
|
||||||
|
| `pm-analysis.md`, `git-sync-notes.md`, dated one-offs | Standalone notes, kept for reference |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plan status convention
|
||||||
|
|
||||||
|
Every plan in `plans/` carries a `**Status:**` line immediately after its title heading. This is the single place a plan's state is recorded — there is no separate tracker.
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `📋 Not started` | Plan written and reviewed; no work begun yet |
|
||||||
|
| `🔄 In progress — <note>` | Actively being worked on. The note says where it stopped, so anyone (or any session) can resume |
|
||||||
|
| `⏸️ Deferred — <reason>` | Intentionally postponed. Still valid, just not now — the reason matters more than the status |
|
||||||
|
| `✅ Complete (YYYY-MM-DD)` | Done and shipped. The date is when it landed, not when the plan was written |
|
||||||
|
| `❌ Abandoned — <reason>` | Won't be implemented. Kept so the decision (and its reasoning) is not re-litigated later |
|
||||||
|
|
||||||
|
Notes on using it:
|
||||||
|
|
||||||
|
- **A trailing note after `✅ Complete` is normal and encouraged** for anything non-trivial — what actually shipped, what was deferred, which commit or environment it landed in. Several plans here carry a paragraph.
|
||||||
|
- **`Deferred` is not `Abandoned`.** Deferred means "still want this"; abandoned means "decided against it". Keeping them distinct is the whole point of having both.
|
||||||
|
- **Update the status when the work lands**, not later. A plan whose status lags reality is worse than no plan, because it is trusted.
|
||||||
|
|
||||||
|
### Asking Claude what's open
|
||||||
|
|
||||||
|
Claude reads these statuses directly (the convention is also in [`../../CLAUDE.md`](../../CLAUDE.md), so it applies without being asked). When asked what's open it will surface `Not started` and `In progress`, show `Deferred` items with the label made explicit, and leave out `Complete` and `Abandoned` unless you ask for them. It sets the status to `✅ Complete (YYYY-MM-DD)` on finishing a plan.
|
||||||
|
|
||||||
|
A quick manual sweep of the same thing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rH '^\*\*Status:\*\*' docs/working/plans/ | grep -v 'Complete\|Abandoned'
|
||||||
|
```
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
## ⚠️ Config lives in the ENVIRONMENT tree, not `user/config/` (IMPORTANT)
|
## ⚠️ Config lives in the ENVIRONMENT tree, not `user/config/` (IMPORTANT)
|
||||||
|
|
||||||
Prod has a per-environment override directory `user/env/<hostname>/config/`
|
Prod has a per-environment override directory `user/env/<hostname>/config/`
|
||||||
(created for Twig prod-mode — see CLAUDE.md §1). **A crucial Grav side effect:
|
(created for Twig prod-mode — see [`../guides/deploy-cycle.md`](../guides/deploy-cycle.md) →
|
||||||
|
"The env override tree"). **A crucial Grav side effect:
|
||||||
once that env dir exists, the Admin panel saves ALL config changes — system and
|
once that env dir exists, the Admin panel saves ALL config changes — system and
|
||||||
plugin — into the active environment's config tree**, not `user/config/`.
|
plugin — into the active environment's config tree**, not `user/config/`.
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,22 @@ execution: code
|
|||||||
|
|
||||||
# Post Form Location Override - Plan
|
# Post Form Location Override - Plan
|
||||||
|
|
||||||
**Status:** 📋 Not started
|
**Status:** ✅ Complete (2026-07-24) — U1–U6 shipped, then hardened by a multi-agent code review the same day. The review found the design's stated server-side safety net (`cleanCoordinate()`) had never been committed, so it landed here; replaced a prefix-parsing coordinate check that accepted `48abc` / `48,85` / `35.0116S` (hemisphere silently flipped); closed three paths that bypassed the submit gate (draft restore, edit-mode prefill, map-load failure) because the gate read a CSS class no code set at init; added pin removal on blanked fields; made the geocode failure visible; and rewrote the U5 guard spec, which asserted only instantly-passing conditions and so could not fail. R8 and R13 above are revised accordingly.
|
||||||
|
|
||||||
|
**Verified by a green run (2026-07-24).** The suite now executes end-to-end: `test-config` 22/22, `test-post` 6/6 (the `scripts/test-post.sh` shell suite — *not* the Playwright specs under `tests/ui/post/`, which is a separate set), and `location-override.spec.js` **20/20** — so the verifications below are no longer by inspection alone. Reaching that took fixing `make test-account` (the password was interpolated into an `sh -c` string, so a shell metacharacter in it killed every UI run), pinning `test-ui` to this checkout's own port, and repairing test cleanup, which had never been able to delete the root-owned entries Grav's Apache creates. See the commit `fix(test): close the test-entry leak into real trip content`.
|
||||||
|
|
||||||
|
Also landed after the review: maplibre's stylesheet is now lazy-`<link>`ed at panel-open instead of statically bundled, cutting `post-form.css` from 92,244 to 26,784 raw bytes (14,528 → 5,631 gzip) on every `/post` load, with a new spec asserting both halves of that boundary.
|
||||||
|
|
||||||
|
**Merged to `main` 2026-07-24** — `user/` at `dd19995`, outer at `4450bd6`, pin bumped. On merged `main`: `test-config` **22/22** and `tests/ui/post/` + `tests/ui/map` **69 passed / 1 failed** (DEL4 only, a pre-existing regression unrelated to this feature — see below). `user/` is still **unpushed by choice**; push `user/` first, then the outer repo.
|
||||||
|
|
||||||
|
**Notes carried forward:**
|
||||||
|
- The `user/` submodule commits remain **unpushed by choice** (git-sync would deploy to prod). Merged to `main` locally on 2026-07-24 and the pin bumped; pushing `user/` — then the outer repo, in that order — is the remaining step and is deliberately left to the user to time.
|
||||||
|
- **DEL4 is a real, pre-existing regression and the one thing still red on `main`** (`tests/ui/post/delete-flow.spec.js:44`, reproducible in isolation). Deleting an entry works: the card leaves the DOM and the folder leaves disk (both asserted and both pass). But a fresh load of the trip page makes the server re-emit the card — an image-less ghost of a page whose content is gone. That is precisely the bug the spec's own header says was already fixed once, so the invalidation has regressed. `cache-on-save` clears the page-tree cache on form *submit*; the delete path evidently does not do the equivalent. Practical impact: delete a bad post from the road, reload, and it is back. Worth its own branch.
|
||||||
|
- ~~This worktree's `user/` branch has diverged from `user/`'s `main`~~ **Done** — `user/main` merged in (`7903432`). It was ahead on both content and theme fixes; `denmark-2026 published: true` came with it, so the local testing flip is gone. The one conflict was `js/post/post-form.js`, a generated bundle, resolved by rebuilding rather than hand-merging minified output.
|
||||||
|
- ~~The `~/Projects` clone's `user/` carries two commits this clone cannot see~~ **Done** — merged in (`8a5cc52`). There is no second clone: `~/Projects` is a symlink to `~/Nextcloud/Projects`. What differs is the **submodule git dir** — a worktree gets `.git/worktrees/<name>/modules/user`, not the checkout's `.git/modules/user` — so `user/main` read `4721af6` here while the checkout's read `285ae37`, and the leg-connection map fix and U+200E strip were unreachable until a local `git fetch` between the two paths. Worth remembering: submodule commits made from the main checkout do not appear in a worktree until fetched, and a local fetch carries them without a push, so git-sync never fires.
|
||||||
|
- **Retracted: the "`owner_username` cluster" diagnosis was wrong.** The worktree showed 6 failures (AN2, DEL1–4, ES1) and they were attributed to `site.yaml` pinning `owner_username: mischa` while the suite authenticates as `testrunner`. On merged `main` only DEL4 fails, with byte-identical `site.yaml` and content — so auth was not the cause. The difference is environmental: the isolated worktree's `user/plugins/` was incomplete (missing `admin`, `markdown-notices`, `migrate-grav`, since `plugins/` is git-ignored and populated per-checkout by `make install-plugins`). Lesson: treat a worktree's UI failures as suspect until reproduced in the main checkout, because the worktree's plugin set is not guaranteed to match.
|
||||||
|
- ~~Every `make` target aborts with `.env:6: *** missing separator`~~ **Fixed by the user (2026-07-24)** — `make` now parses in the checkout. Worth keeping in mind: the env layering is intentional (`.env` global, `-include .env.$(ENV)` per-environment, `ENV` set by the generated env-suffixed remote targets like `make remote-install-prod`), but because `.env` is pulled in with `-include` it must be valid **makefile** syntax as well as valid dotenv — so a leading tab, a multi-line value, or a line without `=` takes down every target at once. Worktrees mask it, since `worktree-new` creates no `.env` and the include silently skips.
|
||||||
|
- **UG1, UG2 and LD1 under `tests/ui/post/` now pass** — they had been failing only because this branch predated `e17a5dc` ("block submit on unfinished photo uploads; un-squeeze EXIF portraits in lightbox"). Merging `user/main` in brought the upload gate and the oriented-derivative slide dims those specs assert, and all three went green with no product change. A first pass mistook them for live defects; the lesson is to check the submodule branch point before reading a red spec on a feature branch as a real bug.
|
||||||
|
|
||||||
## Goal Capsule
|
## Goal Capsule
|
||||||
|
|
||||||
@@ -45,7 +60,7 @@ The only way to set a coordinate today is the GPS button (reads live position) o
|
|||||||
- R5. Lookup is explicit-click only. While in flight, the button shows a disabled "Searching…" state that always re-enables on response, no-match, or network failure.
|
- R5. Lookup is explicit-click only. While in flight, the button shows a disabled "Searching…" state that always re-enables on response, no-match, or network failure.
|
||||||
- R6. Clicking with both City and Country empty is treated as a no-match: an inline hint asks for a city or country first, and no request is sent.
|
- R6. Clicking with both City and Country empty is treated as a no-match: an inline hint asks for a city or country first, and no request is sent.
|
||||||
- R7. Multiple matches render as a clickable list (place name, admin region, country), built via `document.createElement` + `.textContent` (no `innerHTML`), matching every other dynamic-content construction already in `post-form.js`. Clicking an entry sets `lat`/`lng` and the pin only — it never writes back to City/Country. The list hides again until the next lookup.
|
- R7. Multiple matches render as a clickable list (place name, admin region, country), built via `document.createElement` + `.textContent` (no `innerHTML`), matching every other dynamic-content construction already in `post-form.js`. Clicking an entry sets `lat`/`lng` and the pin only — it never writes back to City/Country. The list hides again until the next lookup.
|
||||||
- R8. No matches renders an inline hint suggesting a country or manual pin drag; a network failure degrades silently (fields untouched), consistent with the existing reverse-geocode/weather error handling in `post-form.js`.
|
- R8. No matches renders an inline hint suggesting a country or manual pin drag; a network failure (or a non-2xx response) leaves the fields untouched and renders a *distinct* inline hint naming the connection as the problem. **Revised in code review 2026-07-24** from "degrades silently" — silence was indistinguishable from a broken button, and the two failure modes need different messages.
|
||||||
|
|
||||||
**Map preview & sync**
|
**Map preview & sync**
|
||||||
- R9. A single MapLibre GL map with one draggable marker (≥44×44px touch target) renders in the panel, reusing the site's existing style URL (`MAP_STYLE`, extracted to a shared `user/themes/intotheeast/js/src/map-style.js` module per KTD1). The map instance is created once, on the panel's first open, held in module scope, and reused (with an explicit `.resize()` call) on every subsequent open — the container sits under `display:none` while closed, so the first paint would otherwise get a zero-size canvas.
|
- R9. A single MapLibre GL map with one draggable marker (≥44×44px touch target) renders in the panel, reusing the site's existing style URL (`MAP_STYLE`, extracted to a shared `user/themes/intotheeast/js/src/map-style.js` module per KTD1). The map instance is created once, on the panel's first open, held in module scope, and reused (with an explicit `.resize()` call) on every subsequent open — the container sits under `display:none` while closed, so the first paint would otherwise get a zero-size canvas.
|
||||||
@@ -54,7 +69,7 @@ The only way to set a coordinate today is the GPS button (reads live position) o
|
|||||||
- R12. No pin is shown until one of the four paths above sets a value for the first time.
|
- R12. No pin is shown until one of the four paths above sets a value for the first time.
|
||||||
|
|
||||||
**Error handling & validation boundary**
|
**Error handling & validation boundary**
|
||||||
- R13. Invalid manual `lat`/`lng` text is never client-blocked — the visual mismatch flag (R11) is the only feedback. Final enforcement stays server-side in `cleanCoordinate()`, which already throws on a non-blank, still-invalid value after cleaning.
|
- R13. Invalid manual `lat`/`lng` text raises the visual mismatch flag (R11), **and** an unresolved flag blocks submit. **Revised in code review 2026-07-24** from "never client-blocked". The original wording deferred all enforcement to a server-side `cleanCoordinate()` described as already shipped — it was not committed anywhere, so no layer validated coordinates. It now ships in `cache-on-save.php` (both the `/post` form and the Admin2/API save paths) and the client gate stays, giving real defence in depth. The client parse is intentionally stricter than the server's `is_numeric` (whole-value decimals only, so `48,85` / `35.0116S` / `48abc` are rejected rather than prefix-parsed).
|
||||||
- R14. Geolocation permission denial keeps its existing, unmodified `#location-status` error behavior.
|
- R14. Geolocation permission denial keeps its existing, unmodified `#location-status` error behavior.
|
||||||
|
|
||||||
### Scope Boundaries
|
### Scope Boundaries
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ Backend sanitization has already been added (`user/plugins/cache-on-save/cache-o
|
|||||||
### Error handling
|
### Error handling
|
||||||
|
|
||||||
- No search results: inline message under the search box, map/pin untouched.
|
- No search results: inline message under the search box, map/pin untouched.
|
||||||
- Search network failure: silent-ish degrade (consistent with existing weather/reverse-geocode error handling in `post-form.js`), fields untouched.
|
- Search network failure: fields untouched, and an inline hint says the lookup service could not be reached (distinct from the no-results message, which means the service answered). **Revised in code review 2026-07-24** — this originally said "silent-ish degrade", which in practice left the DOM byte-identical to the pre-click state, so a traveller on flaky mobile data could not tell a failed lookup from a broken button. A non-2xx response is also now treated as a failure rather than parsed as an empty result set.
|
||||||
- Invalid manual `lat`/`lng` text: no client-side hard block (the map preview and eventual server-side `cleanCoordinate()` are the safety nets); this UI's whole point is to make that failure mode rare in practice, not to duplicate the backend validator client-side.
|
- Invalid manual `lat`/`lng` text: the visual mismatch flag is the primary feedback, **and** an unresolved flag blocks submit. **Revised in code review 2026-07-24** — this originally said "no client-side hard block", on the stated grounds that server-side `cleanCoordinate()` was already the safety net. It was not: `cleanCoordinate()` had never been committed, so nothing validated coordinates anywhere. It now ships (`cache-on-save.php`, both the `/post` and Admin2 paths), so the two are genuine defence in depth rather than one imaginary net. Client-side parsing is deliberately *stricter* than the server's `is_numeric` (whole-value decimals only), which is the safe direction for a mismatch.
|
||||||
- Geolocation permission denied: unchanged existing behavior (`#location-status` error message).
|
- Geolocation permission denied: unchanged existing behavior (`#location-status` error message).
|
||||||
|
|
||||||
## Out of scope / explicitly deferred
|
## Out of scope / explicitly deferred
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ check_grep "location_country field present" "name: location_country"
|
|||||||
check_grep "weather_desc field present" "name: weather_desc"
|
check_grep "weather_desc field present" "name: weather_desc"
|
||||||
check_grep "weather_temp_c field present" "name: weather_temp_c"
|
check_grep "weather_temp_c field present" "name: weather_temp_c"
|
||||||
check_grep "transport_mode field present" "name: transport_mode"
|
check_grep "transport_mode field present" "name: transport_mode"
|
||||||
check_grep "hero_image field present" "name: hero_image"
|
# No hero_image assertion: the field was deliberately dropped in 8cf1145 —
|
||||||
|
# entries render their hero from the first photo, so an explicit filename was
|
||||||
|
# redundant (see the comment at that spot in post-form.md). This check outlived
|
||||||
|
# the field and had been failing ever since.
|
||||||
check_grep "force_connect field present" "name: force_connect"
|
check_grep "force_connect field present" "name: force_connect"
|
||||||
check_grep "featured field present" "name: featured"
|
check_grep "featured field present" "name: featured"
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,58 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail fast if the server under test does not serve the `user/` tree the specs
|
||||||
|
* read from disk.
|
||||||
|
*
|
||||||
|
* This mismatch is silent and destructive. Every post spec submits through the
|
||||||
|
* live form (the write target is derived server-side from site.yaml
|
||||||
|
* `active_trip`, so there is no per-request override), then asserts and cleans up
|
||||||
|
* on disk via helpers' USER_DIR. Run the specs from a worktree whose own
|
||||||
|
* container is down and baseURL falls back to localhost:8081 — the MAIN
|
||||||
|
* checkout — so entries get created in one content tree while cleanup deletes
|
||||||
|
* from another. The entries are then left behind in real trip content, which is
|
||||||
|
* exactly what happened on 2026-07-24.
|
||||||
|
*
|
||||||
|
* Docker is the only thing that knows the mapping, so this is best-effort: if we
|
||||||
|
* cannot determine it we warn and continue rather than blocking non-Docker runs.
|
||||||
|
* But when we CAN determine it and it disagrees, that is always a bug.
|
||||||
|
*/
|
||||||
|
function assertServerServesUserDir(baseURL, userDir) {
|
||||||
|
const port = new URL(baseURL).port || '80';
|
||||||
|
let mountedUserDir;
|
||||||
|
try {
|
||||||
|
const container = execSync("docker ps --format '{{.Names}}\t{{.Ports}}'", { encoding: 'utf-8' })
|
||||||
|
.split('\n').filter(Boolean)
|
||||||
|
.find(l => l.includes(`:${port}->`));
|
||||||
|
if (!container) {
|
||||||
|
console.warn(`[setup] no running container publishes port ${port} — is the dev server up? (make start)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = container.split('\t')[0];
|
||||||
|
mountedUserDir = execSync(
|
||||||
|
`docker inspect ${name} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
|
||||||
|
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||||
|
).trim();
|
||||||
|
if (!mountedUserDir) return; // no bind mount to compare against
|
||||||
|
} catch (_) {
|
||||||
|
return; // docker unavailable — nothing to check
|
||||||
|
}
|
||||||
|
|
||||||
|
const served = fs.realpathSync(mountedUserDir);
|
||||||
|
const asserted = fs.realpathSync(userDir);
|
||||||
|
if (served !== asserted) {
|
||||||
|
throw new Error(
|
||||||
|
`Test target mismatch — refusing to run.\n` +
|
||||||
|
` baseURL ${baseURL} is served from: ${served}\n` +
|
||||||
|
` but the specs read/clean up: ${asserted}\n` +
|
||||||
|
`Entries would be created in one tree and cleanup would miss them, leaving\n` +
|
||||||
|
`test entries behind in real content. Start this checkout's own server\n` +
|
||||||
|
`(make start) and point the run at it, e.g. GRAV_BASE_URL=http://localhost:<port>.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = async function globalSetup() {
|
module.exports = async function globalSetup() {
|
||||||
const envFile = path.join(__dirname, '../.env');
|
const envFile = path.join(__dirname, '../.env');
|
||||||
if (fs.existsSync(envFile)) {
|
if (fs.existsSync(envFile)) {
|
||||||
@@ -23,4 +75,9 @@ module.exports = async function globalSetup() {
|
|||||||
|
|
||||||
// Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files)
|
// Ensure demo content is loaded (italy-2026-demo trip + stories + GPX files)
|
||||||
execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
|
execSync('make demo-load', { cwd: path.join(__dirname, '..'), stdio: 'inherit' });
|
||||||
|
|
||||||
|
// Required last: helpers.js resolves USER_DIR at require time, and the .env
|
||||||
|
// load above can supply GRAV_USER_DIR.
|
||||||
|
const { USER_DIR } = require('./ui/helpers');
|
||||||
|
assertServerServesUserDir(process.env.GRAV_BASE_URL || 'http://localhost:8081', USER_DIR);
|
||||||
};
|
};
|
||||||
|
|||||||
+32
-45
@@ -1,57 +1,44 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
function resolveUserDir() {
|
// Reuse the specs' own resolution rather than reimplementing it. The previous
|
||||||
if (process.env.GRAV_USER_DIR) return process.env.GRAV_USER_DIR;
|
// version of this file derived the dailies directory from a `parent:` key in
|
||||||
try {
|
// pages/02.post/post-form.md — a key that was deliberately removed (the write
|
||||||
const raw = execSync(
|
// target is injected server-side from site.yaml `active_trip`, and CLAUDE.md
|
||||||
"docker inspect intotheeast_grav --format '{{range .Mounts}}{{if eq .Destination \"/var/www/html/user\"}}{{.Source}}{{end}}{{end}}'",
|
// forbids re-adding a static parent). The regex therefore never matched,
|
||||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
// dailiesDir was always null, and the dailies sweep below silently did nothing.
|
||||||
).trim();
|
// That is how ui-test entries survived into the active trip's content.
|
||||||
if (raw) return raw;
|
// removeEntryDir handles the root-owned case by deleting through the container —
|
||||||
} catch (_) {}
|
// see its comment. Plain fs.rmSync cannot remove what Grav's Apache wrote.
|
||||||
return path.join(__dirname, '../user');
|
const { USER_DIR, TRACKER_DIR, removeEntryDir } = require('./ui/helpers');
|
||||||
}
|
|
||||||
|
|
||||||
function sweepUiTestEntries(dir) {
|
function sweepUiTestEntries(dir) {
|
||||||
if (!fs.existsSync(dir)) return 0;
|
if (!dir || !fs.existsSync(dir)) return 0;
|
||||||
const entries = fs.readdirSync(dir).filter(e => e.includes('ui-test'));
|
const found = fs.readdirSync(dir).filter(e => e.includes('ui-test'));
|
||||||
entries.forEach(e => fs.rmSync(path.join(dir, e), { recursive: true, force: true }));
|
let removed = 0;
|
||||||
return entries.length;
|
found.forEach(e => {
|
||||||
|
const target = path.join(dir, e);
|
||||||
|
try {
|
||||||
|
removeEntryDir(target);
|
||||||
|
removed++;
|
||||||
|
} catch (err) {
|
||||||
|
// Loud, not silent — a swallowed failure here is exactly what let a
|
||||||
|
// ui-test entry survive into the active trip's content.
|
||||||
|
console.error(`[teardown] COULD NOT REMOVE ${target}: ${err.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = async function globalTeardown() {
|
module.exports = async function globalTeardown() {
|
||||||
const userDir = resolveUserDir();
|
// Sweep both the post inbox and the active trip's dailies.
|
||||||
|
const n1 = sweepUiTestEntries(path.join(USER_DIR, 'pages/02.post'));
|
||||||
// Read active trip slug from post-form.md
|
const n2 = sweepUiTestEntries(TRACKER_DIR);
|
||||||
const postFormPath = path.join(userDir, 'pages/02.post/post-form.md');
|
|
||||||
let dailiesDir = null;
|
|
||||||
if (fs.existsSync(postFormPath)) {
|
|
||||||
const content = fs.readFileSync(postFormPath, 'utf-8');
|
|
||||||
const m = content.match(/parent:\s*['"]?\/trips\/([^/'"]+)\/dailies/);
|
|
||||||
if (m) {
|
|
||||||
const tripSlug = m[1];
|
|
||||||
const tripsBase = path.join(userDir, 'pages/01.trips');
|
|
||||||
const tripFolder = fs.readdirSync(tripsBase).find(
|
|
||||||
f => f === tripSlug || f.endsWith('.' + tripSlug) || f.includes(tripSlug)
|
|
||||||
);
|
|
||||||
if (tripFolder) {
|
|
||||||
const dailiesBase = path.join(tripsBase, tripFolder);
|
|
||||||
const dailiesFolder = fs.readdirSync(dailiesBase).find(
|
|
||||||
f => f === 'dailies' || f === '01.dailies' || f.endsWith('.dailies')
|
|
||||||
);
|
|
||||||
if (dailiesFolder) dailiesDir = path.join(dailiesBase, dailiesFolder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sweep both the post inbox and the active trip's dailies
|
|
||||||
const postInbox = path.join(userDir, 'pages/02.post');
|
|
||||||
const n1 = sweepUiTestEntries(postInbox);
|
|
||||||
const n2 = dailiesDir ? sweepUiTestEntries(dailiesDir) : 0;
|
|
||||||
|
|
||||||
if (n1 + n2 > 0) {
|
if (n1 + n2 > 0) {
|
||||||
console.log(`[teardown] removed ${n1} ui-test entries from 02.post, ${n2} from dailies`);
|
console.log(
|
||||||
|
`[teardown] removed ${n1} ui-test entries from 02.post, ` +
|
||||||
|
`${n2} from ${path.relative(USER_DIR, TRACKER_DIR)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+59
-2
@@ -170,6 +170,60 @@ async function createPhotoEntry(page, tag, { content, publish = true, created }
|
|||||||
'Entry posted successfully!', { timeout: 15_000 });
|
'Entry posted successfully!', { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Grav container that serves USER_DIR, so cleanup can delete as root.
|
||||||
|
* Prefers GRAV_CONTAINER (set by .worktree-env / .env), else matches on the bind
|
||||||
|
* mount so a worktree never picks the main checkout's container.
|
||||||
|
*/
|
||||||
|
function resolveGravContainer() {
|
||||||
|
if (process.env.GRAV_CONTAINER) return process.env.GRAV_CONTAINER;
|
||||||
|
try {
|
||||||
|
const want = fs.realpathSync(USER_DIR);
|
||||||
|
const names = execSync("docker ps --format '{{.Names}}'", { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] })
|
||||||
|
.split('\n').filter(Boolean);
|
||||||
|
return names.find((n) => {
|
||||||
|
const src = execSync(
|
||||||
|
`docker inspect ${n} --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/user"}}{{.Source}}{{end}}{{end}}'`,
|
||||||
|
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
||||||
|
).trim();
|
||||||
|
return src && fs.realpathSync(src) === want;
|
||||||
|
}) || null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an entry directory, falling back to the container when the host cannot.
|
||||||
|
*
|
||||||
|
* Grav's Apache workers run as root, so every entry the form creates is
|
||||||
|
* root-owned. Removing one recursively needs write permission on that directory,
|
||||||
|
* which the host user does not have — so a plain fs.rmSync throws EACCES and the
|
||||||
|
* entry survives. That is how a ui-test entry ended up committed-adjacent in the
|
||||||
|
* active trip's content on 2026-07-24: cleanup had never actually worked for
|
||||||
|
* form-created entries, it just failed inside a path nothing checked.
|
||||||
|
*
|
||||||
|
* `docker exec … rm -rf` runs as root in the container, which can remove them.
|
||||||
|
*/
|
||||||
|
function removeEntryDir(dir) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'EACCES' && err.code !== 'EPERM') throw err;
|
||||||
|
}
|
||||||
|
const container = resolveGravContainer();
|
||||||
|
if (!container) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot remove ${dir}: it is root-owned (written by Grav in the container) and no ` +
|
||||||
|
`matching container was found to delete it as root. Set GRAV_CONTAINER or remove it manually.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
execSync(`docker exec ${container} rm -rf '/var/www/html/user/${path.relative(USER_DIR, dir)}'`,
|
||||||
|
{ stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a tracker entry folder by a unique slug fragment, then delete it.
|
* Find a tracker entry folder by a unique slug fragment, then delete it.
|
||||||
*/
|
*/
|
||||||
@@ -179,7 +233,7 @@ function cleanupEntry(slugFragment) {
|
|||||||
const entries = fs.readdirSync(TRACKER_DIR);
|
const entries = fs.readdirSync(TRACKER_DIR);
|
||||||
const match = entries.find(e => e.includes(slugFragment));
|
const match = entries.find(e => e.includes(slugFragment));
|
||||||
if (match) {
|
if (match) {
|
||||||
fs.rmSync(path.join(TRACKER_DIR, match), { recursive: true });
|
removeEntryDir(path.join(TRACKER_DIR, match));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,4 +256,7 @@ function readEntryMd(entryDir) {
|
|||||||
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
|
return fs.readFileSync(path.join(entryDir, name), 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { fillEditor, waitForPhotoUpload, postEntry, createPhotoEntry, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO, TRACKER_DIR, ACTIVE_TRIP_URL };
|
// USER_DIR is exported so global-setup/global-teardown resolve the same tree the
|
||||||
|
// specs assert against, instead of keeping their own (previously divergent) copy
|
||||||
|
// of this logic.
|
||||||
|
module.exports = { fillEditor, waitForPhotoUpload, postEntry, createPhotoEntry, cleanupEntry, removeEntryDir, findEntry, readEntryMd, TEST_PHOTO, USER_DIR, TRACKER_DIR, ACTIVE_TRIP_URL };
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
// display EXIF-rotated. For a stored-landscape portrait photo the attrs said
|
// display EXIF-rotated. For a stored-landscape portrait photo the attrs said
|
||||||
// landscape while the pixels rendered portrait → PhotoSwipe squeezed them.
|
// landscape while the pixels rendered portrait → PhotoSwipe squeezed them.
|
||||||
//
|
//
|
||||||
|
// Fixed in e17a5dc: slides now link a 2000px fit-within derivative and measure
|
||||||
|
// THAT file, and derivatives are re-encoded upright, so the attrs and the
|
||||||
|
// rendered pixels agree.
|
||||||
|
//
|
||||||
// The invariant tested here is environment-proof: whatever file the slide
|
// The invariant tested here is environment-proof: whatever file the slide
|
||||||
// links to, its browser-rendered natural size must equal the data-pswp-*
|
// links to, its browser-rendered natural size must equal the data-pswp-*
|
||||||
// attrs. (Whether the photo ALSO displays upright depends on the server's
|
// attrs. (Whether the photo ALSO displays upright depends on the server's
|
||||||
@@ -24,10 +28,14 @@ const { test, expect } = require('@playwright/test');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
// USER_DIR comes from helpers so GRAV_USER_DIR is honoured — without it a run
|
||||||
|
// against a checkout detached from the served tree plants the fixture in a
|
||||||
|
// different user/ than Grav renders, and LD1 fails as an opaque "card never
|
||||||
|
// appeared" timeout.
|
||||||
|
const { USER_DIR } = require('../helpers');
|
||||||
|
|
||||||
// Stored 800x600 with EXIF Orientation=6: browsers render it 600x800 portrait.
|
// Stored 800x600 with EXIF Orientation=6: browsers render it 600x800 portrait.
|
||||||
const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg');
|
const EXIF_PORTRAIT = path.join(__dirname, '../../fixtures/test-photo-exif-portrait.jpg');
|
||||||
const USER_DIR = path.join(__dirname, '../../../user');
|
|
||||||
const DEMO_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
|
const DEMO_DAILIES = path.join(USER_DIR, 'pages/01.trips/italy-2026-demo/01.dailies');
|
||||||
const DEMO_TRIP_URL = '/trips/italy-2026-demo';
|
const DEMO_TRIP_URL = '/trips/italy-2026-demo';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Tests: post form "More location details" — search-by-city lookup + draggable
|
||||||
|
// map pin preview for setting an entry's coordinates without live GPS.
|
||||||
|
// Covers R4-R14. The Open-Meteo geocoding endpoint is mocked via page.route()
|
||||||
|
// so this suite is hermetic (no live third-party call, no rate-limit flakiness).
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const path = require('path');
|
||||||
|
const { fillEditor, waitForPhotoUpload, cleanupEntry, findEntry, readEntryMd, TEST_PHOTO } = require('../helpers');
|
||||||
|
|
||||||
|
const GEOCODE_URL = '**/geocoding-api.open-meteo.com/v1/search**';
|
||||||
|
|
||||||
|
const created = [];
|
||||||
|
test.afterAll(() => { created.forEach(cleanupEntry); });
|
||||||
|
|
||||||
|
// Real-API-shaped fixtures (verified live against geocoding-api.open-meteo.com).
|
||||||
|
const KYOTO_RESULTS = {
|
||||||
|
results: [
|
||||||
|
{ name: 'Kyoto', latitude: 35.0116, longitude: 135.7681, admin1: 'Kyoto Prefecture', country: 'Japan' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirrors the design doc's verified live Paris query: Île-de-France (France)
|
||||||
|
// first from the API, then five US states — Texas among them, in admin1 (the
|
||||||
|
// API's `country` field is "United States" for all of the US matches, so the
|
||||||
|
// ranking must also check admin1 to disambiguate on a US state name).
|
||||||
|
const PARIS_RESULTS = {
|
||||||
|
results: [
|
||||||
|
{ name: 'Paris', latitude: 48.85341, longitude: 2.3488, admin1: 'Île-de-France Region', country: 'France' },
|
||||||
|
{ name: 'Paris', latitude: 33.66094, longitude: -95.55551, admin1: 'Texas', country: 'United States' },
|
||||||
|
{ name: 'Paris', latitude: 36.302, longitude: -88.32671, admin1: 'Tennessee', country: 'United States' },
|
||||||
|
{ name: 'Paris', latitude: 38.2098, longitude: -84.2529, admin1: 'Kentucky', country: 'United States' },
|
||||||
|
{ name: 'Paris', latitude: 39.6112, longitude: -87.6961, admin1: 'Illinois', country: 'United States' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockGeocode(page, body) {
|
||||||
|
return page.route(GEOCODE_URL, (route) => route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLocationDetails(page) {
|
||||||
|
await page.locator('.location-details__summary').click();
|
||||||
|
await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel closed by default (R1) ────────────────────────────────────────────
|
||||||
|
test('More location details is closed by default and holds the relocated lat/lng fields', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
const details = page.locator('.location-details');
|
||||||
|
await expect(details).toBeAttached();
|
||||||
|
await expect(details).toHaveJSProperty('open', false);
|
||||||
|
await expect(page.locator('.location-details input[name="data[lat]"]')).toBeAttached();
|
||||||
|
await expect(page.locator('.location-details input[name="data[lng]"]')).toBeAttached();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R6: empty City + Country sends no request ───────────────────────────────
|
||||||
|
test('R6: clicking lookup with City and Country both empty sends no request', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
let requested = false;
|
||||||
|
await page.route(GEOCODE_URL, (route) => { requested = true; route.abort(); });
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
await expect(page.locator('#location-search-hint')).toContainText(/city or country/i);
|
||||||
|
expect(requested).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R7: a search result sets lat/lng only, never City/Country ──────────────
|
||||||
|
test('R7: clicking a search result sets lat/lng and leaves City/Country untouched', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, KYOTO_RESULTS);
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
const results = page.locator('.location-search-results li button');
|
||||||
|
await expect(results).toHaveCount(1);
|
||||||
|
await results.first().click();
|
||||||
|
|
||||||
|
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('35.011600');
|
||||||
|
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('135.768100');
|
||||||
|
await expect(page.locator('input[name="data[location_city]"]')).toHaveValue('Kyoto');
|
||||||
|
await expect(page.locator('input[name="data[location_country]"]')).toHaveValue('');
|
||||||
|
// R7: the list hides again until the next lookup.
|
||||||
|
await expect(page.locator('.location-search-results li')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R4/KTD2: Paris/Texas disambiguation ranks the Texas match first ─────────
|
||||||
|
test('disambiguation: City "Paris" + Country "Texas" ranks the Texas match first', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
let requestedUrl = null;
|
||||||
|
await page.route(GEOCODE_URL, (route) => {
|
||||||
|
requestedUrl = route.request().url();
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARIS_RESULTS) });
|
||||||
|
});
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Paris');
|
||||||
|
await page.fill('input[name="data[location_country]"]', 'Texas');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
const results = page.locator('.location-search-results li button');
|
||||||
|
await expect(results).toHaveCount(5);
|
||||||
|
await expect(results.first()).toContainText('Texas');
|
||||||
|
|
||||||
|
// R4: Country is never concatenated into the query string.
|
||||||
|
expect(requestedUrl).toContain('name=Paris');
|
||||||
|
expect(requestedUrl).not.toContain('Texas');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R8: no matches shows the inline hint, fields untouched ─────────────────
|
||||||
|
test('R8: no matches shows the no-match hint and leaves fields untouched', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, { results: [] });
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Nowheresville');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
await expect(page.locator('#location-search-hint')).toContainText(/no matches/i);
|
||||||
|
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
|
||||||
|
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R5: in-flight state shows "Searching…" and always re-enables ───────────
|
||||||
|
test('R5: the lookup button shows a disabled "Searching…" state while in flight', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.route(GEOCODE_URL, async (route) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(KYOTO_RESULTS) });
|
||||||
|
});
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
const btn = page.locator('#lookup-coords');
|
||||||
|
await expect(btn).toBeDisabled();
|
||||||
|
await expect(btn).toHaveText('Searching…');
|
||||||
|
await expect(btn).toBeEnabled({ timeout: 5_000 });
|
||||||
|
await expect(btn).toContainText('Look up coordinates');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R8: a network failure degrades silently and re-enables the button ──────
|
||||||
|
test('a network failure degrades silently, leaves fields untouched, and re-enables the button', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.route(GEOCODE_URL, (route) => route.abort('failed'));
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
await expect(page.locator('#lookup-coords')).toBeEnabled();
|
||||||
|
await expect(page.locator('input[name="data[lat]"]')).toHaveValue('');
|
||||||
|
await expect(page.locator('input[name="data[lng]"]')).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── XSS safety: an API-sourced name containing markup renders as literal text ──
|
||||||
|
test('a result name containing markup renders as literal text, not executed', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, {
|
||||||
|
results: [{ name: '<img src=x onerror="window.__xss=true">', latitude: 1, longitude: 2, country: 'Nowhere' }]
|
||||||
|
});
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Test');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
|
||||||
|
const btn = page.locator('.location-search-results li button').first();
|
||||||
|
await expect(btn).toContainText('<img src=x onerror="window.__xss=true">');
|
||||||
|
expect(await btn.evaluate((el) => el.querySelector('img'))).toBeNull();
|
||||||
|
expect(await page.evaluate(() => window.__xss)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U4: map renders exactly one canvas, no pin until a coordinate is set ───
|
||||||
|
test('opening the panel renders exactly one map canvas with no initial pin', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U4: reopening does not duplicate the canvas; resize keeps it non-zero ──
|
||||||
|
test('reopening the panel a second time leaves exactly one canvas with non-zero size', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.locator('.location-details__summary').click(); // close
|
||||||
|
await expect(page.locator('.location-details')).toHaveJSProperty('open', false);
|
||||||
|
await openLocationDetails(page); // reopen
|
||||||
|
|
||||||
|
const canvases = page.locator('#location-map canvas.maplibregl-canvas');
|
||||||
|
await expect(canvases).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
const box = await canvases.first().boundingBox();
|
||||||
|
expect(box && box.width).toBeGreaterThan(0);
|
||||||
|
expect(box && box.height).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R11: a search pick shows a pin on the map ───────────────────────────────
|
||||||
|
test('a search-result pick renders a pin on the map', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, KYOTO_RESULTS);
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
await page.locator('.location-search-results li button').first().click();
|
||||||
|
|
||||||
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R11: dragging the marker updates lat/lng (rounded to 6dp) ──────────────
|
||||||
|
test('dragging the pin updates lat/lng to the drop location', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, KYOTO_RESULTS);
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
await page.locator('.location-search-results li button').first().click();
|
||||||
|
|
||||||
|
const marker = page.locator('#location-map .maplibregl-marker');
|
||||||
|
await expect(marker).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
const before = await page.locator('input[name="data[lat]"]').inputValue();
|
||||||
|
|
||||||
|
// setPin()'s map.panTo() animates the marker into view — wait for it to
|
||||||
|
// settle so the bounding box grabbed below matches where the marker will
|
||||||
|
// actually be when the mouse events land.
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const box = await marker.boundingBox();
|
||||||
|
if (!box) throw new Error('marker has no bounding box');
|
||||||
|
const startX = box.x + box.width / 2;
|
||||||
|
const startY = box.y + box.height / 2;
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(startX + 40, startY + 30, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const after = await page.locator('input[name="data[lat]"]').inputValue();
|
||||||
|
expect(after).not.toBe(before);
|
||||||
|
expect(after).toMatch(/^-?\d+\.\d{6}$/);
|
||||||
|
}).toPass({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── R11/R13: typing an invalid value flags the field without crashing ──────
|
||||||
|
test('typing an invalid lat value shows the mismatch flag and clears once fixed', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
|
||||||
|
const latEl = page.locator('input[name="data[lat]"]');
|
||||||
|
const lngEl = page.locator('input[name="data[lng]"]');
|
||||||
|
await latEl.fill('not-a-number');
|
||||||
|
await lngEl.fill('135.7681');
|
||||||
|
await lngEl.blur();
|
||||||
|
|
||||||
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||||
|
await expect(latEl).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(0);
|
||||||
|
|
||||||
|
await latEl.fill('35.0116');
|
||||||
|
await latEl.blur();
|
||||||
|
await expect(latEl).not.toHaveClass(/location-field--mismatch/);
|
||||||
|
await expect(page.locator('#location-map .maplibregl-marker')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U4: rapid close/reopen while the maplibre-gl chunk is still in flight must
|
||||||
|
// not build two Map instances against the same container (code-review fix) ──
|
||||||
|
test('rapid close/reopen before the maplibre-gl chunk resolves still leaves exactly one canvas', async ({ page }) => {
|
||||||
|
await page.route('**/*maplibre-gl*.js', async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
await page.goto('/post');
|
||||||
|
|
||||||
|
// Open, then immediately close and reopen — both toggles land while the
|
||||||
|
// delayed chunk request above is still pending.
|
||||||
|
await page.locator('.location-details__summary').click();
|
||||||
|
await page.locator('.location-details__summary').click();
|
||||||
|
await page.locator('.location-details__summary').click();
|
||||||
|
await expect(page.locator('.location-details')).toHaveJSProperty('open', true);
|
||||||
|
|
||||||
|
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U5: blanking both fields after a mismatch was flagged clears the flag ──
|
||||||
|
test('blanking both lat/lng fields after a mismatch clears the flag', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
|
||||||
|
const latEl = page.locator('input[name="data[lat]"]');
|
||||||
|
const lngEl = page.locator('input[name="data[lng]"]');
|
||||||
|
await latEl.fill('not-a-number');
|
||||||
|
await lngEl.blur();
|
||||||
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||||
|
|
||||||
|
await latEl.fill('');
|
||||||
|
await lngEl.fill('');
|
||||||
|
await lngEl.blur();
|
||||||
|
await expect(latEl).not.toHaveClass(/location-field--mismatch/);
|
||||||
|
await expect(lngEl).not.toHaveClass(/location-field--mismatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U5: a flagged, unresolved lat/lng must block submit (code-review fix) ──
|
||||||
|
test('submitting with an unresolved lat/lng mismatch is blocked', async ({ page }) => {
|
||||||
|
const tag = `loc-mismatch-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await fillEditor(page, 'Location-override mismatch-blocks-submit guard. Safe to delete.');
|
||||||
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||||
|
await waitForPhotoUpload(page);
|
||||||
|
|
||||||
|
await openLocationDetails(page);
|
||||||
|
const latEl = page.locator('input[name="data[lat]"]');
|
||||||
|
const lngEl = page.locator('input[name="data[lng]"]');
|
||||||
|
await latEl.fill('999');
|
||||||
|
await lngEl.fill('999');
|
||||||
|
await lngEl.blur();
|
||||||
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||||
|
|
||||||
|
// Register for cleanup BEFORE the click: if the gate ever regresses, the
|
||||||
|
// entry lands on disk and the afterAll hook must still see the tag.
|
||||||
|
created.push(tag);
|
||||||
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||||
|
|
||||||
|
// `.notices` toHaveCount(0) and toHaveURL(/\/post/) both pass instantly and
|
||||||
|
// both also hold for a SUCCESSFUL submit (the form posts to /post and only
|
||||||
|
// renders its notice after the round trip), so neither can distinguish a
|
||||||
|
// working gate from a regressed one. Prove the negative on disk instead,
|
||||||
|
// after giving a regressed submit time to actually write.
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
expect(findEntry(tag), 'a flagged coordinate must never reach the server').toBeFalsy();
|
||||||
|
// And prove the block was the gate's doing: still flagged, value untouched.
|
||||||
|
await expect(latEl).toHaveClass(/location-field--mismatch/);
|
||||||
|
await expect(latEl).toHaveValue('999');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── U4: lazy-load boundary — an ordinary GPS-only submit never fetches maplibre-gl ──
|
||||||
|
// The URL pattern deliberately covers BOTH halves of the lazy boundary: the JS
|
||||||
|
// chunk (js/post/maplibre-gl-*.js) and the stylesheet
|
||||||
|
// (css-compiled/maplibre-gl.css, <link>ed by location-map.js at panel-open —
|
||||||
|
// see its ensureMaplibreCss). Neither may be requested when the panel stays shut.
|
||||||
|
test('an ordinary submit without opening the panel never fetches the maplibre-gl chunk', async ({ page }) => {
|
||||||
|
const chunkRequests = [];
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (/maplibre-gl/.test(req.url())) chunkRequests.push(req.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag = `loc-nomap-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await fillEditor(page, 'Location-override lazy-load guard. Safe to delete.');
|
||||||
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||||
|
await waitForPhotoUpload(page);
|
||||||
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||||
|
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||||
|
created.push(tag);
|
||||||
|
|
||||||
|
expect(chunkRequests, 'neither the maplibre-gl chunk nor its stylesheet may be fetched when the panel is never opened').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── The other half of that boundary: opening the panel DOES apply the vendor CSS ──
|
||||||
|
// Without this, the guard above could keep passing while the stylesheet silently
|
||||||
|
// stopped loading at all (a broken href, a missed build step), leaving the map
|
||||||
|
// unstyled with nothing to catch it. Asserts the <link> exists AND parsed —
|
||||||
|
// link.sheet is null until the browser has actually applied it.
|
||||||
|
test('opening the panel lazily links maplibre\'s stylesheet and applies it', async ({ page }) => {
|
||||||
|
await page.goto('/post');
|
||||||
|
|
||||||
|
const hrefBefore = await page.evaluate(() => Array.from(document.styleSheets)
|
||||||
|
.map((s) => s.href || '').filter((h) => /maplibre-gl\.css/.test(h)));
|
||||||
|
expect(hrefBefore, 'the vendor stylesheet must not be present before the panel opens').toHaveLength(0);
|
||||||
|
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await expect(page.locator('#location-map canvas.maplibregl-canvas')).toHaveCount(1, { timeout: 10_000 });
|
||||||
|
|
||||||
|
await expect.poll(
|
||||||
|
() => page.evaluate(() => {
|
||||||
|
const link = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
|
||||||
|
.find((l) => /maplibre-gl\.css/.test(l.href));
|
||||||
|
return link ? link.sheet !== null : false;
|
||||||
|
}),
|
||||||
|
{ message: 'maplibre\'s stylesheet must be linked and applied once the panel opens', timeout: 10_000 }
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Full submit: a search-picked location round-trips into the frontmatter ──
|
||||||
|
test('a full submit with a search-picked location saves the expected lat/lng', async ({ page }) => {
|
||||||
|
const tag = `loc-submit-${Date.now()}`;
|
||||||
|
await page.goto('/post');
|
||||||
|
await mockGeocode(page, KYOTO_RESULTS);
|
||||||
|
await page.fill('input[name="data[title]"]', `UI Test ${tag}`);
|
||||||
|
await fillEditor(page, 'Location-override submit test. Safe to delete.');
|
||||||
|
await page.fill('input[name="data[location_city]"]', 'Kyoto');
|
||||||
|
await openLocationDetails(page);
|
||||||
|
await page.click('#lookup-coords');
|
||||||
|
await page.locator('.location-search-results li button').first().click();
|
||||||
|
|
||||||
|
await page.locator('input.filepond--browser').setInputFiles(TEST_PHOTO);
|
||||||
|
await waitForPhotoUpload(page);
|
||||||
|
await page.locator('.btn-post').evaluate((el) => el.click());
|
||||||
|
await expect(page.locator('.notices')).toContainText('Entry posted successfully!', { timeout: 15_000 });
|
||||||
|
created.push(tag);
|
||||||
|
|
||||||
|
const entryDir = findEntry(tag);
|
||||||
|
expect(entryDir, 'Entry folder should exist on disk').toBeTruthy();
|
||||||
|
const md = readEntryMd(entryDir);
|
||||||
|
expect(md).toContain('35.0116');
|
||||||
|
expect(md).toContain('135.7681');
|
||||||
|
});
|
||||||
@@ -12,6 +12,12 @@
|
|||||||
// silent-data-loss path.
|
// silent-data-loss path.
|
||||||
// post-form.js owns the complete gate (theme code; the form plugin is
|
// post-form.js owns the complete gate (theme code; the form plugin is
|
||||||
// GPM-managed and not patchable in-repo).
|
// GPM-managed and not patchable in-repo).
|
||||||
|
//
|
||||||
|
// The gate lives in e17a5dc: submit is blocked unless EVERY FilePond item is
|
||||||
|
// processing-complete, with distinct messages for the failed and still-uploading
|
||||||
|
// cases. Both assert on .photo-convert-status, which post-form.js's setStatus()
|
||||||
|
// creates via photoStatusEl() — so a passing expectation here proves the THEME
|
||||||
|
// gate fired, not the form plugin's, whose own guard only raises alert().
|
||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers');
|
const { fillEditor, findEntry, cleanupEntry, TEST_PHOTO } = require('../helpers');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
// Tests: S1–S7 — story mode rendering and navigation
|
// Tests: S1–S9 — story mode rendering and navigation
|
||||||
// Requires demo data: run `make demo-load` before this suite.
|
// Requires demo data: run `make demo-load` before this suite.
|
||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
@@ -85,3 +85,74 @@ test('S7: story body back link has back-pill class', async ({ page }) => {
|
|||||||
await expect(bodyBack).toBeAttached();
|
await expect(bodyBack).toBeAttached();
|
||||||
await expect(bodyBack).toHaveText(/← Back/);
|
await expect(bodyBack).toHaveText(/← Back/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── S8: Scrolly-section text panels actually render beside the pinned image ───
|
||||||
|
// The server ships the panel text inside .scrolly__steps-content, which CSS hides
|
||||||
|
// (style.css: `display: none`). Only the inline Scrollama block in story.html.twig
|
||||||
|
// splits it into visible .scrolly-step divs — and it early-returns silently if the
|
||||||
|
// main.js bundle (which sets window.scrollama) hasn't executed yet. S3 asserted the
|
||||||
|
// image column exists; nothing asserted the text column was non-empty.
|
||||||
|
test('S8: scrolly-section builds visible step panels from its slot content', async ({ page }) => {
|
||||||
|
await page.goto(STORY_SCROLLY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
|
||||||
|
// The bundle must have published scrollama before the inline block ran
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => typeof window.scrollama !== 'undefined'),
|
||||||
|
'window.scrollama published by main.js bundle'
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Every scrolly-section must have produced at least one step
|
||||||
|
const sections = page.locator('.scrolly');
|
||||||
|
const sectionCount = await sections.count();
|
||||||
|
expect(sectionCount, 'Two scrolly-sections').toBe(2);
|
||||||
|
|
||||||
|
for (let i = 0; i < sectionCount; i++) {
|
||||||
|
const section = sections.nth(i);
|
||||||
|
const steps = section.locator('.scrolly-step');
|
||||||
|
expect(
|
||||||
|
await steps.count(),
|
||||||
|
`scrolly-section ${i} split its slot content into steps`
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// …and the text must be readable, not left hidden in the raw slot.
|
||||||
|
// Scroll each step into view so its reveal transition completes.
|
||||||
|
const firstStep = page.locator('.scrolly').first().locator('.scrolly-step').first();
|
||||||
|
await firstStep.scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await expect(firstStep.locator('.scrolly-step__inner')).toBeVisible();
|
||||||
|
const text = (await firstStep.innerText()).trim();
|
||||||
|
expect(text.length, 'First step panel renders non-empty text').toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── S9: Back-to-top is wired once, by main.js, and pushes a history entry ─────
|
||||||
|
// The inline duplicate in story.html.twig was removed; initBackToTop() in
|
||||||
|
// js/src/main.js now solely owns #story-totop. That makes the button depend on
|
||||||
|
// the bundle having loaded, so assert the observable behaviour end to end.
|
||||||
|
test('S9: story back-to-top reveals on scroll, returns to top, and pushes history', async ({ page }) => {
|
||||||
|
await page.goto(STORY_SCROLLY);
|
||||||
|
await expect(page.locator('.story-hero__img')).toBeVisible({ timeout: 8000 });
|
||||||
|
|
||||||
|
const btn = page.locator('#story-totop');
|
||||||
|
await expect(btn).toBeAttached();
|
||||||
|
|
||||||
|
// Hidden until scrolled past the 0.8 * viewport threshold
|
||||||
|
await expect(btn).not.toHaveClass(/is-visible/);
|
||||||
|
|
||||||
|
const historyBefore = await page.evaluate(() => history.length);
|
||||||
|
|
||||||
|
await page.evaluate(() => window.scrollTo(0, window.innerHeight * 2));
|
||||||
|
await expect(btn).toHaveClass(/is-visible/, { timeout: 3000 });
|
||||||
|
|
||||||
|
await btn.click();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.scrollY), { timeout: 3000 })
|
||||||
|
.toBeLessThan(10);
|
||||||
|
|
||||||
|
// main.js's variant pushes a history entry; the removed inline copy did not
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => history.length),
|
||||||
|
'Back-to-top pushed a history entry'
|
||||||
|
).toBeGreaterThan(historyBefore);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,5 +3,8 @@
|
|||||||
{
|
{
|
||||||
"path": "."
|
"path": "."
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"settings": {
|
||||||
|
"makefile.configureOnOpen": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
Submodule user updated: 02fa4e94a7...1b9e51baf7
Reference in New Issue
Block a user