docs(16): research phase

This commit is contained in:
2026-10-09 11:16:49 +02:00
parent 78b1d91568
commit 4117a6fce3

View File

@@ -0,0 +1,733 @@
# Phase 16: grzybyfunkcjonalne.pl on reusable blog views - Research
**Researched:** 2026-10-09
**Domain:** Nuxt 4 SSR layer (reusable Journal views) + Nuxt site app + headless SummerCMS Go host, WinterCMS content migration, two-process deploy
**Confidence:** MEDIUM-HIGH (in-repo facts and live-site observations HIGH; Nuxt layer-over-git mechanics MEDIUM, need a Wave 0 spike)
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
#### Rendering approach (user-stated before discussion)
- **D-01:** Build a new Nuxt theme following summercms standards (the `vue-summercmsio-app` setup: Nuxt 4, `@nuxtjs/i18n`, `@nuxtjs/seo`, `@nuxt/fonts`, pnpm, node `--test`). The styling is based on grzybyfunkcjonalne but refreshed, not a pixel copy of arsha/Bootstrap. Go stays headless. No Go `html/template` theme layer and no Twig port. — **Reversibility:** costly — every public page is a Nuxt page that consumes the Journal JSON API.
#### Reuse shape
- **D-02:** The reusable blog views are a **Nuxt layer** in a new repo `vue-journal-layer` (`git.golem15.com/golem15/vue-journal-layer`). Site apps consume it with `extends`. — **Reversibility:** costly — later blogs build on the layer's structure.
- **D-03:** The layer ships a **neutral base design driven by CSS custom-property tokens** (colors, fonts, spacing, radii). It looks clean on its own. Site apps override the tokens and can override any layer component. The refreshed grzyby design lives in the site app, not in the layer.
- **D-04:** Distribution is a **pnpm git dependency** with a pinned tag: `git+https://git.golem15.com/golem15/vue-journal-layer.git#<tag>`, with the commit locked in `pnpm-lock.yaml`. There is no npm registry and no submodule for the layer. (`git.golem15.com` is Gitea 1.25.4, and a public repo needs no auth.) Local development uses `pnpm link` or a `link:../vue-journal-layer` override. A release is a git tag.
- **D-05:** The layer owns these Journal views and their data access: post list with pagination (successor of `journalPosts`, with sort and page size configurable), single post (successor of `journalPost`: rendered markdown, featured image, meta, related/random posts like `randomPosts`), category and tag listings, the author page (successor of `journalAuthor`), and the RSS feed link. It also owns a typed client for `/_journal/api/v1` (anonymous GET routes only).
- **D-06:** Route paths in the layer must be configurable or overridable by the app, because grzyby uses specific PHP URLs (D-15) that another blog may not want.
#### Render & deploy
- **D-07:** **SSR**: `nuxt build` runs as a Node server, with `routeRules` swr/ISR caching so a newly published post shows up without a rebuild and pages stay full HTML for SEO. Unlike summercms.io (`nuxt generate` embedded in the binary), this site runs two processes: the Go binary and the Nuxt Node server. — **Reversibility:** costly — deploy scripts, supervisor and nginx topology depend on it.
- **D-08:** The Nuxt app is a new repo `vue-grzybyfunkcjonalne-app`, mounted as a **submodule in `sm-grzybyfunkcjonalne-app`**, the same way `vue-summercmsio-app` is mounted in `sm-summercmsio-app`.
- **D-09:** The admin is exposed on the site domain at **`/gfadmin`** (set with `backend.uri: /gfadmin` in the host config; cabana supports this without a framework change). nginx routes `/gfadmin` and `/_journal` (plus media/storage paths) to Go and everything else to Nuxt. PHP used `/backend`; the new path is intentional.
- **D-10:** Deploy target is the **same server as the PHP site**. Staging runs next to the live Winter install. The later cutover switches nginx, and the Winter install stays as the rollback.
#### Non-Journal content
- **D-11:** The homepage marketing content (hero, about, why-us, CTA and the other sitemanager blocks, `content/blocks/*`, `content/homepage`, `content/blog` headers, the kontakt page content) moves **into the Nuxt app repo** as components plus i18n/markdown, with the current Polish copy carried over. The sitemanager `enable_*` toggles are dropped, and the design decides which sections stay. No sitemanager or blocks plugin port.
- **D-12:** The main menu (`g15_menu`) becomes typed config in the app, and the FAQ (`g15_faq` slug `what-mushrooms-can-give-you`) becomes static i18n/markdown content in the app. Both get proper plugins later; todos `menu-plugin-port.md` and `faq-plugin-port.md` were added.
- **D-13:** SEO and sitemap use **`@nuxtjs/seo` in the layer**: per-post meta, OG tags, canonical, robots (honor the per-page `robots_noindex` equivalents), and `sitemap.xml` generated from `/_journal/api/v1` plus the app's static pages. This replaces `eagle_seo` and `Golem15.Sitemap` for this site. `sm-sitemap-plugin` stays on the backlog.
- **D-14:** The contact and newsletter forms are **dropped**. The kontakt page shows contact details and an email link only, and the newsletter stays in backlog 999.1. No new Go endpoints.
#### URLs, i18n, migration, cutover
- **D-15:** **The PHP public URLs stay identical**: `/`, `/blog` (with pagination), `/blog/:post`, `/autor/:author`, `/kontakt`, `/:category` (root-level catch-all, where known static routes take precedence), plus `/404` and `/error` behavior. Research must enumerate the real live URLs (including the pagination query/segment form and `trwaja-prace`) so none are lost. — **Reversibility:** one-way — search-indexed URLs; changing them later needs a 301 map.
- **D-16:** **Polish only, i18n-ready**: `@nuxtjs/i18n` is wired in the layer and the app, the layer ships pl and en UI strings, and the site serves `pl` without a URL prefix. A later blog can enable more locales.
- **D-17:** Content migration uses the Phase 15 **`journal:import-posts`** command fed by a PHP Journal export (the PHP repo already has `journal-export/*.json`), plus copying featured images and media into Go storage. Research must check that the export format matches the importer, and that slugs, authors, categories, tags, published dates and media references survive; it must flag gaps rather than patch over them.
- **D-18:** **The phase stops at staging.** grzybyfunkcjonalne content runs end to end on staging (Go + Nuxt + imported posts) on the PHP site's server, with a smoke check that hits every old URL. The production switch is not a plan in this phase. Success criterion 1 counts as met once the site is served by SummerCMS on staging.
### Claude's Discretion
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan" (the layer gets Vitest or `node --test` tests, and app-local Go code gets Go tests).
- How the layer exposes route configuration (`app.config`, runtime config or pages override) and token names.
- How markdown renders on the Nuxt side (render server-side from the API's markdown or HTML field, whichever the Phase 15 API returns), with sanitization consistent with Journal's XSS rules.
- Exact swr/ISR TTLs, supervisor/nginx config and scripts layout, modeled on `sm-summercmsio-app/deploy` and `scripts/`.
- Whether the refreshed visual direction gets a `/gsd-ui-phase` UI-SPEC before planning (recommended, not required).
- Run the security-review agent only if new Go endpoints or auth surface appear. None are planned.
### Deferred Ideas (OUT OF SCOPE)
- `sm-menu-plugin` port (todo `menu-plugin-port.md`)
- `sm-faq-plugin` port (todo `faq-plugin-port.md`)
- Sitemanager/blocks plugin port for editor-managed homepage sections
- Contact form via a Go endpoint, and the newsletter signup (backlog 999.1)
- English locale for grzybyfunkcjonalne.pl
- Production cutover (nginx switch on the PHP server, Winter kept as rollback): an operational step after the phase
#### Reviewed Todos (not folded)
- `sitemap-plugin-port.md`: superseded for this site by `@nuxtjs/seo` sitemap (D-13); stays on the backlog for a site that needs editor-defined sitemaps.
- `nest-framework-packages-under-modules.md`, `2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md`, `2026-10-07-translate-messages-as-nuxt-i18n-json-editor.md`: keyword matches only, not this phase.
</user_constraints>
<phase_requirements>
## Phase Requirements
No REQUIREMENTS.md IDs are mapped to Phase 16 (ROADMAP `**Requirements**: TBD`). The success criteria are the requirement source:
| ID | Description | Research Support |
|----|-------------|------------------|
| SC-1 | grzybyfunkcjonalne.pl is served by a SummerCMS binary (Journal content, routing, i18n as the site needs); met on staging (D-18) | Deploy topology, nginx/supervisor sketches, migration runbook (default locale `pl` before import), smoke script over the 71 sitemap URLs + static routes |
| SC-2 | Blog views live in a reusable place (`vue-journal-layer`) | Layer structure, `extendPages`-based configurable routes, tokens, i18n merge rules, pnpm git dependency mechanics |
| SC-3 | Every public page PHP Journal + theme rendered has a named Go/Nuxt successor; sitemap if needed | URL inventory table (PHP file → URL → live status → Nuxt route), sitemap via `@nuxtjs/seo` dynamic source |
| SC-4 | New code has unit tests in the last plan | Validation Architecture: `node --test` for layer/app TS utils, Go tests for host config/commands, smoke script |
</phase_requirements>
## Summary
The Phase 15 API is usable for most of the layer, but four facts change the plan. Three are data facts and one is about the API:
1. **The live post URLs use the Polish translation slug, not the export's base slug.** `https://grzybyfunkcjonalne.pl/sitemap.xml` lists 62 post URLs. All 62 match `translations.pl.slug` (or the base slug when no pl translation exists), and 57 of them differ from the base `slug` (e.g. live `/blog/blogbadania-nad-cordycepsem` vs base `badania-nad-cordycepsem`; the base-slug URL returns 404 live). The Go translate plugin seeds `en` as the default locale. The importer writes base fields to the host columns and pl to `golem15_translate_attributes`, and `GET /posts/{slug}` matches only the host `slug` column. So with the seeded locales every live post URL would 404 on Go. **Fix without code:** make `pl` the default locale (admin `makeDefault` action or SQL) **before** running `journal:import-posts`. `SetTranslated` then writes the pl values onto the host columns, so the API serves pl title/content/slug directly. [VERIFIED: live sitemap + export diff; sm-translate-plugin/updates/202610060005_seed_en_pl_locales.go:13-18; translatable.go:192-201; posts_helpers.go:55-60]
2. **The export lacks data the site needs:** no author, no tags, no featured images (the PHP exporter skips attachments), no category names, descriptions or category "pillar" content. Category pages on the live site render about 13 KB of pillar HTML per mushroom (`category.content`). The Go `Category` model has no `content` column, and the API does not return one. Recommend that category pillar content and descriptions move into the Nuxt app as content keyed by category slug. That matches the D-11 treatment of non-Journal content and needs no API change, but it needs user confirmation (A3).
3. **The API cannot serve an author page (D-05) as specified.** No author endpoint exists. The `author` list filter is editor-only. The serialized author is only `{id, first_name, last_name, login}`, with no slug, bio, avatar or subtitle. `sort=random` is not supported, but `related_posts` (latest 3 in the same categories) comes back with every single-post response and is the natural `randomPosts` successor. The author route is not linked anywhere on the live site and is not in the live sitemap. Imported posts also have no author. **Decision needed (Open Question 1).**
4. **Saving an imported post in the admin would destroy its body.** The pl `content` is HTML, not markdown. `classes.FormatHTML` (goldmark without unsafe HTML) turns HTML content into `<!-- raw HTML omitted -->`. The importer is safe because the pl `content_html` translation overwrites the host column afterwards. But editing an imported post in `/gfadmin` would run `FormatHTML` again and wipe the rendered body. [VERIFIED: probe run this session, output pasted under Pitfall 2]
**Primary recommendation:** Build `vue-journal-layer` as a Nuxt 4.4.8 layer:
- Its page components sit outside `app/pages`, and a local module registers them with `extendPages`. Paths come from a `journal.routes` config key the app overrides. Route names stay stable.
- A typed `$fetch` client calls `/_journal/api/v1`: an internal base URL on the server (forwarding `X-Forwarded-For`), same-origin from the browser.
- `@nuxtjs/i18n` 10.4.0 and `@nuxtjs/seo` 5.2.1 run with a dynamic sitemap source.
The site is `vue-grzybyfunkcjonalne-app` with `extends: ['vue-journal-layer']`, the grzyby tokens and static content, and `routeRules: { '/**': { swr: 300 } }`. It runs as `node .output/server/index.mjs` under supervisor next to the Go binary. nginx sends `/gfadmin`, `/_journal` and `/storage/app/**` to Go or disk, and everything else to Nuxt. Migration order: migrate → set pl default → import → fix category names and descriptions → copy media and featured images → smoke every old URL.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Post/category/tag data, drafts, publish gate | API / Backend (Go `sm-journal-plugin`) | Database (Postgres) | Frozen contract (Phase 15 D-14); the layer is read-only |
| Public HTML for every URL (SSR) | Frontend Server (Nuxt Nitro node-server) | — | D-07: full HTML for SEO, swr cache |
| Route paths (`/blog/:post`, `/:category`, `/autor/:author`) | Frontend Server (layer route module + app config) | — | D-06/D-15: URL shape is a site concern, so the Go API stays path-agnostic |
| HTML gate on `content_html` | API (Go `FormatHTML` on admin/API save) | Frontend Server (layer TS port of the same 3 regexes) | Imported translations bypass `FormatHTML`; defense in depth (see Security) |
| sitemap.xml, robots.txt, canonical/OG | Frontend Server (`@nuxtjs/seo`) | API (data source) | D-13 replaces eagle_seo + Golem15.Sitemap |
| RSS | API (Go `/_journal/api/v1/rss`) | Frontend Server (`/rss.xml` alias via nginx rewrite or Nitro proxy) | Live pages advertise `/rss.xml`; Go owns the feed |
| Uploaded files (featured images, media) | CDN / Static (nginx serving `storage/app/**` from disk) | API (Go writes `system_files`) | The Go host mounts no static handler (see Pitfall 7) |
| Admin `/gfadmin` | API / Backend (cabana + boardwalk SPA) | — | D-09 |
| Homepage, kontakt, FAQ, menu, category pillar copy | Frontend Server (site app content) | — | D-11/D-12; A3 for pillar content |
## Project Constraints (from CLAUDE.md)
- Lean planning: few large plans; **present the plan count with one-line scopes and wait for confirmation before writing PLAN.md files** (rule 2). [VERIFIED: summercms.go/CLAUDE.md]
- **Unit tests are always the last plan of the phase**; earlier plans may carry smoke tests only (rule 3).
- Go: standard library first; add a dependency only when the research doc or a phase decision names it; `go vet` and `go test ./...` green at every commit in every Go repo touched (`sm-grzybyfunkcjonalne-app`, and `sm-journal-plugin` only if touched).
- Compiled plugins only; no runtime plugin loading.
- Commits: no co-author tags; one logical change per commit; planning docs and code in separate commits.
- Framework/plugin/layer READMEs never name a consuming application. The `vue-journal-layer` README uses `blog`/`acme` and never "grzybyfunkcjonalne". `modules/*` README updates apply only if `summercms.go` is touched (not planned).
- Core plugin contracts (user, blog, pages, payment) must not break; `sm-journal-plugin` API contract is frozen (Phase 15 D-14, CONTEXT "Out of scope").
- Submodule workflow (`.planning/notes/core-plugins-own-repos.md`): commit inside the submodule and push first, then bump the gitlink in the app repo as a separate commit; never stage submodule files from the app repo.
- Global user rule: no co-authored tags in commits.
## URL Inventory (D-15)
Sources: theme page files [VERIFIED: Read `themes/arsha/pages/*.htm` lines 1-3], Winter router sort [VERIFIED: `vendor/winter/storm/src/Router/Router.php:250-264` "Sorts all the routing rules by static segments, then dynamic"], live probes with curl on 2026-10-09 [VERIFIED: live HTTP], live `sitemap.xml` (71 `<loc>`: `/`, `/blog`, 7 category roots, 62 posts) [VERIFIED: live HTTP].
**Note:** the live server does not run the code in the local PHP repo. It serves theme `pkurg-arsha` (asset paths `/themes/pkurg-arsha/...`), `/kontakt` returns 404 live, and an unknown `/:x` renders a page titled "Kategoria". Its page config matches `sources/grzybyfunkcjonalne.pl-old-golem/themes/arsha` (Golem15.Blog components `blogPosts`/`blogPost`). The URL patterns are identical in both trees. The table uses live status as the ground truth.
| PHP page file | `url =` (verbatim) | Components / behavior | Live status (2026-10-09) | Nuxt successor (route name → path) |
|---|---|---|---|---|
| `category.htm` | `url = "/"` | homepage partial; `journalPostsHomepage` postsPerPage 3; `g15_faq` slug `what-mushrooms-can-give-you` | 200 | app page `index` → `/` |
| `blog.htm` | `url = "/blog"` | `journalPosts` postsPerPage 12, sort `published_at desc` | 200; `/blog?page=2` 200 (title "Artykuły \| Strona 2", canonical keeps `?page=2`); `/blog?page=99` 200 empty; `/blog?page=abc` 200 | layer `journal-index` → `/blog`, `?page=N` |
| `blog-post.htm` | `url = "/blog/:post"` | `journalPost` + `randomPosts` (3, `random`) | 200 for pl slug; 404 for base slug, unknown and unpublished | layer `journal-post` → `/blog/:slug` |
| `index.htm` | `url = "/:category"` | category filter postsPerPage 10; **no category → homepage partial, status 200** (soft 404) | `/reishi` 200, `/reishi?page=2` 200; `/nieistnieje`, `/rss`, `/feed`, `/rss.xml` all 200 (soft) | layer `journal-category` → `/:category`; **404 when the slug is not a category** |
| `autor.htm` | `url = "/autor/:author"` | `journalAuthor` + `journalPosts` (10) | `/autor/admin` 200 ("Admin Person"), canonical broken (`/autor/`); `/author/admin` 404 | layer `journal-author` → `/autor/:author` (see Open Question 1) |
| `kontakt.htm` | `url = "/kontakt"` | `content-kontakt` (2 headings) | **404 live** | app page `kontakt` → `/kontakt` (D-14 contact details) |
| `trwaja-prace.htm` | `url = "/trwaja-prace"` | `<h3>Niebawem</h3>` | 200 | app page `trwaja-prace` → `/trwaja-prace` (keep; static) |
| `404.htm` | `url = "/404"` | error hero | 404 | Nuxt `error.vue` (no explicit route; `/404` falls to 404) |
| `error.htm` | `url = "/error"` | 500 hero | 404 live | Nuxt `error.vue` handles 500; `/error` falls to 404 as live |
**Non-page URLs served by PHP:**
| URL | Source | Live | Successor |
|---|---|---|---|
| `/sitemap.xml` | `plugins/golem15/sitemap/routes.php` (definition: url `/`, all-blog-categories, all-blog-posts) | 200 XML | `@nuxtjs/sitemap` (via `@nuxtjs/seo`) with a dynamic source from the API |
| `/sitemap.xsl` | same | 200 | dropped (cosmetic; the sitemap module ships its own XSL) [ASSUMED] |
| `/robots.txt` | `plugins/golem15/seo/routes.php` | 200, body is the literal `Golem15` (broken) | `@nuxtjs/robots` (via `@nuxtjs/seo`) |
| `/rss.xml` | `<link rel="alternate" ... href="/rss.xml">` in every live page head, but it is the soft-200 catch-all | 200 HTML (wrong) | **New:** nginx `location = /rss.xml` → Go `/_journal/api/v1/rss`, or a Nitro route proxy |
| `/backend` | Winter admin | 200 | `/gfadmin` (D-09); `/backend` → 404 on the Go host (D-09 intentional) |
| `/storage/app/uploads/public/**` | Winter uploads + `thumb_*` | 200 | nginx static from Go host disk |
| `/storage/app/media/**` | media library (`las.webp`, `Artur Wolak.png` referenced by 32 post bodies) | 200 | nginx static from Go host disk |
**Internal link forms found on live pages** (smoke targets): `/blog?page=N`, `/<category>` (7), `/blog/<pl-slug>`. Broken links, which stay broken or get fixed but must not be reproduced: `/blog/<category>?page=N` (category pagination partial bug; live 404) and `/author/` (journalAuthor partial). [VERIFIED: live HTML link extraction; `partials/pagination.htm:1-12`]
**Routing rules for the successor:**
- Static routes (`/blog`, `/kontakt`, `/trwaja-prace`) must beat `/:category`. Vue Router ranks by path specificity, and "The order of the `routes` array doesn't even matter!" [CITED: router.vuejs.org/guide/essentials/route-matching-syntax]. Static-over-param precedence itself is [ASSUMED] and is covered by the URL smoke test.
- Category pagination successor: `/<category>?page=N` (live works). Do not generate `/blog/<category>?page=N`.
- Pagination: `?page=N` query, not a segment. Recommend `page` > `last_page` → 404 and non-numeric → 404, both via `createError({ statusCode: 404, fatal: true })`. This differs from live (soft 200) but is SEO-correct (A5).
- Canonical for paginated lists must keep `?page=N`, as live does. `nuxt-seo-utils` strips queries from canonical except a whitelist; `page` is believed to be in the default whitelist [ASSUMED]. The smoke test asserts it.
## Journal Export vs Importer (D-17)
Export: 105 files in `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/`. 63 are published and 42 are drafts. 100 have a `pl` translation. Keys in every file: `slug, title, content, content_html, excerpt, metadata, published, published_at, categories, translations`. [VERIFIED: python scan this session]
Importer struct [VERIFIED: sm-journal-plugin/console/export_posts.go:53-67], verbatim fields: `Slug`, `Title`, `Content`, `ContentHTML`, `Excerpt`, `Metadata`, `Sources`, `Published`, `PublishedAt`, `Categories []string`, `Translations map[string]map[string]any`, `AuthorEmail string json:"author_email,omitempty"`, `CategoryNames map[string]string json:"category_names,omitempty"`.
| Item | Export has | Importer does | Survives? | Gap / action |
|---|---|---|---|---|
| Slug (live URL) | base `slug` + `translations.pl.slug` (57 differ; live = pl) | host `slug` = base; then `SetTranslated(pl)`; writes host if pl is default (`if locale == defaultCode { ... tx.Model(m).Update(column, ...) }`) | **Only if pl is the default locale before import** | Seed default is `'en', 'English', TRUE` / `'pl', 'Polski', FALSE` [VERIFIED: 202610060005_seed_en_pl_locales.go:13-18]. Set pl default first (admin Locales `makeDefault`, or SQL). |
| Title / excerpt / content / content_html | base (older text; 52 titles, 100 contents differ) + pl (live text) | same mechanism; `stripUnpublishedTitlePrefix` removes `🔒 ` | Yes with pl default | Without pl default the API serves stale base text |
| content format | published posts: base = markdown, pl `content` = **HTML** (61/63) | `FormatHTML(post.Content)` on base, then pl `content_html` overwrites | Yes at import | **Admin re-save destroys body** (Pitfall 2) |
| published / published_at | `"2024-06-24 06:00:00"` style, UTC (PHP `'timezone' => 'UTC'`) | `parsePublishedAt` accepts `"2006-01-02 15:04:05"` | Yes | none |
| Categories | slug list only (`cordyceps, reishi, chaga, shiitake, lions-mane, turkey-tail`; `amanita-muscaria` only on drafts) | creates missing category with `titleFromSlug` → "Lions Mane" (live "Lion's Mane") | Slugs yes; names partly; **no description, no pillar content** | Fix names and descriptions in `/gfadmin` (7 rows) or a seed step; pillar content → app (A3) |
| Authors | **absent** (no `author_email`) | `user_id` stays nil | **No** | Posts render without author; author page empty (Open Question 1) |
| Tags | **absent** (PHP exporter has no tags) | none | n/a | Live site uses no tags; tag route can be disabled for this site |
| Featured images | **absent** (PHP `ExportPosts.php` warns "File attachments ... are NOT included") | none | **No** | Separate migration step (below) |
| Inline media refs | absolute `https://grzybyfunkcjonalne.pl/storage/app/media/Artur%20Wolak.png` (32 occurrences) and `https://gf.uat.golem15.com/storage/app/uploads/public/660/fc7/c2e/660fc7c2e04d0300676113.png` (2) | stored verbatim | Yes if nginx serves `/storage/app/media/` | Copy `storage/app/media/`; the 2 UAT-host refs are already broken (flag, do not fix in this phase) |
| Internal links in bodies | `https://grzybyfunkcjonalne.pl/lions-mane`, `/turkey-tail` (2 each) | verbatim | Yes (category routes kept) | none |
| Re-import idempotency | — | existence check `db.Where("slug = ?", data.Slug)` uses base slug | **Breaks with pl default**: host slug becomes the pl slug, so a `--force` re-run does not find the row, creates a new one, and then hits `golem15_journal_posts_slug_unique` on the pl slug and rolls back the whole transaction | Run import once into a fresh DB; to redo, drop and re-migrate (Pitfall 3) |
| Post IDs | not exported | new IDs | n/a | Featured-image mapping must key on slug |
| Per-post SEO overrides (`golem15_seo_pages`) | not exported | n/a | No | Live post `<title>` = post title + " \| Grzyby Funkcjonalne"; derive from post fields (A6) |
**Featured images: recommended path.** The source of truth is the PHP DB on the server (live `system_files` with `field = 'featured_images'`). The local `storage/app/uploads/public` is unreadable to this user and the local `gf_old.sql` is a 2024 Golem15.Blog dump. Two options:
- **(a, recommended)** An app-local Go command in `sm-grzybyfunkcjonalne-app` (e.g. `grzyby:import-featured-images --map map.json --source <php uploads/public>`). It reads a slug → `{disk_name, file_name, content_type, file_size}` map produced by a one-off SQL query on the PHP DB, copies each original into the Go bucket under the same Winter partition key (`attach.BlobKey(diskName)` = `PartitionDirectory(diskName) + diskName`), and inserts an `attach.File` row (`AttachmentType` = `Golem15\Journal\Models\Post`, `AttachmentID` = Go post id as string, `Field` = `featured_images`). URLs keep their `/storage/app/uploads/public/xxx/yyy/zzz/<disk_name>` shape, so old image URLs keep working. Go code means Go tests (fits SC-4). [VERIFIED: attach/thumb.go:54-90; attach/file.go:26-45; host config/storage.yaml:3-4 `bucket_url: "file://./storage/app/uploads/public"`, `public_path_prefix: "/storage/app/uploads/public"`]
- **(b)** A script that uploads each image through the existing write route `POST /_journal/api/v1/posts/{id}/featured-images` with a backend JWT. No new Go code, but new disk names (old image URLs die), and it needs an admin token.
Recommend (a). Either way the PHP-side query needs server access (human checkpoint).
## Journal API available to the layer (anonymous GET)
[VERIFIED: Read sm-journal-plugin/routes.go:13-20]: verbatim `g.Get("/posts", ...)`, `g.Get("/posts/{slug}", ...)`, `g.Where("slug", `[a-z0-9][a-z0-9\-/]*`)`, `g.Get("/categories", ...)`, `g.Get("/tags", ...)`, `g.Get("/rss", ...)`, group middleware `surf.Use("throttle:journal-public-api")`.
| Need (D-05) | API support | Evidence | Layer approach |
|---|---|---|---|
| List + pagination | `GET /posts?page=&per_page=&sort=&category=&tag=&search=`; envelope `{data, meta{current_page,last_page,per_page,total}}` | posts.go:41-42, 101-112; lagoon/paginate.go:4-15 | Pass `page`, `per_page`; read `meta.last_page` |
| Page size | `defaultPerPage = 9`, `maxPerPage = 30` | posts.go:23-26 | grzyby sizes 12/10/3 fit |
| Sort | columns `published_at`, `created_at`, `updated_at`, `title`, `id` + `asc`/desc; **no `random`** (PHP API.md lists `random`) | posts.go:469-486 | Configurable sort among those values |
| Single post | `GET /posts/{slug}` → `{data: {...post, previous_post, next_post, related_posts}}`; 404 `errPostNotFound` for draft/future | posts.go:119-153 | `related_posts` = `randomPosts` successor |
| Related posts | same category, `Order("published_at DESC").Limit(3)`; **empty when the post has no category** (1 published post has none) | posts_helpers.go:133-153 | Fallback: latest 3 excluding current |
| Post fields | `id, title, slug, excerpt, content, content_html, reading_time, published, published_at, is_pinned, metadata, sources, author, categories[{id,name,slug}], tags[{id,name,slug}], featured_images[{id,url,file_name,file_size}], featured_image_url, translations, created_at, updated_at` | posts.go:547-568 | Render `content_html` (HTML; markdown is not re-rendered client-side) |
| Dates | `"2006-01-02T15:04:05+00:00"` UTC | wire/response.go:41-43 | Format with `Intl.DateTimeFormat('pl-PL')` |
| Category listing | `GET /categories[?include_empty=1]` tree `{id,name,slug,description,parent_id,nest_depth,post_count,children?}`; **empty categories omitted by default** | posts_helpers.go:155-216 | Always pass `include_empty=1` when resolving `/:category` (`amanita-muscaria` has 0 published posts but is in the live sitemap) |
| Category filter | `category=<slug or id>` matches the **host** slug column, includes nested children | posts.go:65-71, 495-516 | OK with pl default |
| Tags | `GET /tags` `{id,name,slug,post_count}`; `tag=` filter | posts.go:176-190, 518-528 | Layer tag route; disabled for grzyby (no tags) |
| Author page | **No endpoint.** `author=` filter only for editors (posts.go:58-64). Author object `{id, first_name, last_name, login}` (posts.go:576-581): no slug/bio/avatar/subtitle | — | **Gap: Open Question 1** |
| RSS | `GET /rss` RSS 2.0; item link `site + "/blog/" + p.Slug`; `site` = config `url`, else `scheme://Host` with `scheme := "http"` unless TLS | rss.go:74, 122-133 | Set `SUMMER_URL=https://<host>` on the Go process or RSS links come out `http://` behind nginx (Pitfall 6) |
| Rate limit | `journal-public-api` 120/min keyed on `surf.ClientIP`; `127.0.0.1/32` is a trusted proxy in host `config/http.yaml:15-16` | plugin.go:69-82; surf/clientip.go:16-46 | SSR fetches must forward `X-Forwarded-For` or all visitors share one 120/min bucket (Pitfall 5) |
## Standard Stack
### Core (layer and site app; versions pinned as in `vue-summercmsio-app/package.json`)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| nuxt | 4.4.8 (published 2026-06-08) | SSR framework, layers, Nitro node-server | Project standard (D-01) [VERIFIED: vue-summercmsio-app/package.json; npm registry] |
| vue | 3.5.35 | UI runtime | Pinned with Nuxt in the summercms.io app |
| @nuxtjs/i18n | 10.4.0 | pl/en UI strings, `prefix_except_default` | D-16 [VERIFIED: package.json] |
| @nuxtjs/seo | 5.2.1 | bundles `@nuxtjs/sitemap` 8.6.1, `@nuxtjs/robots` 6.2.4, `nuxt-seo-utils` 8.6.1, `nuxt-schema-org`, `nuxt-og-image`, `nuxt-link-checker`, `nuxt-site-config` | D-13 [VERIFIED: node_modules/@nuxtjs/seo/package.json dependencies] |
| @nuxt/fonts | 0.14.0 | self-hosted fonts (grzyby used Open Sans + Jost + Poppins from Google) | D-01 |
| typescript | 5.9.3 (dev) | types | Pinned |
| Node | 22.22.0 (`.nvmrc`), `engines >=22.18` | runs `node --test` on `.ts` and the Nitro server | [VERIFIED: vue-summercmsio-app/.nvmrc, package.json] |
| pnpm | 11.3.0 locally; lockfile v9 | install, git dependency | [VERIFIED: `pnpm --version`] |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `nuxt/kit` (re-export of `@nuxt/kit`, ships inside `nuxt`) | 4.4.8 | `defineNuxtModule`, `extendPages`, `createResolver`, `addServerHandler` in the layer's local module | Always (no new dependency) [VERIFIED: node_modules/nuxt/kit.d.ts `export * from '@nuxt/kit'`; @nuxt/kit index.d.mts:301 `declare function extendPages(...)`] |
| `sanitize-html` | 2.17.7 (pin; avoid 2.18.0, 9 days old) | full HTML sanitizer | **Only if** the user rejects the "port Go's 3-regex gate" recommendation (A4). `[ASSUMED]` name source: training knowledge. [WARNING: flagged as suspicious (`too-new` on latest) — verify before using.] |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Page components + `extendPages` from configurable paths | Layer `app/pages/blog/[slug].vue` overridden by app files | Overriding by filename cannot move `/blog/:post` to `/posts/:post` without copying page files, which fails D-06 |
| Direct `$fetch` to Go (internal URL on the server, same-origin in the browser) | A Nitro BFF (`server/api/journal/**`) | A BFF adds a hop and caching layer; swr page caching already covers load; the BFF stays an option if sanitization moves server-only |
| nginx serving `/storage/app/**` from disk | Mount `attach.StaticHandlerPublic` in an app-local plugin | The handler exists (attach/static.go:26-36) but no host mounts it; nginx static is the Winter precedent and needs no Go code |
| `@nuxt/image` for resizing | Use the original `featured_image_url` with `loading="lazy"` and explicit sizes | Go has no public thumb route; `@nuxt/image` is a new dependency; defer |
**Installation (layer `package.json`):** dependencies `@nuxtjs/i18n@10.4.0`, `@nuxtjs/seo@5.2.1`, `@nuxt/fonts@0.14.0`; devDependencies `nuxt@4.4.8`, `vue@3.5.35`, `typescript@5.9.3`. Per the Nuxt docs, anything the layer imports must be in its `dependencies`, and `nuxt` itself goes in `devDependencies` [CITED: nuxt.com/docs/4.x/guide/going-further/layers].
**Site app `package.json`:**
```json
{
"dependencies": {
"nuxt": "4.4.8",
"vue": "3.5.35",
"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0"
}
}
```
Do not list `@nuxtjs/i18n`/`@nuxtjs/seo` in the app at different versions; if listed, pin them identically to the layer (Pitfall 4).
## Package Legitimacy Audit
| Package | Registry | Age (pinned version) | Downloads | Source Repo | Verdict | Disposition |
|---------|----------|-----|-----------|-------------|---------|-------------|
| nuxt | npm | 4.4.8: 4 mo | 2.3M/wk | github.com/nuxt/nuxt | SUS (`too-new`, refers to latest 4.6.0) | Approved: pinned 4.4.8 is already locked in `vue-summercmsio-app/pnpm-lock.yaml` |
| @nuxtjs/i18n | npm | 10.4.0 | 735k/wk | github.com/nuxt-modules/i18n | OK | Approved |
| @nuxtjs/seo | npm | 5.2.1 | 120k/wk | github.com/harlan-zw/nuxt-seo | SUS (`too-new` on latest) | Approved: 5.2.1 already locked in the summercms.io app |
| @nuxt/fonts | npm | 0.14.0 | (in use) | github.com/nuxt/fonts | not re-run | Approved: already locked in the summercms.io app |
| sanitize-html | npm | 2.17.7: 2 mo | 11.6M/wk | github.com/apostrophecms/apostrophe | SUS (`too-new` on 2.18.0) | **Conditional**: only if A4 rejected; planner adds `checkpoint:human-verify` before install |
Postinstall: `sanitize-html` `postinstall: null` [VERIFIED: seam signals]. No new Go dependencies are proposed.
**Packages removed due to [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** `sanitize-html` (conditional); nuxt/@nuxtjs/seo SUS flags apply to latest tags, not the pinned versions already in the lockfile.
## Architecture Patterns
### System Architecture Diagram
```
Browser ──HTTPS──> nginx (staging vhost on the PHP server)
│
├─ /gfadmin/**, /_journal/** ──> Go binary (grzybyfunkcjonalne serve, 127.0.0.1:PORT_GO)
│ ├─ cabana admin SPA (/gfadmin)
│ └─ Journal API ──> Postgres
├─ = /rss.xml (rewrite) ──> Go /_journal/api/v1/rss
├─ /storage/app/uploads/public/**,
│ /storage/app/media/** ──> disk (Go host storage/app/...), images only, nosniff
└─ everything else ──> Nuxt Nitro (node .output/server/index.mjs, 127.0.0.1:PORT_NUXT)
├─ routeRules swr cache hit? ──yes──> cached HTML
└─ miss: SSR page (layer route module decides page by path)
├─ $fetch ${journal.apiBase} (internal http://127.0.0.1:PORT_GO/_journal/api/v1,
│ forwards X-Forwarded-For) ──> Go Journal API
├─ 404 from API / unknown category / page out of range ──> error.vue (404)
└─ HTML + payload (content_html after TS html gate)
Browser hydration / client navigation ──> same-origin /_journal/api/v1 (nginx ──> Go)
/sitemap.xml ──> Nuxt @nuxtjs/sitemap ──> layer server source ──> Go /posts (paged) + /categories?include_empty=1
```
### Recommended Project Structure
```
vue-journal-layer/ # new repo; neutral, never names a site
├── nuxt.config.ts # modules: ['@nuxtjs/i18n','@nuxtjs/seo', resolve('./modules/journal')]
│ # i18n locales pl/en (langDir via import.meta.url-resolved path), css tokens
├── modules/journal.ts # configKey 'journal': routes, perPage, apiBase; extendPages + sitemap source
├── app/
│ ├── app.config.ts # UI defaults (date format, related count, show author/reading time)
│ ├── assets/css/journal-tokens.css # --journal-color-*, --journal-font-*, --journal-space-*, --journal-radius-*
│ ├── assets/css/journal-base.css # neutral styles using only tokens
│ ├── components/Journal/ # PostCard, PostList, Pagination, PostBody, PostMeta, FeaturedImage,
│ │ # RelatedPosts, CategoryHeader, AuthorCard, RssLink (overridable by name)
│ ├── composables/ # useJournalApi (typed client), useJournalLinks (route-name based URLs), useJournalPage
│ ├── journal-pages/ # NOT app/pages: PostIndexPage, PostPage, CategoryPage, TagPage, AuthorPage
│ └── utils/ # pure TS, node --test friendly: types.ts, client.ts, query.ts,
│ # pagination.ts, htmlGate.ts, routes.ts (path config validation)
├── server/api/__sitemap__/journal.ts # defineSitemapEventHandler -> posts + categories
├── i18n/locales/{pl,en}.json # journal.* UI strings
├── tests/*.test.ts # node --test
└── README.md # neutral (acme/blog)
sm-grzybyfunkcjonalne-app/ # existing Go host
├── config/backend.yaml # uri: /gfadmin
├── vue-grzybyfunkcjonalne-app/ # new submodule (D-08)
│ ├── nuxt.config.ts # extends ['vue-journal-layer'], journal.routes, routeRules, site, i18n pl default
│ ├── app/assets/css/tokens.css # grzyby token overrides (refreshed palette: #0D705D/#27AE60/#138C74 family)
│ ├── app/pages/{index,kontakt,trwaja-prace}.vue
│ ├── app/components/ # Hero, About, Cta, Services, Faq, SiteHeader(menu), SiteFooter, Journal/* overrides
│ ├── app/data/ # menu.ts (typed), faq.pl.json/md, categories/<slug>.md (pillar content, A3)
│ ├── app/error.vue
│ ├── i18n/locales/pl.json # site copy
│ ├── public/ # las.webp, cta/blog images, logo, favicon
│ └── tests/*.test.ts
├── deploy/{nginx,supervisor,env.example}
└── scripts/{build.sh,smoke.sh,check-deploy.sh,urls.txt}
```
### Pattern 1: Configurable routes via a layer module (D-06)
**What:** Page components live outside `app/pages`. A local module registers them with `extendPages`, using paths from the `journal.routes` config that the app overrides (defu merge, project wins). Links are built by route **name**, so changing a path never breaks links.
**When:** Always, for every Journal view.
**i18n compatibility:** Nuxt 4.4.8 defaults `experimental.scanPageMeta` to `"after-resolve"` [VERIFIED: @nuxt/schema@4.4.8 dist/index.mjs:618-620 `return typeof val === "boolean" || val === "after-resolve" ? val : "after-resolve";`]. @nuxtjs/i18n 10.4.0 then localizes in `pages:resolved` [VERIFIED: @nuxtjs/i18n dist/module.mjs:682-683 `nuxt.options.experimental.scanPageMeta === "after-resolve" ? "pages:resolved" : "pages:extend"`], which runs after every `pages:extend`, so routes added by the module are localized whatever the module order. Route names gain a `___pl` suffix, so links must go through `useLocalePath()`/`useLocaleRoute()` [ASSUMED: suffix format].
```ts
// vue-journal-layer/modules/journal.ts (sketch; option names are recommendations)
import { defineNuxtModule, createResolver, extendPages } from 'nuxt/kit'
export interface JournalRoutes {
index: string | false // e.g. '/blog'
post: string | false // e.g. '/blog/:slug'
category: string | false // e.g. '/blog/category/:category' (neutral default)
tag: string | false // e.g. '/blog/tag/:tag'
author: string | false // e.g. '/blog/author/:author'
}
export default defineNuxtModule<{ routes: JournalRoutes }>({
meta: { name: 'journal', configKey: 'journal' },
defaults: {
routes: {
index: '/blog', post: '/blog/:slug', category: '/blog/category/:category',
tag: '/blog/tag/:tag', author: '/blog/author/:author',
},
},
setup(options) {
const { resolve } = createResolver(import.meta.url)
const pages: Array<[keyof JournalRoutes, string]> = [
['index', 'PostIndexPage'], ['post', 'PostPage'], ['category', 'CategoryPage'],
['tag', 'TagPage'], ['author', 'AuthorPage'],
]
extendPages((routes) => {
for (const [key, file] of pages) {
const path = options.routes[key]
if (path === false) continue
routes.push({ name: `journal-${key}`, path, file: resolve(`../app/journal-pages/${file}.vue`) })
}
})
},
})
```
```ts
// vue-grzybyfunkcjonalne-app/nuxt.config.ts (excerpt)
export default defineNuxtConfig({
extends: ['vue-journal-layer'],
journal: {
routes: { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false },
},
routeRules: {
'/**': { swr: 300 },
'/blog/**': { swr: 120 },
},
})
```
### Pattern 2: Typed client with an internal server base and XFF forwarding
**What:** `runtimeConfig.journal.apiBase` (server-only, e.g. `http://127.0.0.1:8096/_journal/api/v1`, overridable with `NUXT_JOURNAL_API_BASE`) and `runtimeConfig.public.journal.apiBase` (default `/_journal/api/v1`, same origin). On the server, forward the visitor's `x-forwarded-for` (Go trusts `127.0.0.1/32` and reads the rightmost untrusted XFF hop). Runtime config is overridden by `NUXT_`-prefixed env vars only for keys declared in `nuxt.config` [CITED: nuxt.com/docs/4.x/guide/going-further/runtime-config].
```ts
// app/composables/useJournalApi.ts (sketch)
export function useJournalApi() {
const cfg = useRuntimeConfig()
const base = import.meta.server ? cfg.journal.apiBase : cfg.public.journal.apiBase
const fwd = import.meta.server ? useRequestHeaders(['x-forwarded-for']) : {}
return <T>(path: string, query?: Record<string, unknown>) =>
$fetch<T>(path, { baseURL: base, query, headers: fwd })
}
```
### Pattern 3: Category route as the root catch-all with a real 404
**What:** The category page resolves `:category` against `GET /categories?include_empty=1` (flattened tree), throws `createError({ statusCode: 404, fatal: true })` when absent, then lists `GET /posts?category=<slug>&per_page=10&page=N`. The site app injects pillar content per slug (A3) through a slot or override component.
**Why:** Live soft-200s every unknown root path (`/rss`, `/feed`, `/nieistnieje` render a "Kategoria" page). The successor must not.
### Pattern 4: Token theming (D-03)
The layer defines every visual value as `--journal-*` custom properties in `:root` and uses only those tokens. The app overrides them in its own CSS, which loads after the layer CSS because the project's `css` array is appended after the layer's (defu array merge) [ASSUMED: CSS order; verify in the Wave 0 spike]. To be safe, scope app overrides with a selector of equal or higher specificity (`:root:root`) or put them on `html`.
### Pattern 5: Sitemap source from the API (D-13)
```ts
// vue-journal-layer/server/api/__sitemap__/journal.ts (sketch)
export default defineSitemapEventHandler(async () => {
// page through /posts?per_page=30 until meta.last_page; add categories from /categories?include_empty=1
// return [{ loc: '/blog/<slug>', lastmod: post.updated_at }, { loc: '/<category>' }, ...]
})
```
with `sitemap: { sources: ['/api/__sitemap__/journal'] }`. Each entry needs `loc`; `lastmod/changefreq/priority/_sitemap` are optional. The resolved sitemap is cached `cacheMaxAgeSeconds` (default 10 min) in production and not cached in dev [CITED: nuxtseo.com/docs/sitemap/guides/dynamic-urls]. Build each `loc` from the route config (the same `journal.routes`), not hard-coded `/blog/`.
### Anti-Patterns to Avoid
- **Layer pages in `app/pages/`:** they get fixed paths and collide with the app's routes, which breaks D-06.
- **`~/` or `@/` imports inside layer files:** these resolve to the consuming project. Use relative imports or a `#layers/<name>` alias with `$meta.name` [CITED: nuxt.com layers doc].
- **Relative paths in the layer `nuxt.config` (css, langDir):** they resolve against the project. Build them from `fileURLToPath(new URL('.', import.meta.url))` [CITED: nuxt.com layers doc].
- **Calling Go's public URL from SSR:** it hairpins through nginx/TLS. Use the internal base.
- **`isr` route rules on node-server:** CDN-only semantics (Netlify/Vercel); use `swr: <seconds>` [CITED: nuxt.com/docs/4.x/guide/concepts/rendering].
- **Hard-coding `/blog/` in the layer:** RSS in Go hard-codes it (fine for this site), but the layer must use route names.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| sitemap.xml | XML writer | `@nuxtjs/sitemap` (via `@nuxtjs/seo`) dynamic source | chunking, lastmod, i18n, caching |
| robots.txt / meta robots | static file | `@nuxtjs/robots` + `useRobotsRule`/route rules | per-route `noindex` (D-13 `robots_noindex`) |
| canonical/OG/twitter tags | manual `useHead` | `useSeoMeta` + `nuxt-seo-utils` site config | absolute URLs from `site.url` |
| SSR response cache | custom LRU | `routeRules` `swr` (Nitro cache) | built-in; per-route TTL |
| Route localization | manual prefixing | `@nuxtjs/i18n` `prefix_except_default` | future locales (D-16) |
| Image partition paths | custom path math | `attach.PartitionDirectory`/`BlobKey` (Go) | Winter-exact layout [VERIFIED: attach/thumb.go:54-69] |
| Admin auth for any write | new token scheme | existing cabana backend JWT | Phase 15 D-15 |
| Font hosting | Google Fonts `<link>` | `@nuxt/fonts` | privacy, same as summercms.io |
**Key insight:** The only new logic this phase owns is the URL mapping, the content port and the migration. Everything SEO- or cache-related is a configured module.
## Runtime State Inventory (content migration)
| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | PHP DB on the server (posts, translations, `system_files` featured images, categories with pillar `content`); local `gf_old.sql` is a 2024 Golem15.Blog dump (7 categories with 4-14 KB `content`, FAQ with 16 entries, sitemanager settings) | Data migration: import posts (pl default first); featured-image migration (needs server SQL); category names/descriptions; pillar content → app files (A3) |
| Live service config | Live sitemanager settings (hero/about/cta/services/footer copy) live in the PHP DB, not in git; repo `content/blocks/*` are `*.en.htm` arsha demo copy | Copy the **current** Polish copy from the live HTML (authority) into app i18n/markdown; `gf_old.sql` is a fallback |
| OS-registered state | Staging: new supervisor programs (Go + Nuxt), new nginx vhost; the PHP site's own nginx/php-fpm stay untouched | New config files only (D-10) |
| Secrets/env vars | `SUMMER_DATABASE__DSN`, `SUMMER_APP__KEY`, `SUMMER_ADMIN__JWT__SECRET`, `SUMMER_GOLEM15__USER__JWT__SECRET` (pattern from summercms.io `deploy/env.example`), plus `SUMMER_URL` (RSS origin) and `NUXT_JOURNAL_API_BASE` | New `.env` on the server; never committed |
| Build artifacts | `.output/` (Nuxt server bundle) and `bin/grzybyfunkcjonalne`; Google Analytics `G-MLSV7WJ6W4` in the live layout | Decide analytics successor (Open Question 5) |
## Content Inventory (D-11, D-12)
Sizes measured on live HTML and on `gf_old.sql` sitemanager settings [VERIFIED: this session]:
| Block | Live? | Source | Size |
|---|---|---|---|
| Hero | yes | title "Grzyby Funkcjonalne", text "Serwis informacyjny poświęcony grzybom funkcjonalnym", CTA "Czytam", bg `/las.webp` (full-size, darken 50%) | tiny |
| Latest posts (3) + "Czytaj więcej" → `/blog` | yes | Journal | layer component |
| About ("Czym są grzyby funkcjonalne?") | yes | 2 rich-text columns (574 + 780 chars), button "Poznaj naszą misję" | ~1.2 KB text |
| CTA ("Skorzystaj z wiedzy ekspertów") | yes | text + image `65f9f2e96287d085855960.png` | tiny |
| Services ("Dlaczego warto?") | yes | 4 icon boxes (~400 chars each) | ~1.6 KB |
| FAQ (`what-mushrooms-can-give-you`) | yes | intro + 16 Q&A (HTML answers, 200-700 chars) | ~5.7 KB text |
| Footer | yes | name, address "Wróblewskiego, 1A/1U, Namysłów", phone 666-681-225, email kontakt@grzybyfunkcjonalne.pl, links (Artykuły blogowe; Golem15, Ciemność.com, Dobra bomba), social (x.com/ophiel118), copyright | small |
| Menu (`g15_menu` main) | yes | "Blog" → `/blog`; dropdown "Grzyby Funkcjonalne" listing the 7 categories (type `blog_categories`) | typed config; category list can come from `/categories?include_empty=1` |
| Blog page header | yes | hero with blog image (`65f9f2e96287d085855960.png`), title/description from page meta ("Artykuły", "Przeczytaj nasze artykuły na temat grzybów funkcjonalnych") | tiny |
| Category pillar content | yes | 6 categories × 12-14 KB HTML (amanita empty) + 7 short descriptions (53-85 chars) | **~80 KB**: largest content item |
| Kontakt | live 404 | repo: "Kontakt" / "Skontaktuj Się z Ekspertami od Grzybów Funkcjonalnych" + footer details | small |
| Disabled blocks (clients, portfolio, pricing, team, whyus, newsletter, contact) | no | arsha demo copy | **drop** |
Theme visual baseline: Bootstrap 5 arsha, fonts Open Sans/Jost/Poppins. Colours from sitemanager: main `#000000`, secondary `#0D705D`, accent `#27AE60`, text `#444444`, link `#138C74`, link hover `#1AAC8F`, background `#FFFFFF` [VERIFIED: gf_old.sql sitemanager JSON; may differ from live]. The refresh means a new design using these as seeds, not a Bootstrap port. A UI-SPEC (`/msd-ui-phase`) before planning is recommended.
## Common Pitfalls
### Pitfall 1: Importing with `en` as the default locale
**What goes wrong:** Every live post URL (`/blog/blog...`) 404s; the API serves stale base titles and markdown.
**Why:** The seeded default is `en` (`SELECT 'en', 'English', TRUE, TRUE, 1`). `GET /posts/{slug}` uses `q.Where("slug = ?", slug)` on the host column only.
**How to avoid:** After `migrate`, set `pl` as default (admin Settings → Translate → Locales → make default, or `UPDATE golem15_translate_locales SET is_default = (code = 'pl')`), **then** import. Optionally disable `en`.
**Warning signs:** `GET /_journal/api/v1/posts/blogbadania-nad-cordycepsem` → 404 after import.
### Pitfall 2: Editing an imported post in `/gfadmin` wipes its body
**What goes wrong:** `content_html` becomes `<!-- raw HTML omitted -->`.
**Why:** The pl `content` is HTML. `FormatHTML` uses goldmark without `html.WithUnsafe()` [VERIFIED: format_html.go:15-18]. Falsification probe run this session on `10-faktow-na-temat-cordycepsu.json` pl content:
```
zz_probe_test.go:20: pl HTML content -> err=<nil> out="<!-- raw HTML omitted -->\n"
```
**How to avoid in this phase:** Document "do not edit imported posts in the admin until fixed". Record a follow-up todo against `sm-journal-plugin` (convert HTML content to markdown at import, or pass raw HTML through); do not patch it here (D-17).
**Warning signs:** Post page shows an empty body after an admin save.
### Pitfall 3: `journal:import-posts --force` is not idempotent once pl is default
**What goes wrong:** The second run looks up the base slug, misses the row (host now has the pl slug), creates a duplicate, then violates `golem15_journal_posts_slug_unique` and the whole transaction rolls back.
**How to avoid:** Import once into a freshly migrated DB. To redo: drop the database, `migrate`, set pl default, import again. Put this in the runbook.
### Pitfall 4: Duplicate Nuxt modules from the layer and the app
**What goes wrong:** Two `@nuxtjs/i18n` copies at different versions, so locale state is split or the build warns.
**How to avoid:** Modules are declared once, in the layer `nuxt.config`. The app either omits them or pins the same exact versions. Keep the lazy-load locale format identical across layers ("Mixing locale configuration such as lazy loading objects and strings may not work as expected") [CITED: i18n.nuxtjs.org/docs/guide/layers].
### Pitfall 5: All SSR traffic shares one rate-limit bucket
**What goes wrong:** Cache misses from many visitors all come from 127.0.0.1, exceed 120/min, and Go answers 429. Pages then error.
**How to avoid:** Forward `x-forwarded-for` from the incoming request in the SSR fetch (Pattern 2); keep swr TTLs ≥ 60 s; the sitemap source pages at `per_page=30`.
### Pitfall 6: RSS links come out `http://` and with the wrong host
**Why:** `requestOrigin` uses config `url`, else `"http"` + `r.Host` (TLS is terminated at nginx) [VERIFIED: rss.go:122-133].
**How to avoid:** Set `SUMMER_URL=https://<staging host>` in the Go `.env` (compass maps `SUMMER_URL` to the top-level `url` key [VERIFIED: compass README line 96 mapping rule]), and `proxy_set_header Host $host`.
### Pitfall 7: Nothing serves `/storage/app/**`
**Why:** The framework has `attach.StaticHandler`/`StaticHandlerPublic` but no host mounts them [VERIFIED: grep usage; attach/static.go:26-36]. The static.go comment warns: "Do not mount the ungated handler on the app origin — same-origin Content-Type from uploads is XSS-relevant."
**How to avoid:** nginx `location ^~ /storage/app/uploads/public/` and `/storage/app/media/` aliasing the Go host's storage dirs. Allow only image extensions (`jpe?g|png|gif|webp`), set `add_header X-Content-Type-Options nosniff`, `expires 30d`.
### Pitfall 8: Category pages for empty categories 404
**Why:** `/categories` omits categories with 0 published posts unless `include_empty=1`. `amanita-muscaria` (in the live sitemap) has 0 published posts.
**How to avoid:** Always `include_empty=1` when resolving a slug and building the sitemap.
### Pitfall 9: Layer CSS/i18n paths resolved against the app
**How to avoid:** In the layer config use `const currentDir = dirname(fileURLToPath(import.meta.url))` and absolute `css`/`langDir` paths [CITED: nuxt.com layers doc]. Test by building the site app against the layer installed from git, not only via `link:`.
### Pitfall 10: pnpm git dependency from Gitea
**What goes wrong:** pnpm cannot resolve a tag on a non-GitHub host, the lockfile pins a branch, or a `prepare` script never runs.
**How to avoid:** The layer ships source and has no `prepare`/build script ("A Git-hosted dependency that ships sources rather than a built package has to be prepared": avoid needing it) [CITED: pnpm.io/package-sources]. Use the full form `git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0`. Wave 0 spike: install it in a scratch app and run `nuxt build` [ASSUMED: works against Gitea 1.25.4 over anonymous HTTPS].
### Pitfall 11: Repos do not exist yet
`https://git.golem15.com/golem15/vue-journal-layer` and `/vue-grzybyfunkcjonalne-app` return 404 [VERIFIED: curl this session]. Creating them on Gitea is a human step (credentials); plan a checkpoint.
### Pitfall 12: Node and Postgres availability on the PHP server
The PHP server runs Winter (likely MySQL/MariaDB per `gf_old.sql`). The Go host needs Postgres (database.yaml asks for a pl-PL ICU database), and Nuxt needs Node ≥ 22.18. Neither is verified on that server (Open Question 4). The ICU template note in host `config/database.yaml`: `CREATE DATABASE grzybyfunkcjonalne TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL';` requires Postgres ≥ 15.
## Code Examples
### nginx staging vhost (sketch; adapt from `sm-summercmsio-app/deploy/nginx/summercms.io.conf`)
```nginx
# Go: admin, Journal API, RSS alias
location ^~ /gfadmin { proxy_pass http://127.0.0.1:8096; include /etc/nginx/proxy_params; }
location ^~ /_journal/ { proxy_pass http://127.0.0.1:8096; include /etc/nginx/proxy_params; }
location = /rss.xml { proxy_pass http://127.0.0.1:8096/_journal/api/v1/rss; include /etc/nginx/proxy_params; }
location ^~ /backend { return 404; } # D-09: old admin path is not served by the new stack
# Uploads and media from disk, images only
location ~ ^/storage/app/(uploads/public|media)/.+\.(?:jpe?g|png|gif|webp)$ {
root /srv/grzybyfunkcjonalne-go; # holds storage/app/...
add_header X-Content-Type-Options nosniff;
expires 30d;
}
location ^~ /storage/ { return 404; }
# Everything else: Nuxt SSR
location / {
proxy_pass http://127.0.0.1:3016;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Ports and paths are placeholders [ASSUMED]. `proxy_params` may not exist on the server; inline the headers as summercms.io does.
### supervisor: two programs (adapt `deploy/supervisor/summercms-io.conf`)
```ini
[program:grzyby-go]
command=/srv/grzybyfunkcjonalne-go/bin/grzybyfunkcjonalne serve --addr 127.0.0.1:8096
directory=/srv/grzybyfunkcjonalne-go
environment=SUMMER_ENV="production"
stopsignal=TERM
stopwaitsecs=15
[program:grzyby-nuxt]
command=/usr/bin/node /srv/grzybyfunkcjonalne-go/nuxt/.output/server/index.mjs
directory=/srv/grzybyfunkcjonalne-go/nuxt
environment=NODE_ENV="production",NITRO_HOST="127.0.0.1",NITRO_PORT="3016",NUXT_JOURNAL_API_BASE="http://127.0.0.1:8096/_journal/api/v1"
stopsignal=TERM
```
`NODE_ENV=production node .output/server/index.mjs`; `NITRO_PORT`/`PORT` and `NITRO_HOST`/`HOST` (default 0.0.0.0, so bind to loopback explicitly) [CITED: nuxt.com/docs/4.x/getting-started/deployment].
### HTML gate (TS port of Journal's rules, layer `utils/htmlGate.ts`)
```ts
// Same three patterns as sm-journal-plugin classes/format_html.go:20-22 (verbatim regex sources)
const UNSAFE_TAG = /<(?:script|iframe|object|embed)\b/i
const EVENT_HANDLER = /\son[a-z]+\s*=/i
const DANGEROUS_URL = /(?:javascript|vbscript|data):/i
export function isSafeJournalHtml(html: string): boolean {
return !(UNSAFE_TAG.test(html) || EVENT_HANDLER.test(html) || DANGEROUS_URL.test(html))
}
```
The scan this session found 0 matches in all export `content`/`content_html` (base and pl), so the live corpus passes [VERIFIED]. Note that `DANGEROUS_URL` also matches the plain-text Polish word "data:" ("date:"); an unsafe result should render a neutral notice, not throw.
### Migration runbook (order matters)
1. `bin/grzybyfunkcjonalne migrate` (fresh Postgres DB, pl-PL ICU).
2. Create the admin user (cabana runtime command) and log in at `/gfadmin`.
3. Make `pl` the default locale (Locales → make default) [Pitfall 1].
4. `bin/grzybyfunkcjonalne journal:import-posts --path journal-export --dry-run`, then without `--dry-run` (once).
5. Fix the 7 categories (names e.g. "Lion's Mane", descriptions from live hero text).
6. rsync PHP `storage/app/media/` → Go `storage/app/media/`; run the featured-image migration (option a/b).
7. Smoke every URL in `scripts/urls.txt` (live sitemap's 71 + `/kontakt`, `/trwaja-prace`, `/rss.xml`, `/robots.txt`, `/sitemap.xml`, `/blog?page=2..6`, `/<cat>?page=2`, negative cases).
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Winter CMS pages + Twig components | Nuxt 4 SSR layer + JSON API | this phase | URL logic moves to Nuxt |
| eagle_seo + Golem15.Sitemap | `@nuxtjs/seo` 5 (sitemap 8, robots 6) | D-13 | runtime sitemap from API |
| `isr` for "rebuild-free" pages | `swr` on node-server (`isr` = CDN-only) | Nuxt 3+ hybrid rendering | use `swr` TTLs |
| Pages in layer `pages/` | `extendPages` from module config | — | configurable paths |
**Deprecated/outdated:** sitemanager `enable_*` toggles (dropped, D-11); arsha demo blocks (drop); `/backend` path (→ `/gfadmin`).
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | pnpm 11 installs `git+https://git.golem15.com/...git#tag` from Gitea anonymously and Nuxt `extends: ['vue-journal-layer']` resolves it from node_modules | Stack, Pitfall 10 | Distribution model (D-04) needs a fallback (tarball URL / `link:` / submodule); Wave 0 spike |
| A2 | Vue Router ranks static `/blog`, `/kontakt` above `/:category` | URL Inventory | Static pages shadowed; caught by smoke test |
| A3 | Category pillar content and descriptions belong in the site app (not a Journal API change) | Summary, Content | If the user wants them editable in admin, an `sm-journal-plugin` change is needed (breaks frozen-API scope) |
| A4 | Porting Go's 3-regex gate is enough sanitization (no `sanitize-html`) | Security, Code Examples | If stricter sanitizing is wanted, add `sanitize-html` 2.17.7 behind a checkpoint |
| A5 | Out-of-range/non-numeric `?page=` → 404 (live returns 200) | URL Inventory | Minor SEO/UX difference; confirm |
| A6 | Post `<title>` = `<post title> \| Grzyby Funkcjonalne`, description = excerpt, og:image = featured image (no per-post SEO override migration) | Export table | Lost custom SEO titles if eagle_seo overrides exist for posts |
| A7 | `nuxt-seo-utils` keeps `page` in canonical query whitelist by default | URL Inventory | Paginated canonicals collapse to page 1; set the whitelist explicitly |
| A8 | Project CSS loads after layer CSS so token overrides win | Pattern 4 | Tokens not overridden; use higher-specificity selector |
| A9 | i18n route-name suffix is `___pl` | Pattern 1 | Links by name fail; use `localePath` regardless |
| A10 | `/sitemap.xsl` can be dropped | URL Inventory | Cosmetic only |
| A11 | nginx/supervisor ports and paths in examples | Code Examples | Placeholders; fix on server |
## Open Questions
1. **Author page (D-05) has no API support.**
- What we know: no author endpoint; the `author` filter is editor-only; the author payload has no slug/bio/avatar; imported posts have no author; `/autor/admin` is the only live author URL, unlinked and not in the sitemap.
- Options: (a) layer author page backed by a Nitro route that pages `/posts` and filters by `author.login`, with bio/avatar from app config (no Go change); (b) additive `sm-journal-plugin` change (anonymous `author=<slug>` filter + `author.slug` in payload), which reopens the frozen API, so it needs the user's explicit OK; (c) ship the route but 404 until authors exist, keeping `/autor/:author` reserved.
- Recommendation: (a) for the layer, plus assigning authors in `/gfadmin` after import if the user wants author pages populated.
2. **Category pillar content location** (A3): app markdown per slug (recommended) or a Journal schema change?
3. **Featured images**: who runs the SQL on the PHP DB to produce the slug → disk_name map, and are live images under `Golem15\Blog\Models\Post` or `Golem15\Journal\Models\Post` morphs on the server? Option (a) Go command vs (b) API upload.
4. **Server environment**: hostname, staging domain (e.g. `staging.grzybyfunkcjonalne.pl`?) and TLS, Postgres ≥15 with ICU, Node ≥22.18, supervisor and nginx layout on the PHP server. These must be confirmed before the deploy plan.
5. **Analytics**: the live layout loads GA `G-MLSV7WJ6W4`. Carry it over (needs a consent decision), or drop it on staging?
6. **Draft posts and `amanita-muscaria`**: 42 drafts are imported as drafts; `/amanita-muscaria` shows 0 posts live. Keep it (it is in the sitemap) or drop it?
## Environment Availability
| Dependency | Required By | Available (this machine) | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Go | host build/tests | ✓ | go1.27.0 | — |
| Node | Nuxt build, `node --test` | ✓ | v24.21.0 (project pins 22.22.0 via `.nvmrc`) | nvm 22 |
| pnpm | install, git dep | ✓ | 11.3.0 | — |
| Postgres | Go host, import rehearsal | ✓ (local, accepting on 127.0.0.1:5432) | — | — |
| Docker | `scripts/smoke.sh` pattern (postgres:15 container) | ✗ (permission denied on docker.sock for this user) | 29.7.2 client | local Postgres via `SMOKE_DSN`, or run as a user in the docker group |
| nginx | `check-deploy.sh` (`nginx -t` with temp prefix) | ✓ | — | — |
| supervisord | config parse check | ✓ | — | — |
| git.golem15.com | layer git dep, repos | ✓ reachable (Gitea 1.25.4); target repos 404 | — | human creates repos |
| PHP server (Node, Postgres, nginx, supervisor) | staging | unknown | — | **blocking until confirmed (OQ4)** |
| PHP storage `storage/app/uploads/public` | featured images | ✗ locally (permission denied) | — | copy on the server |
**Missing dependencies with no fallback:** confirmed server environment (OQ4); Gitea repos (human creation).
**Missing dependencies with fallback:** Docker for smoke (use local Postgres DSN).
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework (layer, site app) | `node --test` on `.ts` (Node ≥22.18 type stripping), as `vue-summercmsio-app` (`"test": "node --test --test-reporter=tap tests/*.test.ts"`) |
| Framework (Go host) | `go test ./...` (stdlib testing; existing `boot_test.go` `TestBootUserTranslateJournal`, `TestCORS`) |
| Config file | none (package.json script); Go none |
| Quick run command | `pnpm test` (layer or app dir); `go test ./...` (host) |
| Full suite command | `pnpm -C vue-journal-layer test && pnpm -C vue-grzybyfunkcjonalne-app build && pnpm -C vue-grzybyfunkcjonalne-app test && go vet ./... && go test ./...` |
| Smoke | `scripts/smoke.sh <base-url>` over `scripts/urls.txt` (expected status per URL) |
Keep testable logic in pure TS under `app/utils/` (no `.vue` imports), because `node --test` cannot load SFCs. Component rendering is checked through the built server (`.output/server/index.mjs` started against a fixture API), the same idea as summercms.io's `tests/output.test.ts` but for SSR. Vitest + `@nuxt/test-utils` would be a new dependency; avoid it unless the user asks.
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SC-2 | route config: defaults, overrides, `false` disables, invalid path rejected | unit | `node --test tests/routes.test.ts` (layer) | ❌ Wave 0 |
| SC-2 | client builds query (`page`,`per_page` clamp ≤30, `category`, `include_empty`), parses envelope | unit | `node --test tests/client.test.ts` | ❌ |
| SC-2 | pagination math: page validity, prev/next, 404 on out-of-range | unit | `node --test tests/pagination.test.ts` | ❌ |
| SC-2 | HTML gate: same 3 patterns as Go | unit | `node --test tests/htmlGate.test.ts` | ❌ |
| SC-3 | sitemap entries from posts+categories use route config | unit | `node --test tests/sitemap.test.ts` | ❌ |
| SC-3 | every old URL → expected status (200/404), canonical keeps `?page=N`, `/rss.xml` is RSS | smoke | `scripts/smoke.sh https://<staging>` | ❌ |
| SC-1 | host boots with `backend.uri: /gfadmin` (admin at `/gfadmin`, `/backend` 404) | unit/boot | `go test ./... -run TestBoot` | ✅ extend `boot_test.go` |
| SC-1 | featured-image migration command (if option a): map parse, blob key, row fields, idempotent skip | unit | `go test ./... -run FeaturedImages` | ❌ |
| SC-1 | deploy configs: `nginx -t`, supervisor parse | config check | `scripts/check-deploy.sh` | ❌ (adapt) |
| SC-1 | built Nuxt server renders `/`, `/blog`, a post, a category against a fixture API | integration | `node --test tests/ssr.test.ts` (app; starts `.output/server/index.mjs`) | ❌ |
### Sampling Rate
- **Per task commit:** `pnpm test` in the touched JS repo; `go vet ./... && go test ./...` in the host.
- **Per wave merge:** full suite above plus `nuxt build` of the site app against the layer **installed from the git tag**.
- **Phase gate:** full suite green plus `scripts/smoke.sh` against staging before `/msd-verify-work`.
### Wave 0 Gaps
- [ ] Spike: pnpm git dependency from Gitea + `extends` + `nuxt build` (A1, Pitfall 10).
- [ ] `vue-journal-layer/tests/` and `package.json` test script.
- [ ] `vue-grzybyfunkcjonalne-app/tests/` + a fixture API (small Node `http` server serving recorded JSON) for SSR tests.
- [ ] `sm-grzybyfunkcjonalne-app/scripts/urls.txt`, generated from the live sitemap (71 URLs) + static/negative cases.
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no new surface | existing cabana backend JWT for `/gfadmin` |
| V3 Session Management | no new surface | cabana cookie; `backend.cookie_secure` must stay `true` in production |
| V4 Access Control | yes (deploy) | nginx: only `GET/HEAD` to Nuxt and public storage; `/storage/**` limited to image extensions |
| V5 Input Validation | yes | `?page`/`:category`/`:slug` validated in the layer (numeric page; slug regex `[a-z0-9][a-z0-9\-/]*` as the Go route) |
| V6 Cryptography | no | — |
| V14 Config | yes | secrets only in server `.env`; `NUXT_` runtime keys declared in config |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Stored XSS via `content_html` rendered with `v-html` | Tampering | Go `FormatHTML` gate on admin/API save; layer re-check with the same regexes; imported corpus scanned clean |
| Same-origin XSS via uploaded files served with sniffed types | Tampering | nginx image-extension allowlist + `nosniff` (attach/static.go warning) |
| SSRF via configurable API base | Tampering | `journal.apiBase` from server config only, never from request input |
| Rate-limit bypass or self-DoS through the SSR proxy | DoS | forward XFF to Go (127.0.0.1 trusted); swr caching |
| Admin exposure | Elevation | `/gfadmin` behind cabana auth; `/backend` returns 404 |
No new Go endpoints or auth surface are planned, so the security-review agent is not required (CONTEXT discretion). If option (b) of Open Question 1 is chosen (an API change), it is.
## Sources
### Primary (HIGH confidence)
- In-repo reads: `sm-journal-plugin/routes.go`, `controllers/api/{posts.go,posts_helpers.go,rss.go}`, `console/{import_posts.go,export_posts.go}`, `classes/format_html.go`, `README.md`; `sm-translate-plugin/updates/202610060005_seed_en_pl_locales.go`, `classes/translatable.go`; `summercms.go/modules/{lagoon/paginate.go,lagoon/attach/*.go,cabana/README.md,compass/README.md,surf/clientip.go}`; `sm-grzybyfunkcjonalne-app/config/*.yaml`, `go.work`, `summer.yaml`; `sm-summercmsio-app/{deploy,scripts,vue-summercmsio-app}`
- Legacy PHP: `grzybyfunkcjonalne.pl/themes/arsha/{pages,partials}`, `plugins/golem15/journal/{components,models,console}`, `plugins/golem15/{sitemap,seo}/routes.php`, `vendor/winter/storm/src/Router/Router.php`, `sources/gf_old.sql`, `journal-export/*.json`
- Live site probes (curl, 2026-10-09): sitemap.xml, status codes, canonical tags, link forms, navbar, footer
- Probe: `go test` run of `FormatHTML` on pl HTML content (output pasted)
- Installed node_modules: Nuxt 4.4.8 schema (`scanPageMeta`), @nuxtjs/i18n 10.4.0 module (`pages:resolved`), `nuxt/kit` re-export
### Secondary (MEDIUM confidence)
- nuxt.com/docs/4.x/guide/going-further/layers (layer priority, deps, path pitfalls)
- nuxt.com/docs/4.x/guide/concepts/rendering (routeRules, swr vs isr)
- nuxt.com/docs/4.x/getting-started/deployment (node server, ports)
- nuxt.com/docs/4.x/guide/going-further/runtime-config (`NUXT_` overrides)
- i18n.nuxtjs.org/docs/guide/layers (locale/message merge)
- nuxtseo.com/docs/sitemap/guides/dynamic-urls (dynamic sources, cache)
- pnpm.io/package-sources (git URL forms, git-hosted builds, blockExoticSubdeps)
- router.vuejs.org route matching (order independence)
### Tertiary (LOW confidence)
- Assumptions A1-A11 above
## Metadata
**Confidence breakdown:**
- URL inventory / data gaps: HIGH (live probes + source reads + export scan)
- Journal API shape: HIGH (source read)
- Standard stack: HIGH (versions from the existing lockfile)
- Layer mechanics over pnpm git: MEDIUM (docs + reasoning; spike required)
- Deploy topology: MEDIUM (pattern from summercms.io; server unknown)
- Pitfalls: HIGH for 1-3, 5-8 (verified in code/probe); MEDIUM for 4, 9-10
**Research date:** 2026-10-09
**Valid until:** 2026-11-08 (live-site facts can drift sooner; re-pull `sitemap.xml` before the smoke list is frozen)