diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index b7585b6..b8ee9b3 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -959,10 +959,23 @@ Plans: 3. Public pages that PHP Journal + the current theme rendered have a named successor on Go; sitemap is in if the site still needs it. 4. The new code has unit tests, delivered in the phase's last plan. -**Plans:** 0 plans +**Plans:** 5 plans Plans: -- [ ] TBD (run $gsd-plan-phase 16 to break down) +**Wave 1** +- [ ] 16-01-PLAN.md — vue-journal-layer: prerequisite gate (repos, D-20/D-21), git-tag install spike, Journal views, typed client, routes config, tokens, pl/en, SEO/sitemap/RSS; tag v0.1.0 + +**Wave 2** *(blocked on Wave 1 completion)* +- [ ] 16-02-PLAN.md — vue-grzybyfunkcjonalne-app: SSR site on the layer with the PHP URL map, homepage/menu/FAQ/kontakt content port, GA via runtime config; submodule in the host + +**Wave 3** *(blocked on Wave 2 completion)* +- [ ] 16-03-PLAN.md — sm-grzybyfunkcjonalne-app: /gfadmin, app plugin with featured-image and category-content commands, migration runbook, two-process deploy files, urls.txt and local rehearsal + +**Wave 4** *(blocked on Wave 3 completion)* +- [ ] 16-04-PLAN.md — Staging deploy on the PHP site's server (human checkpoint) and smoke over every old URL + +**Wave 5** *(blocked on Wave 4 completion)* +- [ ] 16-05-PLAN.md — Unit tests: layer and app node --test (incl. SSR), Go command and boot tests, phase gate ### Phase 17: Blog companions diff --git a/.planning/STATE.md b/.planning/STATE.md index 684ae62..f653370 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,19 +1,20 @@ --- -gsd_state_version: "1.0" +msd_state_version: "1.0" milestone: v1.0 -current_phase: "14.3" -current_phase_name: "One-command app scaffolding: summer new and summercms.io/new bootstrap script" -status: verifying +current_phase: 16 +current_phase_name: grzybyfunkcjonalne-pl-on-reusable-blog-views +status: executing stopped_at: Completed 14.3-04-PLAN.md -last_updated: "2026-10-08T14:24:54.530Z" +last_updated: "2026-10-09T10:25:03.224Z" last_activity: 2026-10-08 last_activity_desc: Phase 14.3 execution started -state_head: 50c8cee54129d8cf639e0893a1dca0b3dabd89bd +state_head: 6d36ffaef0427a03ca5934d9f6332ea4128a443e progress: total_phases: 31 completed_phases: 13 - total_plans: 143 + total_plans: 148 completed_plans: 139 +gsd_state_version: "1.0" milestone_name: milestone --- @@ -28,9 +29,9 @@ See: .planning/PROJECT.md (updated 2026-09-16) ## Current Position -Phase: 14.3 (One-command app scaffolding: summer new and summercms.io/new bootstrap script) — EXECUTING +Phase: 16 (grzybyfunkcjonalne-pl-on-reusable-blog-views) — READY TO EXECUTE Plan: 4 of 4 -Status: Phase complete — ready for verification +Status: Ready to execute Last activity: 2026-10-08 - Completed quick task 261008-o6k: summercms.io/update updates one application in place Progress: [██████████] 100% diff --git a/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-PLAN.md b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-PLAN.md new file mode 100644 index 0000000..51c24cf --- /dev/null +++ b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-PLAN.md @@ -0,0 +1,384 @@ +--- +phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - ../vue-journal-layer/package.json + - ../vue-journal-layer/pnpm-lock.yaml + - ../vue-journal-layer/pnpm-workspace.yaml + - ../vue-journal-layer/.nvmrc + - ../vue-journal-layer/.gitignore + - ../vue-journal-layer/tsconfig.json + - ../vue-journal-layer/nuxt.config.ts + - ../vue-journal-layer/lib/journal-module.ts + - ../vue-journal-layer/app/app.vue + - ../vue-journal-layer/app/layouts/default.vue + - ../vue-journal-layer/app/utils/types.ts + - ../vue-journal-layer/app/utils/client.ts + - ../vue-journal-layer/app/utils/routes.ts + - ../vue-journal-layer/app/utils/htmlGate.ts + - ../vue-journal-layer/app/utils/pagination.ts + - ../vue-journal-layer/app/utils/sitemap.ts + - ../vue-journal-layer/app/utils/seo.ts + - ../vue-journal-layer/app/composables/useJournalApi.ts + - ../vue-journal-layer/app/composables/useJournalLinks.ts + - ../vue-journal-layer/app/journal-pages/PostIndexPage.vue + - ../vue-journal-layer/app/journal-pages/PostPage.vue + - ../vue-journal-layer/app/journal-pages/CategoryPage.vue + - ../vue-journal-layer/app/journal-pages/TagPage.vue + - ../vue-journal-layer/app/journal-pages/AuthorPage.vue + - ../vue-journal-layer/app/components/Journal/PostBody.vue + - ../vue-journal-layer/app/components/Journal/PostCard.vue + - ../vue-journal-layer/app/components/Journal/PostList.vue + - ../vue-journal-layer/app/components/Journal/PostMeta.vue + - ../vue-journal-layer/app/components/Journal/FeaturedImage.vue + - ../vue-journal-layer/app/components/Journal/Pagination.vue + - ../vue-journal-layer/app/components/Journal/RelatedPosts.vue + - ../vue-journal-layer/app/components/Journal/IndexHeader.vue + - ../vue-journal-layer/app/components/Journal/CategoryHeader.vue + - ../vue-journal-layer/app/components/Journal/AuthorCard.vue + - ../vue-journal-layer/app/components/Journal/RssLink.vue + - ../vue-journal-layer/app/assets/css/journal-tokens.css + - ../vue-journal-layer/app/assets/css/journal-base.css + - ../vue-journal-layer/i18n/locales/pl.json + - ../vue-journal-layer/i18n/locales/en.json + - ../vue-journal-layer/server/routes/rss.xml.ts + - ../vue-journal-layer/server/api/__sitemap__/journal.ts + - ../vue-journal-layer/testing/fixture-api.mjs + - ../vue-journal-layer/testing/nuxt-server.mjs + - ../vue-journal-layer/testing/smoke-ssr.mjs + - ../vue-journal-layer/testing/fixtures/acme/posts.json + - ../vue-journal-layer/testing/fixtures/acme/categories.json + - ../vue-journal-layer/testing/fixtures/acme/tags.json + - ../vue-journal-layer/testing/fixtures/acme/authors.json + - ../vue-journal-layer/testing/fixtures/acme/rss.xml + - ../vue-journal-layer/testing/expect-acme.txt + - ../vue-journal-layer/scripts/check-git-install.sh + - ../vue-journal-layer/.playground/nuxt.config.ts + - ../vue-journal-layer/.playground/app/app.vue + - ../vue-journal-layer/README.md +autonomous: false +requirements: [SC-2, SC-3] +user_setup: + - service: git.golem15.com (Gitea 1.25.4) + why: "D-02/D-04/D-08: the layer and the site app live in two new public repos; creating a repo needs a Gitea account" + dashboard_config: + - task: "Create public empty repo golem15/vue-journal-layer (default branch main)" + location: "https://git.golem15.com/repo/create (owner golem15)" + - task: "Create public empty repo golem15/vue-grzybyfunkcjonalne-app (default branch main)" + location: "https://git.golem15.com/repo/create (owner golem15)" +estimate: + tokens: 160000 + raw_tokens: 160000 + tasks: 4 + confidence: low +must_haves: + truths: + - "D-02: the Journal views live in the new repo vue-journal-layer, a Nuxt layer that a site app consumes with extends: ['vue-journal-layer']." + - "D-04: a scratch consumer installs the layer from git+https://git.golem15.com/golem15/vue-journal-layer.git# anonymously, pnpm-lock.yaml records the tag commit, and nuxt build of that consumer succeeds (the Wave 0 spike, RESEARCH A1/Pitfall 10)." + - "D-05: the layer renders the post list with pagination (page size and sort from journal config), the single post (content_html, featured image, meta, related posts with a latest-posts fallback), category and tag listings, the author page and an RSS link, all through a typed client that calls only anonymous GET routes of the Journal API." + - "D-06: every Journal route path comes from the journal.routes config key; false disables a route; route names journal-index, journal-post, journal-category, journal-tag, journal-author stay stable so links never hard-code a path." + - "D-03: the layer looks clean on its own with --journal-* CSS custom-property tokens only; no site colours or site copy are in the layer." + - "D-16: the layer ships pl and en UI strings, registers them only for locales the consuming app enables, and declares no locale itself, so an app that enables only pl gets no /en routes." + - "D-13: @nuxtjs/seo runs in the layer: per-post title, description, og:image and canonical (canonical keeps ?page=N), robots, and a sitemap source that pages the Journal API and builds every loc from journal.routes." + - "D-24: the client always requests categories with include_empty=1, so an empty category page answers 200 with its content and an empty list; an unknown category slug answers 404." + - "D-20: /autor-style author pages consume the anonymous author endpoint added by the pre-execution quick task; the plan stops at a checkpoint when that endpoint is absent and does not implement it." + - "D-21: category pages render the category rich content field (added by the pre-execution quick task) above the post list, through the same HTML gate as post bodies." + - "D-14 and Phase 15 D-14: the layer adds no Go endpoint and never calls a Journal write route." + - "Every v-html sink (post body, category content, author bio) passes the Go FormatHTML three-pattern gate first; an unsafe value renders a neutral notice, never the raw HTML." + - "SSR fetches use the server-only internal base and forward the visitor x-forwarded-for, so Go's journal-public-api bucket stays per visitor (Pitfall 5)." + - "The layer repo never names a consuming application; README examples use blog and acme (CLAUDE.md documentation rule)." + artifacts: + - path: "../vue-journal-layer/lib/journal-module.ts" + provides: "configKey journal; extendPages from journal.routes; public runtime config; i18n:registerModule for enabled locales" + contains: "extendPages" + - path: "../vue-journal-layer/app/utils/client.ts" + provides: "createJournalClient over an injected fetcher; include_empty=1; per_page clamp 1..30" + contains: "include_empty" + - path: "../vue-journal-layer/app/utils/htmlGate.ts" + provides: "isSafeJournalHtml and gateJournalHtml with the three Go regex sources" + contains: "vbscript" + - path: "../vue-journal-layer/app/utils/routes.ts" + provides: "DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, normalizeJournalRoutes, fillJournalPath" + contains: "journal-post" + - path: "../vue-journal-layer/server/api/__sitemap__/journal.ts" + provides: "sitemap source paging posts per_page=30 plus categories include_empty=1" + contains: "defineSitemapEventHandler" + - path: "../vue-journal-layer/scripts/check-git-install.sh" + provides: "the D-04 spike: install from a git tag, nuxt build, SSR smoke against the fixture API" + contains: "git+https://git.golem15.com/golem15/vue-journal-layer.git" + - path: "../vue-journal-layer/testing/smoke-ssr.mjs" + provides: "boots a built .output against the fixture API and checks status/contains/type lines" + contains: "smoke-ssr: ok" + - path: "../vue-journal-layer/README.md" + provides: "neutral layer README (blog/acme), config keys, tokens, release by git tag" + contains: "extends" + key_links: + - from: "../vue-journal-layer/lib/journal-module.ts" + to: "../vue-journal-layer/app/journal-pages/PostPage.vue" + via: "extendPages pushes { name: journal-post, path: journal.routes.post, file } for every non-false route" + pattern: "journal-pages" + - from: "../vue-journal-layer/app/composables/useJournalApi.ts" + to: "../vue-journal-layer/app/utils/client.ts" + via: "server uses runtimeConfig.journal.apiBase and forwards x-forwarded-for; browser uses runtimeConfig.public.journal.apiBase" + pattern: "x-forwarded-for" + - from: "../vue-journal-layer/app/components/Journal/PostBody.vue" + to: "../vue-journal-layer/app/utils/htmlGate.ts" + via: "v-html only receives gateJournalHtml(...).html when safe" + pattern: "gateJournalHtml" + - from: "../vue-journal-layer/server/api/__sitemap__/journal.ts" + to: "../vue-journal-layer/app/utils/sitemap.ts" + via: "buildJournalSitemapEntries(posts, categories, routes) fills loc from journal.routes" + pattern: "buildJournalSitemapEntries" +--- + +## Phase Goal + +ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme. + +MVP_MODE note: the ROADMAP `**Goal:**` line is not an "As a / I want to / so that" story. No story is invented here; run `/msd-mvp-phase 16` to reframe it if a story is wanted. + +This plan's slice: a scratch site installs `vue-journal-layer` from a git tag and serves one Journal post as full SSR HTML from the Journal API, then the remaining Journal views, SEO, sitemap and RSS grow out of that path and ship as tag `v0.1.0`. + + +Create the reusable Journal views layer (D-02) with configurable routes (D-06), a typed anonymous-GET Journal API client (D-05), token-driven neutral design (D-03), pl/en strings (D-16), @nuxtjs/seo and sitemap (D-13), and prove the D-04 git-tag distribution end to end before any site work starts. + +Purpose: SC-2 (the blog views live in a reusable place) and the layer half of SC-3 (every PHP Journal page has a named successor). +Output: the `vue-journal-layer` repo pushed to git.golem15.com with tags `v0.0.1` (spike) and `v0.1.0` (release consumed by 16-02). + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md +@../sm-summercmsio-app/vue-summercmsio-app/package.json +@../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts +@../sm-journal-plugin/routes.go +@../sm-journal-plugin/controllers/api/posts.go +@../sm-journal-plugin/controllers/api/posts_helpers.go +@../sm-journal-plugin/classes/format_html.go + + +Journal API facts the layer builds against (read from sm-journal-plugin at d09edde or newer; do not re-derive): +- Anonymous GET group under /_journal/api/v1 with middleware throttle:journal-public-api (120/min keyed on surf.ClientIP; 127.0.0.1/32 is a trusted proxy in the host config/http.yaml). +- GET posts: query page, per_page (default 9, max 30), sort (" ", columns published_at, created_at, updated_at, title, id; default "published_at desc"), category (slug or id, includes nested children), tag (slug), search (min 3 chars). Envelope: data (array) and meta { current_page, last_page, per_page, total }. +- GET posts/{slug} (slug route regex [a-z0-9][a-z0-9\-/]*): { data: post + previous_post, next_post, related_posts }. Unknown, draft or future post: 404 with body { error: "Post not found" } and no data key. +- Post fields: id, title, slug, excerpt, content, content_html, reading_time, published, published_at ("2006-01-02T15:04:05+00:00" UTC), is_pinned, metadata, sources, author ({ id, first_name, last_name, login } or null), categories [{ id, name, slug }], tags [{ id, name, slug }], featured_images [{ id, url, file_name, file_size }], featured_image_url (string or null), translations, created_at, updated_at. +- related_posts: latest 3 in the same categories; empty when the post has no category. +- GET categories[?include_empty=1]: { data: tree of { id, name, slug, description, parent_id, nest_depth, post_count, children? } }; categories with zero published posts are omitted unless include_empty=1. +- GET tags: { data: [{ id, name, slug, post_count }] }. +- GET rss: RSS 2.0, Content-Type application/rss+xml; item links are config url + "/blog/" + slug. +- Pre-execution additions (assumed shapes, confirmed by Task 1): D-20 anonymous author endpoint (assumed GET /_journal/api/v1/authors/{slug} answering { data: { id, slug, first_name, last_name, plus public profile fields } } with the author's posts as a paginated list, assumed GET /_journal/api/v1/authors/{slug}/posts or posts?author=); D-21 category rich content field (assumed key content, an HTML string or null, on every categories row). +- FormatHTML gate regex sources (classes/format_html.go): (?i)<(?:script|iframe|object|embed)\b, (?i)\son[a-z]+\s*=, (?i)(?:javascript|vbscript|data): + + + +## Spec-less probe fallback + +Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP success criteria SC-1..SC-4 and CONTEXT decisions D-01..D-25 are the source of truth for must_haves. + +## Artifacts this phase produces + +- Repo `git.golem15.com/golem15/vue-journal-layer` (local checkout `../vue-journal-layer`), package name `vue-journal-layer`, `"main": "./nuxt.config.ts"`, tags `v0.0.1` and `v0.1.0`. +- Nuxt module `lib/journal-module.ts` (meta.name `vue-journal-layer`, configKey `journal`) with options `journal.routes` (`index`, `post`, `category`, `tag`, `author`: path string or `false`), `journal.perPage` (`index`, `category`, `tag`, `author`), `journal.sort`, `journal.relatedCount`, `journal.rss`. +- Route names `journal-index`, `journal-post`, `journal-category`, `journal-tag`, `journal-author`; neutral default paths `/blog`, `/blog/:slug`, `/blog/category/:category`, `/blog/tag/:tag`, `/blog/author/:author`. +- Runtime config keys `runtimeConfig.journal.apiBase` (env `NUXT_JOURNAL_API_BASE`, default `http://127.0.0.1:8096/_journal/api/v1`), `runtimeConfig.public.journal.apiBase` (env `NUXT_PUBLIC_JOURNAL_API_BASE`, default `/_journal/api/v1`), `runtimeConfig.public.journal.routes`, `.perPage`, `.sort`, `.relatedCount`, `.rss`. +- Pure TS utils (auto-imported in Nuxt, importable by node --test): `types.ts` (`JournalPost`, `JournalPostDetail`, `JournalCategory`, `JournalTag`, `JournalAuthor`, `JournalAuthorRef`, `JournalFeaturedImage`, `JournalPage`, `JournalItem`, `JournalRoutes`, `JournalModuleOptions`), `client.ts` (`JOURNAL_MAX_PER_PAGE`, `clampPerPage`, `buildPostListQuery`, `encodeSlugPath`, `flattenCategories`, `createJournalClient`), `routes.ts` (`DEFAULT_JOURNAL_ROUTES`, `JOURNAL_ROUTE_NAMES`, `normalizeJournalRoutes`, `fillJournalPath`), `htmlGate.ts` (`UNSAFE_TAG`, `EVENT_HANDLER`, `DANGEROUS_URL`, `isSafeJournalHtml`, `gateJournalHtml`), `pagination.ts` (`parsePageParam`, `pageOutOfRange`, `paginationWindow`), `sitemap.ts` (`buildJournalSitemapEntries`), `seo.ts` (`postSeoMeta`, `listTitle`). +- Composables `useJournalApi()` and `useJournalLinks()`. +- Page components `app/journal-pages/{PostIndexPage,PostPage,CategoryPage,TagPage,AuthorPage}.vue`. +- Overridable components `JournalPostBody`, `JournalPostCard`, `JournalPostList`, `JournalPostMeta`, `JournalFeaturedImage`, `JournalPagination`, `JournalRelatedPosts`, `JournalIndexHeader`, `JournalCategoryHeader`, `JournalAuthorCard`, `JournalRssLink`. +- CSS tokens `--journal-color-*`, `--journal-font-*`, `--journal-space-*`, `--journal-radius-*` in `app/assets/css/journal-tokens.css`; neutral styles in `journal-base.css`. +- i18n message files `i18n/locales/{pl,en}.json` under the `journal.*` key tree. +- Nitro routes `/rss.xml` (proxy of the Journal API rss with x-forwarded-for) and `/api/__sitemap__/journal` (sitemap source). +- Test tooling `testing/fixture-api.mjs` (`startFixtureApi`), `testing/nuxt-server.mjs` (`startNuxtServer`), `testing/smoke-ssr.mjs` (CLI), fixtures `testing/fixtures/acme/*`, expectations `testing/expect-acme.txt`, spike script `scripts/check-git-install.sh `. + + + + + + + Task 1: Prerequisite gate — two Gitea repos exist and the D-20/D-21 Journal additions are in sm-journal-plugin + The sibling checkout ../sm-journal-plugin exists and builds (go -C ../sm-journal-plugin vet ./... exits 0). + .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-02, D-04, D-08, D-20, D-21), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pitfall 11, Open Question 1, Journal API table), ../sm-journal-plugin/routes.go, ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/controllers/api/posts_helpers.go (serializeCategoryNode), ../sm-journal-plugin/controllers/api/posts.go (serializeAuthor), ../sm-journal-plugin/README.md + Before stopping, run every check in the verify block and report which pass. Then stop for the human: (a) create the public empty repos golem15/vue-journal-layer and golem15/vue-grzybyfunkcjonalne-app on git.golem15.com if the ls-remote checks fail (D-02, D-08; Pitfall 11); (b) run the pre-execution quick tasks in ../sm-journal-plugin if the D-20 author endpoint or the D-21 category content field is missing. Do not implement either Journal addition in this phase (D-20, D-21 are prerequisites, not plan work) and create no Journal route or handler in this phase. + +After the human resumes and every check passes, read the landed code and record a "Prerequisite contract" section in the plan SUMMARY: the exact D-20 route path or paths, the author object field names (slug and every public profile field, for example bio or avatar if present), how the author's posts are listed (path, query keys, envelope), the 404 body; and the exact D-21 key on the categories rows (assumed content) plus whether it is HTML or markdown. Tasks 2–4 use these recorded names; where they differ from the assumptions in this plan's interfaces block, the plugin wins (it is the contract) and the deviation is noted in the SUMMARY. + + Claude ran the checks below and reports the result of each. + The human: create the two public repos on git.golem15.com (owner golem15, empty, default branch main) and make sure the D-20 author endpoint and the D-21 category content field quick tasks have landed in ../sm-journal-plugin. + + All four checks in the verify block exit 0. + Type "prerequisites ready" once the repos exist and both quick tasks have landed. + + GIT_TERMINAL_PROMPT=0 git ls-remote https://git.golem15.com/golem15/vue-journal-layer.git >/dev/null && GIT_TERMINAL_PROMPT=0 git ls-remote https://git.golem15.com/golem15/vue-grzybyfunkcjonalne-app.git >/dev/null && grep -qi 'author' ../sm-journal-plugin/routes.go && grep -qi 'content' ../sm-journal-plugin/models/category.go && awk '/^func serializeCategoryNode/,/^}/' ../sm-journal-plugin/controllers/api/posts_helpers.go | grep -qi 'content' && go -C ../sm-journal-plugin vet ./... && echo gate-ok + Non-zero exit, or the final line is not "gate-ok": a repo ls-remote fails (repo missing or not public), routes.go has no author route, models/category.go has no content field, or serializeCategoryNode does not emit it. + + + - `git ls-remote` succeeds anonymously for both https://git.golem15.com/golem15/vue-journal-layer.git and https://git.golem15.com/golem15/vue-grzybyfunkcjonalne-app.git. + - ../sm-journal-plugin/routes.go registers an anonymous author route (D-20) and models/category.go declares a content field that serializeCategoryNode emits (D-21). + - The SUMMARY records the confirmed D-20 route paths and author fields and the D-21 categories key. + - No file in ../sm-journal-plugin was modified by this plan. + + Both repos exist and the author endpoint and category content field are confirmed, with their exact names recorded for Tasks 2–4. + + + + Task 2: Tracer — a scratch site installs the layer from git tag v0.0.1 and serves one Journal post as SSR HTML + D-02: every other blog builds on this layer's module, route names and component names; changing them means touching every consuming app. + ../vue-journal-layer/package.json, ../vue-journal-layer/pnpm-lock.yaml, ../vue-journal-layer/pnpm-workspace.yaml, ../vue-journal-layer/.nvmrc, ../vue-journal-layer/.gitignore, ../vue-journal-layer/tsconfig.json, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/app.vue, ../vue-journal-layer/app/layouts/default.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/composables/useJournalApi.ts, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/testing/fixture-api.mjs, ../vue-journal-layer/testing/nuxt-server.mjs, ../vue-journal-layer/testing/smoke-ssr.mjs, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/fixtures/acme/categories.json, ../vue-journal-layer/testing/fixtures/acme/tags.json, ../vue-journal-layer/testing/fixtures/acme/authors.json, ../vue-journal-layer/testing/fixtures/acme/rss.xml, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/scripts/check-git-install.sh, ../vue-journal-layer/.playground/nuxt.config.ts, ../vue-journal-layer/.playground/app/app.vue + .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pattern 1, Pattern 2, Anti-Patterns, Pitfalls 4, 5, 9, 10, Standard Stack, Package Legitimacy Audit), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md (package.json, nuxt.config.ts, modules/journal.ts, app/utils), ../sm-summercmsio-app/vue-summercmsio-app/package.json, ../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts, ../sm-summercmsio-app/vue-summercmsio-app/pnpm-workspace.yaml, ../sm-summercmsio-app/vue-summercmsio-app/.gitignore, ../sm-summercmsio-app/vue-summercmsio-app/app/utils/scrollSpy.ts, ../sm-journal-plugin/controllers/api/posts.go (serializePost, Show), ../sm-journal-plugin/classes/format_html.go, the Task 1 "Prerequisite contract" in this plan's SUMMARY + Clone the empty repo into ../vue-journal-layer (git clone git@git.golem15.com:golem15/vue-journal-layer.git), branch main. Copy .nvmrc (22.22.0), tsconfig.json (extends ./.nuxt/tsconfig.json) and pnpm-workspace.yaml (allowBuilds esbuild, @parcel/watcher, vue-demi) from ../sm-summercmsio-app/vue-summercmsio-app; .gitignore adds node_modules/, .nuxt/, .output/, .data/, .playground/.nuxt/, .playground/.output/. + +package.json: name vue-journal-layer, version 0.0.1, private true, type module, main ./nuxt.config.ts, engines node >=22.18. dependencies exactly @nuxt/fonts 0.14.0, @nuxtjs/i18n 10.4.0, @nuxtjs/seo 5.2.1 (versions already locked in vue-summercmsio-app/pnpm-lock.yaml; Package Legitimacy Audit approved). devDependencies exactly nuxt 4.4.8, vue 3.5.35, typescript 5.9.3; peerDependencies nuxt 4.4.8 so the consumer's nuxt resolves for imports from nuxt/kit. Scripts: dev = nuxi dev .playground, build:playground = nuxt build .playground, dev:prepare = nuxt prepare .playground, check:git-install = bash scripts/check-git-install.sh. Do not add an npm lifecycle script named prepare or any install-time build: a git-hosted dependency that needs preparation breaks pnpm install (Pitfall 10). No sanitize-html (RESEARCH A4: the Go three-pattern gate is the sanitizer). Run pnpm install to write pnpm-lock.yaml. + +nuxt.config.ts (layer): compute currentDir from fileURLToPath(new URL('.', import.meta.url)) and build every path from it, never relative or alias paths (Pitfall 9). Set pages: true (the consumer may have no app/pages directory). modules: '@nuxt/fonts', '@nuxtjs/i18n', '@nuxtjs/seo' and the absolute path of lib/journal-module.ts, declared once here so the app never re-lists them (Pitfall 4). The module sits in lib/ rather than modules/ so Nuxt's local-modules auto-scan cannot register it a second time. runtimeConfig.journal.apiBase = http://127.0.0.1:8096/_journal/api/v1 (server only); runtimeConfig.public.journal.apiBase = /_journal/api/v1. ogImage enabled false. i18n: no locales key in the layer (D-16, see Task 3). + +lib/journal-module.ts: defineNuxtModule from nuxt/kit with meta.name vue-journal-layer and configKey journal. Defaults: routes = DEFAULT_JOURNAL_ROUTES (index /blog, post /blog/:slug, category /blog/category/:category, tag /blog/tag/:tag, author /blog/author/:author), perPage { index 9, category 9, tag 9, author 9 }, sort published_at desc, relatedCount 3, rss true. In setup call normalizeJournalRoutes(options.routes), then extendPages pushing { name: JOURNAL_ROUTE_NAMES[key], path, file: absolute path of app/journal-pages/.vue } for each key whose path is not false (D-06). Copy routes, perPage, sort, relatedCount and rss into nuxt.options.runtimeConfig.public.journal next to apiBase. Import helpers from ../app/utils/routes.ts with a relative path; never use the tilde or at-sign alias in layer source (they resolve to the consuming project). + +app/utils (pure TS, no Nuxt auto-imports, type-only imports between utils, so node --test can import them with explicit .ts paths like scrollSpy.ts): types.ts with the interfaces listed under Artifacts, mirroring the Go serializer field names exactly (snake_case, nullable where Go can emit null) plus JournalAuthor from the Task 1 contract. routes.ts: DEFAULT_JOURNAL_ROUTES, JOURNAL_ROUTE_NAMES, normalizeJournalRoutes (each value is false or a string starting with /; post must contain :slug, category :category, tag :tag, author :author; otherwise throw an Error whose message names journal.routes.), fillJournalPath(pattern, params) replacing :name segments with encodeURIComponent values per segment. client.ts: JOURNAL_MAX_PER_PAGE = 30, clampPerPage (1..30, non-numeric to the default 9), encodeSlugPath (encode each / separated segment), createJournalClient(fetcher) where fetcher is (path, query?) => Promise; methods getPost(slug) returning the data object or null on a 404 (detect status or statusCode 404 on the thrown error), listPosts, listCategories, findCategory, listTags, getAuthor, listAuthorPosts (bodies may land in Task 3/4, but signatures and types are final now); categories always carry include_empty=1 (D-24). htmlGate.ts: UNSAFE_TAG, EVENT_HANDLER, DANGEROUS_URL as the three verbatim Go regex sources with the i flag, isSafeJournalHtml(html), gateJournalHtml(html) returning { safe, html } with html set to the empty string when unsafe. + +app/composables/useJournalApi.ts: on the server use useRuntimeConfig().journal.apiBase and pass useRequestHeaders(['x-forwarded-for']) as request headers (Pitfall 5); in the browser use runtimeConfig.public.journal.apiBase; wrap $fetch as the fetcher and return createJournalClient(fetcher). The base never comes from request input (SSRF). + +app/journal-pages/PostPage.vue: read route.params.slug, useAsyncData keyed by journal-post- calling getPost; when null throw createError({ statusCode: 404, fatal: true }); render the title in an h1 and . app/components/Journal/PostBody.vue: v-html only receives gateJournalHtml(html).html when safe; otherwise render a paragraph with class journal-post-body--blocked and the fixed English text "This content cannot be displayed." (Task 3 replaces it with the i18n key journal.post.contentUnavailable). app/app.vue renders NuxtLayout around NuxtPage; app/layouts/default.vue wraps the slot in a main element with class journal-layout. + +testing (plain .mjs with JSDoc types, because Node does not strip types under node_modules and the site app imports these from there): fixture-api.mjs exports startFixtureApi({ dir, port = 0 }) resolving { url, requests, close }: a node:http server answering the anonymous Journal API routes under /_journal/api/v1 from the JSON files in dir (posts.json array of full post objects, categories.json tree, tags.json, authors.json, rss.xml), applying page, per_page (clamped like Go), sort, category (matches post.categories[].slug), tag, include_empty, and the D-20 author routes from the Task 1 contract; show answers the post plus previous_post, next_post and related_posts (same category, latest 3) or 404 { error: "Post not found" }; every request's method, url and x-forwarded-for header is pushed onto requests. nuxt-server.mjs exports startNuxtServer({ outputDir, apiBase, port, env }) spawning node /server/index.mjs with NITRO_HOST=127.0.0.1, NITRO_PORT (a free port when 0), NUXT_JOURNAL_API_BASE=apiBase and the extra env, waiting up to 30 s for any HTTP answer on /, resolving { url, close }. smoke-ssr.mjs is a CLI taking --output, --fixtures, --expect and repeatable --env KEY=VALUE; it boots both servers, then for each non-comment line of the expect file (grammar: STATUS PATH, then optional contains=TEXT, notcontains=TEXT, type=PREFIX; values run to the end of the line) issues a GET without following redirects, with the header X-Forwarded-For: 203.0.113.7, and compares; after the lines it asserts that at least one fixture API request recorded an x-forwarded-for value containing 203.0.113.7 (Pitfall 5 forwarding), else it fails with reason "x-forwarded-for not forwarded"; it prints "smoke-ssr: ok (N checks)" and exits 0, or prints "smoke-ssr: FAIL : " and exits 1. Fixtures (acme, neutral English): 13 published posts so a 9-per-page index has 2 pages, including hello-acme (title Hello Acme, content_html with a heading and a paragraph) and unsafe-sample whose content_html contains a script tag; categories news (content HTML present, post_count 12) and quiet (post_count 0); one tag; one author per the Task 1 contract. expect-acme.txt starts with: 200 /blog/hello-acme contains=Hello Acme; 404 /blog/missing-post; 200 /blog/unsafe-sample contains=journal-post-body--blocked notcontains=alert(. + +scripts/check-git-install.sh TAG: set -euo pipefail, die() to stderr, tool loop for git, pnpm, node; create a temp dir with mktemp -d and trap cleanup; write a consumer package.json (name acme-blog, private, type module, dependencies nuxt 4.4.8, vue 3.5.35, vue-journal-layer git+https://git.golem15.com/golem15/vue-journal-layer.git#TAG), the same pnpm-workspace.yaml allowBuilds, and a nuxt.config.ts with extends ['vue-journal-layer'], compatibilityDate 2025-07-15 and i18n { strategy prefix_except_default, defaultLocale en, locales [{ code en, language en-US }, { code pl, language pl-PL }] }; run pnpm install; compare the commit recorded for vue-journal-layer in pnpm-lock.yaml with git ls-remote of refs/tags/TAG (peeled ^{} when annotated) and die on mismatch; run pnpm exec nuxt build; run node node_modules/vue-journal-layer/testing/smoke-ssr.mjs --output .output --fixtures node_modules/vue-journal-layer/testing/fixtures/acme --expect node_modules/vue-journal-layer/testing/expect-acme.txt; print "check-git-install: ok TAG ". .playground/nuxt.config.ts extends '..' with the same i18n block; .playground/app/app.vue renders NuxtLayout and NuxtPage. + +Commit (feat: scaffold Journal layer with configurable post route and git-install check), push main, create annotated tag v0.0.1, push the tag. Pushing the layer and its tags to git.golem15.com is part of this plan (D-04, D-19: the spike installs from the tag). If anything in the spike fails because of pnpm or Gitea mechanics (A1), stop and report the exact error rather than switching distribution (D-04 is locked); the fallback options in RESEARCH A1 need the user. + + bash ../vue-journal-layer/scripts/check-git-install.sh v0.0.1 + Non-zero exit; output contains "smoke-ssr: FAIL", "check-git-install:" followed by an error, or "ERR_PNPM"; the line "check-git-install: ok v0.0.1" is absent. + + + - git ls-remote https://git.golem15.com/golem15/vue-journal-layer.git lists refs/heads/main and refs/tags/v0.0.1. + - The scratch consumer's pnpm-lock.yaml pins vue-journal-layer to the commit of tag v0.0.1 (checked inside the script). + - GET /blog/hello-acme on the built scratch consumer answers 200 and contains "Hello Acme"; GET /blog/missing-post answers 404; GET /blog/unsafe-sample answers 200, contains journal-post-body--blocked and does not contain alert(. + - ../vue-journal-layer/package.json has no lifecycle script named prepare and lists nuxt only under devDependencies and peerDependencies. + - ../vue-journal-layer has no app/pages directory (layer pages live in app/journal-pages, RESEARCH anti-pattern). + - `grep -rnE "from '(~|@)/" ../vue-journal-layer/app ../vue-journal-layer/lib` prints nothing. + - smoke-ssr's forwarding assertion passes: the fixture API recorded x-forwarded-for containing 203.0.113.7 on SSR requests. + + A site that only extends the layer from a git tag renders a Journal post as full SSR HTML, blocks unsafe HTML, and 404s unknown posts. + + + + Task 3: Post list, category pages, pagination, related posts, tokens and pl/en strings + ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/pagination.ts, ../vue-journal-layer/app/composables/useJournalLinks.ts, ../vue-journal-layer/app/journal-pages/PostIndexPage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/components/Journal/PostList.vue, ../vue-journal-layer/app/components/Journal/PostMeta.vue, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/components/Journal/Pagination.vue, ../vue-journal-layer/app/components/Journal/RelatedPosts.vue, ../vue-journal-layer/app/components/Journal/IndexHeader.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/assets/css/journal-tokens.css, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/i18n/locales/pl.json, ../vue-journal-layer/i18n/locales/en.json, ../vue-journal-layer/testing/expect-acme.txt + ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/testing/expect-acme.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pattern 3, Pattern 4, URL Inventory routing rules, Pitfall 8, A5, A8, A9), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md (CSS tokens, pages and components, i18n locale files), ../sm-summercmsio-app/vue-summercmsio-app/app/assets/css/tokens.css, ../sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json, ../sm-journal-plugin/controllers/api/posts_helpers.go (relatedPosts, serializeCategoryNode) + pagination.ts: parsePageParam(raw) returns 1 for an absent value, the integer for a string of digits greater than 0, and null for anything else (non-numeric, 0, negative, decimal, array); pageOutOfRange(page, lastPage) is true when page > 1 and page > lastPage (page 1 with zero posts is in range and renders the empty state); paginationWindow(current, last, size = 5) returns the page numbers to render. A null page or an out-of-range page throws createError({ statusCode: 404, fatal: true }) in the pages (RESEARCH A5: live answers a soft 200; the successor answers a real 404 — recorded as an assumption, covered by the smoke). + +client.ts bodies: listPosts({ page, perPage, sort, category, tag }) builds the query through buildPostListQuery (per_page clamped, sort passed verbatim as " "), returning JournalPage; listCategories() always sends include_empty=1 (D-24, Pitfall 8) and returns the tree; findCategory(slug) flattens the tree with flattenCategories and returns the row or null; listTags(). + +useJournalLinks(): returns postLink(slug), categoryLink(slug), tagLink(slug), authorLink(slug), indexLink(page) built with useLocalePath() over the route names in JOURNAL_ROUTE_NAMES and params (never string-concatenated paths; RESEARCH A9: names gain a locale suffix, so useLocalePath is mandatory); a link to a disabled route (false) returns null and the component renders plain text. + +PostIndexPage.vue (journal-index): page from parsePageParam(route.query.page); listPosts with perPage.index and sort from runtimeConfig.public.journal; 404 when null or out of range; renders , , . Page links use ?page=N query form (D-15; never a path segment). CategoryPage.vue (journal-category): findCategory(route.params.category); null throws 404 (an unknown root path must not soft-200, RESEARCH Pattern 3); then listPosts({ category: slug, perPage: perPage.category, page }); renders which shows name, description and the D-21 rich content (key from the Task 1 contract) through gateJournalHtml above the list (D-21); an empty category renders 200 with its header and the empty-state string (D-24). PostPage.vue: add (published date formatted with Intl.DateTimeFormat for the active i18n locale, reading_time, category links, author link when the post has an author and the author route is enabled), (featured_image_url, alt from the post title, loading lazy, width and height attributes from CSS tokens), previous and next links, and showing related_posts, or when related_posts is empty, the latest relatedCount posts excluding the current slug (RESEARCH: related_posts is empty for a post without a category). PostBody.vue uses the i18n key journal.post.contentUnavailable for the blocked notice. + +Components (app/components/Journal/*, auto-registered with the Journal prefix so an app overrides any of them by creating the same file): PostCard (title link, excerpt, date, featured image), PostList (cards, empty state), Pagination (previous/next and window, rel prev/next), IndexHeader (h1 from journal.index.title plus journal.pagination.pageTitle when page > 1), CategoryHeader, RelatedPosts, PostMeta, FeaturedImage. Styles use only var(--journal-*) tokens; no hard-coded colours (D-03). + +journal-tokens.css: one :root block, grouped, a trailing comment per variable naming its use (summercms.io tokens.css style): --journal-color-text, -muted, -bg, -surface, -border, -link, -link-hover, -accent; --journal-font-body, -heading, -mono; --journal-space-1..6; --journal-radius-sm, -md, -lg; --journal-content-width. Neutral greys and one blue accent so the layer looks clean alone. journal-base.css: layout, typography for .journal-post-body (headings, lists, tables, blockquote, images max-width 100%), cards, pagination. Add both files to the layer nuxt.config css array as absolute paths built from currentDir (Pitfall 9). + +i18n (D-16): i18n/locales/pl.json and en.json with identical key trees under journal (index.title, index.empty, pagination.previous, pagination.next, pagination.pageTitle with {page}, post.publishedOn with {date}, post.readingTime with {minutes}, post.related, post.previous, post.next, post.contentUnavailable, category.empty, tag.title with {name}, author.title with {name}, author.posts, readMore, rss.title). In journal-module setup register nuxt.hook('i18n:registerModule', register => register({ langDir: absolute i18n/locales dir, locales })) where locales is the subset of pl and en whose code appears in the project's nuxt.options.i18n.locales (strings or objects), each with file .json; register nothing when the project enables neither. @nuxtjs/i18n 10.4.0 calls this hook in modules:done and merges every registered locale into the app (module.mjs applyLayerOptions), so declaring locales in the layer config would add /en routes to a pl-only app — the layer must never declare them. + +Append to expect-acme.txt: 200 /blog contains=Hello Acme; 200 /blog?page=2; 404 /blog?page=3; 404 /blog?page=abc; 404 /blog?page=0; 200 /blog/category/news contains=; 200 /blog/category/quiet contains=; 404 /blog/category/nope; 200 /pl/blog contains=. Verify against the local playground build (no tag needed for this task). + +Commit (feat: Journal list, category pages, pagination, tokens and pl/en strings) and push main. + + pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt + Non-zero exit; the nuxt build prints "ERROR" or exits non-zero; output contains "smoke-ssr: FAIL"; the line starting "smoke-ssr: ok" is absent. + + + - /blog answers 200, /blog?page=2 answers 200, /blog?page=3, /blog?page=abc and /blog?page=0 answer 404 on the playground build. + - /blog/category/news answers 200 and contains the D-21 content marker; /blog/category/quiet answers 200 with the empty-state string; /blog/category/nope answers 404. + - The fixture API log shows every categories request carried include_empty=1. + - /pl/blog renders the pl journal.index.title string (the hook registered the pl messages for a project that enables pl). + - pl.json and en.json have the same set of leaf keys under journal. + - `grep -rnE '#[0-9a-fA-F]{3,6}\b' ../vue-journal-layer/app/components` prints nothing (components use tokens only). + + Index, category and post pages paginate, 404 correctly, render category content and related posts, and speak pl or en through tokens-only styling. + + + + Task 4: Author and tag pages, SEO meta, sitemap source, /rss.xml, neutral README and release tag v0.1.0 + ../vue-journal-layer/package.json, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/sitemap.ts, ../vue-journal-layer/app/utils/seo.ts, ../vue-journal-layer/app/journal-pages/TagPage.vue, ../vue-journal-layer/app/journal-pages/AuthorPage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/journal-pages/PostIndexPage.vue, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/components/Journal/AuthorCard.vue, ../vue-journal-layer/app/components/Journal/RssLink.vue, ../vue-journal-layer/server/routes/rss.xml.ts, ../vue-journal-layer/server/api/__sitemap__/journal.ts, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md + ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/testing/expect-acme.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pattern 5, Don't Hand-Roll, URL Inventory non-page URLs, A6, A7, Security Domain), ../sm-summercmsio-app/vue-summercmsio-app/README.md, ../sm-journal-plugin/README.md (neutral wording precedent), ../sm-journal-plugin/controllers/api/rss.go, the Task 1 "Prerequisite contract" in this plan's SUMMARY + client.ts: getAuthor(slug) and listAuthorPosts(slug, { page, perPage }) against the D-20 routes recorded in the Task 1 contract (404 returns null). AuthorPage.vue (journal-author): getAuthor(route.params.author); null throws 404; renders (name, and every public profile field the endpoint returns; an HTML bio goes through gateJournalHtml) and the paginated author posts with perPage.author; same page validation as the index. TagPage.vue (journal-tag): resolves the tag from listTags() by slug (404 when absent), lists posts with tag=slug and perPage.tag. Both pages are registered by the module only when their route is not false (D-06). + +seo.ts: postSeoMeta(post, siteUrl) returns { title: post.title, description: excerpt (or the first 160 characters of the content_html text), ogType article, ogImage: absolute featured_image_url when present, articlePublishedTime: published_at, articleModifiedTime: updated_at }; listTitle(base, page, pageTitle) appends the page title string when page > 1. Every journal page calls useSeoMeta with these; the site name suffix comes from nuxt-seo-utils' title template and the app's site.name (RESEARCH A6). In the layer nuxt.config set seo.canonicalQueryWhitelist to ['page'] explicitly so paginated canonicals keep ?page=N (RESEARCH A7; nuxt-seo-utils already defaults to a list containing page, set it anyway to lock the behaviour), and sitemap { sources: ['/api/__sitemap__/journal'], autoI18n: false }. + +sitemap.ts: buildJournalSitemapEntries(posts, categories, routes) returns { loc, lastmod? } entries: each published post at fillJournalPath(routes.post, { slug }) with lastmod updated_at, each category of the flattened include_empty=1 tree at fillJournalPath(routes.category, { category: slug }), nothing for a disabled route; never hard-code /blog/. server/api/__sitemap__/journal.ts: defineSitemapEventHandler that reads runtimeConfig.journal.apiBase and runtimeConfig.public.journal.routes, pages GET posts with per_page=30 until meta.last_page, fetches categories with include_empty=1, and returns buildJournalSitemapEntries(...) (D-13, D-24, Pitfall 5: per_page 30 keeps the request count low). + +server/routes/rss.xml.ts: GET handler that fetches /rss as text with the incoming x-forwarded-for forwarded, answers with Content-Type application/rss+xml; charset=utf-8 and the upstream body, and answers 502 with a plain-text body when the upstream fails; when runtimeConfig.public.journal.rss is false answer 404. RssLink.vue adds a link rel alternate type application/rss+xml href /rss.xml to the head (useHead) and a visible link with journal.rss.title; PostIndexPage and PostPage include it when rss is true. Robots: no per-page noindex on journal pages (every PHP page has robots_noindex = 0); error responses rely on @nuxtjs/robots' default handling, and staging turns indexing off through NUXT_SITE_INDEXABLE (16-03/16-04). + +README.md (neutral: the words used are blog and acme; never name a consuming site or company app): H1 vue-journal-layer, one-sentence summary, install line (pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0), then Overview, Features, Usage (extends: ['vue-journal-layer'], an acme example of journal.routes moving category pages to /:category and disabling tags), Configuration (every journal.* key with default, runtime config keys and their NUXT_ env names, the x-forwarded-for forwarding note), Theming (every --journal-* token, how an app overrides tokens on :root:root and overrides a component by creating app/components/Journal/.vue), i18n (strings are registered only for locales the app enables), SEO and sitemap, Local development (pnpm link or a link:../vue-journal-layer override, never committed), Releasing (a release is an annotated git tag; consumers bump the #tag and pnpm-lock.yaml), Testing (check-git-install.sh, smoke-ssr.mjs, fixture API). + +Append to expect-acme.txt: 200 /blog/author/ contains=; 404 /blog/author/nobody; 200 /blog/tag/; 404 /blog/tag/nope; 200 /rss.xml type=application/rss+xml; 200 /sitemap.xml contains=/blog/hello-acme; 200 /sitemap.xml contains=/blog/category/quiet; 200 /blog?page=2 contains=?page=2 (canonical link keeps the query); 200 /blog/hello-acme contains=og:title. + +Set package.json version 0.1.0, commit (feat: Journal author and tag pages, SEO, sitemap source and RSS route), push main, create and push annotated tag v0.1.0. + + pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt && bash ../vue-journal-layer/scripts/check-git-install.sh v0.1.0 + Non-zero exit; output contains "smoke-ssr: FAIL" or "ERR_PNPM"; the line "check-git-install: ok v0.1.0" is absent. + ! grep -rniE 'grzyb|fonoteka|plytarium' --exclude-dir=node_modules --exclude-dir=.nuxt --exclude-dir=.output --exclude=pnpm-lock.yaml ../vue-journal-layer + Exit status 1 with any matching line printed: the layer names a consuming application. + + + - The scratch consumer installed from tag v0.1.0 passes every line of expect-acme.txt, including author 200/404, tag 200/404, /rss.xml with type application/rss+xml, /sitemap.xml listing /blog/hello-acme and /blog/category/quiet, and the canonical of /blog?page=2 keeping ?page=2. + - git ls-remote of the layer lists refs/tags/v0.1.0 and package.json version is 0.1.0. + - README.md contains the sections Overview, Features, Usage, Configuration, Theming, i18n, Releasing and Testing, and every journal.* key the module accepts. + - No file in the layer repo (excluding node_modules, .nuxt, .output, pnpm-lock.yaml) matches the consuming-application name scan. + - The layer source contains no call to a Journal write route: `grep -rnE "method: *'(POST|PUT|DELETE)'" ../vue-journal-layer/app ../vue-journal-layer/server` prints nothing. + + The layer ships every Journal view, SEO, sitemap and RSS as tag v0.1.0, installable from git by any blog. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Journal API JSON → v-html | Post bodies, category content and author bios are HTML that editors or imports wrote; they cross into the DOM | +| Browser request → SSR fetch | Visitor route params and headers shape the server-side API request | +| Nuxt server → Go API | All SSR traffic leaves from 127.0.0.1, which Go trusts as a proxy | +| npm/git installs | The layer pulls three Nuxt modules and is itself pulled from git | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-16-01 | Tampering | JournalPostBody, JournalCategoryHeader, JournalAuthorCard (v-html) | high | mitigate | gateJournalHtml applies the three verbatim Go FormatHTML regexes before any v-html; unsafe values render journal.post.contentUnavailable; acme fixture unsafe-sample proves it in the smoke (Task 2, Task 3) | +| T-16-02 | Tampering (SSRF) | useJournalApi, server/routes/rss.xml.ts, sitemap source | high | mitigate | apiBase comes only from runtimeConfig (server key journal.apiBase); route params are encoded per segment with encodeURIComponent; no request value selects the host | +| T-16-03 | Denial of Service | SSR fetches against journal-public-api (120/min) | medium | mitigate | useJournalApi and the RSS route forward x-forwarded-for so Go buckets per visitor; sitemap source pages per_page=30; site app adds swr caching (16-02) | +| T-16-04 | Tampering | ?page, :slug, :category, :author route input | medium | mitigate | parsePageParam accepts digits only and 404s the rest; unknown slugs 404 through the API; no input is interpolated into HTML | +| T-16-05 | Information Disclosure | Journal API drafts | low | accept | The layer calls only anonymous GET routes; Go already 404s drafts (Phase 15 JOURNAL-005) | +| T-16-SC | Tampering | npm/pnpm installs and the git dependency | high | mitigate | Only packages already locked in vue-summercmsio-app (nuxt 4.4.8, vue 3.5.35, @nuxtjs/i18n 10.4.0, @nuxtjs/seo 5.2.1, @nuxt/fonts 0.14.0, typescript 5.9.3) at exact versions; RESEARCH Package Legitimacy Audit approved them; sanitize-html is not installed; the layer has no install-time scripts; consumers pin the layer by tag and lockfile commit | + + + +- Task 1 gate prints gate-ok. +- `bash ../vue-journal-layer/scripts/check-git-install.sh v0.1.0` prints "check-git-install: ok v0.1.0". +- The playground smoke passes every line of testing/expect-acme.txt. +- The consuming-application name scan over the layer prints nothing. + + + +- vue-journal-layer v0.1.0 is on git.golem15.com and installs into a fresh consumer from the tag (D-04). +- Post list, post, category, tag and author views, RSS link, SEO meta and sitemap work from journal.routes (D-05, D-06, D-13). +- Categories always use include_empty=1; unknown slugs and out-of-range pages answer 404 (D-24). +- pl/en strings register only for enabled locales (D-16); tokens drive every visual value (D-03). + + + +Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md` when done, including the "Prerequisite contract" section from Task 1. + diff --git a/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-PLAN.md b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-PLAN.md new file mode 100644 index 0000000..26f0fc1 --- /dev/null +++ b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-PLAN.md @@ -0,0 +1,303 @@ +--- +phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views +plan: 02 +type: execute +wave: 2 +depends_on: ["16-01"] +files_modified: + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-workspace.yaml + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.nvmrc + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.gitignore + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tsconfig.json + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/error.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/index.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/kontakt.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/trwaja-prace.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteFooter.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/HeroSection.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/LatestPosts.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/AboutSection.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/CtaSection.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/ServicesSection.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/PageHero.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/Journal/IndexHeader.vue + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/contact.ts + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/base.css + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/favicon.ico + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/hero-forest.webp + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/cta.png + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/categories.json + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/tags.json + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/authors.json + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/rss.xml + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect-ga.txt + - ../sm-grzybyfunkcjonalne-app/.gitmodules + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app +autonomous: true +requirements: [SC-1, SC-3] +estimate: + tokens: 130000 + raw_tokens: 130000 + tasks: 3 + confidence: low +must_haves: + truths: + - "D-01/D-08: vue-grzybyfunkcjonalne-app is a Nuxt 4 SSR app (summercms standards: @nuxtjs/i18n, @nuxtjs/seo, @nuxt/fonts through the layer, pnpm, node --test) mounted as a submodule of sm-grzybyfunkcjonalne-app the way vue-summercmsio-app is mounted in sm-summercmsio-app." + - "D-04: the app depends on git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0 with the commit locked in pnpm-lock.yaml, and does not re-list the layer's Nuxt modules (Pitfall 4)." + - "D-15: the PHP public URLs answer on the new app with the same paths: /, /blog with ?page=N, /blog/:slug, /autor/:author, /kontakt, /trwaja-prace and the root /:category catch-all, where static routes win; /404, /error and unknown roots answer 404." + - "D-16: the app serves pl only, without a URL prefix; /en/blog answers 404." + - "D-07: routeRules swr caching keeps pages full SSR HTML and lets a newly published post appear without a rebuild." + - "D-11: the homepage hero, latest 3 posts, about, CTA and services sections and the blog header carry the current Polish copy, as components plus i18n strings, with no sitemanager toggles." + - "D-12: the main menu is typed config in app/data/menu.ts with the category dropdown resolved from the Journal API, and the FAQ what-mushrooms-can-give-you is static content in app/data/faq.pl.json; no menu or FAQ plugin is built (deferred to Phase 17)." + - "D-14: /kontakt shows the contact details and a mailto link; the app contains no contact or newsletter form and calls no new Go endpoint." + - "D-23: Google Analytics G-MLSV7WJ6W4 loads only when NUXT_PUBLIC_GA_ID is set; the default build and staging render no analytics script." + - "D-03: the refreshed design lives in the app as --journal-* token overrides and app CSS; the layer is unchanged by this plan." + - "D-24: /amanita-muscaria answers 200 with its content and an empty list." + - "No English locale for the site, no production cutover, no sitemanager/blocks port (CONTEXT deferred ideas)." + artifacts: + - path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts" + provides: "extends the layer; journal.routes for the PHP URLs; pl-only i18n; site config; swr routeRules; runtime gaId" + contains: "/autor/:author" + - path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts" + provides: "typed MAIN_MENU config" + contains: "journal-categories" + - path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json" + provides: "the 16 FAQ entries with the live Polish copy" + contains: "items" + - path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts" + provides: "gaHeadScripts(id) returning no scripts for an empty or malformed id" + contains: "googletagmanager" + - path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt" + provides: "SSR smoke expectations for every D-15 URL form" + contains: "/autor/" + - path: "../sm-grzybyfunkcjonalne-app/.gitmodules" + provides: "submodule vue-grzybyfunkcjonalne-app" + contains: "vue-grzybyfunkcjonalne-app" + key_links: + - from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json" + to: "git.golem15.com/golem15/vue-journal-layer#v0.1.0" + via: "pnpm git dependency pinned by tag and lockfile commit" + pattern: "vue-journal-layer.git#v0.1.0" + - from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts" + to: "../vue-journal-layer/lib/journal-module.ts" + via: "journal.routes overrides: category /:category, author /autor/:author, tag false" + pattern: "category: '/:category'" + - from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue" + to: "useJournalApi().listCategories()" + via: "the journal-categories menu item lists every category (include_empty=1) through useJournalLinks().categoryLink" + pattern: "listCategories" + - from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue" + to: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts" + via: "useHead({ script: gaHeadScripts(runtimeConfig.public.gaId) })" + pattern: "gaHeadScripts" +--- + +## Phase Goal + +ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme. + +MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted. + +This plan's slice: the grzybyfunkcjonalne.pl Nuxt app extends `vue-journal-layer` v0.1.0 and answers the PHP site's URLs with SSR HTML, then the homepage, menu, FAQ, kontakt, error pages and analytics carry the current Polish content in a refreshed design. + + +Build `vue-grzybyfunkcjonalne-app` (D-08) on the layer (D-02, D-04) with the PHP URL map (D-15), pl-only i18n (D-16), SSR swr caching (D-07), the refreshed design (D-01, D-03), the content port (D-11, D-12, D-14) and GA through runtime config (D-23), and mount it as a submodule of the host. + +Purpose: SC-1 (the site, as content and routes) and SC-3 (every PHP page has a named Nuxt successor). +Output: the app repo pushed to git.golem15.com and mounted at `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app`. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md +@../vue-journal-layer/README.md +@../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts +@../sm-summercmsio-app/vue-summercmsio-app/README.md +@../sm-summercmsio-app/.gitmodules + + +From vue-journal-layer v0.1.0 (16-01): configKey journal with routes { index, post, category, tag, author } (path or false), perPage { index, category, tag, author }, sort, relatedCount, rss; route names journal-index, journal-post, journal-category, journal-tag, journal-author; runtime keys journal.apiBase (NUXT_JOURNAL_API_BASE) and public.journal.apiBase; composables useJournalApi() (listPosts, getPost, listCategories, findCategory, listTags, getAuthor, listAuthorPosts) and useJournalLinks() (postLink, categoryLink, tagLink, authorLink, indexLink); utils gateJournalHtml, isSafeJournalHtml; overridable components Journal/{PostBody, PostCard, PostList, PostMeta, FeaturedImage, Pagination, RelatedPosts, IndexHeader, CategoryHeader, AuthorCard, RssLink}; tokens --journal-color-*, --journal-font-*, --journal-space-*, --journal-radius-*; testing tools node_modules/vue-journal-layer/testing/{fixture-api.mjs, nuxt-server.mjs, smoke-ssr.mjs} (expect grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]; smoke-ssr flag --env KEY=VALUE). +Live content facts (RESEARCH Content Inventory): hero title "Grzyby Funkcjonalne", text "Serwis informacyjny poświęcony grzybom funkcjonalnym", CTA "Czytam"; about "Czym są grzyby funkcjonalne?" with button "Poznaj naszą misję"; CTA "Skorzystaj z wiedzy ekspertów"; services "Dlaczego warto?" with 4 boxes; FAQ intro plus 16 Q&A; footer 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; blog header "Artykuły" / "Przeczytaj nasze artykuły na temat grzybów funkcjonalnych"; colour seeds #0D705D, #27AE60, #138C74, #1AAC8F, #444444; fonts Open Sans, Jost, Poppins. + + + +## Spec-less probe fallback + +Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves. + +## Artifacts this phase produces + +- Repo `git.golem15.com/golem15/vue-grzybyfunkcjonalne-app`, mounted as submodule `vue-grzybyfunkcjonalne-app` in `sm-grzybyfunkcjonalne-app` (`.gitmodules` entry, gitlink). +- `nuxt.config.ts` keys: `extends: ['vue-journal-layer']`; `journal.routes` = `{ index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }`; `journal.perPage` = `{ index: 12, category: 10, author: 10 }`; `journal.sort` = `published_at desc`; `i18n` pl only (`prefix_except_default`, `defaultLocale: 'pl'`); `site` (`url https://grzybyfunkcjonalne.pl`, `name Grzyby Funkcjonalne`); `routeRules` swr; `runtimeConfig.public.gaId` (env `NUXT_PUBLIC_GA_ID`); `robots.disallow`; `sitemap.exclude`; `linkChecker.excludeLinks`. +- Pages `/` (`index.vue`), `/kontakt` (`kontakt.vue`), `/trwaja-prace` (`trwaja-prace.vue`), `app/error.vue`, layout `default.vue`. +- Components `SiteHeader`, `SiteFooter`, `HeroSection`, `LatestPosts`, `AboutSection`, `CtaSection`, `ServicesSection`, `FaqSection`, `PageHero`, override `Journal/IndexHeader`. +- Typed data `app/data/menu.ts` (`MenuItem`, `MAIN_MENU`), `app/data/contact.ts` (`CONTACT`), `app/data/faq.pl.json` (`{ slug, intro, items: [{ question, answer_html }] }`). +- Util `app/utils/analytics.ts` (`GA_ID_PATTERN`, `gaHeadScripts`). +- i18n `i18n/locales/pl.json` (site copy plus overrides of `journal.*` strings). +- Fixtures `tests/fixtures/api/*` and expectations `tests/fixtures/expect.txt`, `tests/fixtures/expect-ga.txt`; package scripts `build`, `dev`, `preview`, `smoke:ssr`, `smoke:ga`. + + +The ` + + + Task 1: Tracer — the site app extends layer v0.1.0 and answers the PHP post and category URLs as SSR HTML, mounted in the host + D-15: the URL shapes are search-indexed; changing them after the cutover needs a 301 map. Already decided by the user in CONTEXT — recorded here, not re-asked. + ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-workspace.yaml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.nvmrc, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/.gitignore, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tsconfig.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/categories.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/tags.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/authors.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/rss.xml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app + .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-04, D-07, D-08, D-15, D-16, D-24), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (URL Inventory, routing rules, Pattern 1 app excerpt, Pattern 4, Pitfall 4, A2, A8), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md (Prerequisite contract, any layer deviations), ../vue-journal-layer/README.md, ../vue-journal-layer/testing/smoke-ssr.mjs, ../sm-summercmsio-app/vue-summercmsio-app/package.json, ../sm-summercmsio-app/vue-summercmsio-app/nuxt.config.ts, ../sm-summercmsio-app/vue-summercmsio-app/app/assets/css/tokens.css, ../sm-summercmsio-app/.gitmodules, .planning/notes/core-plugins-own-repos.md, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/badania-nad-cordycepsem.json + Create the app as a nested repo at ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app (git init -b main, remote origin git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git). Copy .nvmrc, tsconfig.json, pnpm-workspace.yaml (allowBuilds esbuild, @parcel/watcher, vue-demi) and .gitignore (node_modules/, .nuxt/, .output/, .data/, dist, .env) from ../sm-summercmsio-app/vue-summercmsio-app. + +package.json: name vue-grzybyfunkcjonalne-app, private, type module, engines node >=22.18; scripts build (nuxt build), dev (nuxt dev), preview (nuxt preview), prepare (nuxt prepare), smoke:ssr (node node_modules/vue-journal-layer/testing/smoke-ssr.mjs --output .output --fixtures tests/fixtures/api --expect tests/fixtures/expect.txt); no generate script (D-07 SSR). dependencies exactly nuxt 4.4.8, vue 3.5.35, vue-journal-layer git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0 (D-04); devDependencies typescript 5.9.3. Do not list @nuxtjs/i18n, @nuxtjs/seo or @nuxt/fonts (the layer declares them; Pitfall 4). pnpm install writes pnpm-lock.yaml with the tag commit. A link:../../vue-journal-layer override for local layer work is documented in the README and never committed (D-04). + +nuxt.config.ts (short comment above each block, summercms.io style): compatibilityDate 2025-07-15, devtools off, extends ['vue-journal-layer'], no modules key. journal: routes { index '/blog', post '/blog/:slug', category '/:category', author '/autor/:author', tag false } (D-15; the PHP site has no tag pages), perPage { index 12, category 10, author 10 } (PHP blog.htm 12, index.htm 10, autor.htm 10), sort 'published_at desc'. i18n: strategy prefix_except_default, defaultLocale pl, locales [{ code pl, language pl-PL, file pl.json, name Polski }], langDir locales/, baseUrl https://grzybyfunkcjonalne.pl (D-16: pl only, no prefix). site: url https://grzybyfunkcjonalne.pl, name Grzyby Funkcjonalne, description Serwis informacyjny poświęcony grzybom funkcjonalnym, defaultLocale pl. routeRules: '/**' swr 300 and '/blog/**' swr 120 (D-07: Nitro swr on node-server, never isr). runtimeConfig: journal.apiBase http://127.0.0.1:8096/_journal/api/v1 and public.gaId '' (D-23, env NUXT_PUBLIC_GA_ID). css: ~/assets/css/tokens.css (the app may use ~ for its own files). fonts: Jost (400, 500, 600, 700) for headings and Open Sans (400, 600, 700) for body, normal style, latin and latin-ext subsets, global. + +tokens.css: one :root:root block (higher specificity than the layer's :root, RESEARCH Pattern 4 and A8) overriding --journal-color-* with the refreshed palette seeded from #0D705D (primary), #27AE60 (accent), #138C74 (link), #1AAC8F (link hover), #444444 (text) and white background, --journal-font-heading Jost and --journal-font-body Open Sans, plus site tokens --site-* (header height, hero overlay), each with a trailing comment naming its use. app.vue renders NuxtLayout and NuxtPage; layouts/default.vue wraps the slot in a header slot area, main and footer slot area (filled in Task 2). + +i18n/locales/pl.json: overrides for the layer strings the site wants in its own words (journal.index.title Artykuły, journal.pagination.pageTitle Strona {page}) plus site keys used in this task. + +Fixtures (tests/fixtures/api, the layer fixture-api format): 14 published posts copied from /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export (pl translation title, slug, excerpt and content_html, published_at converted to the Go wire format, categories as { id, name, slug }), including blogbadania-nad-cordycepsem; categories tree with cordyceps, reishi, chaga, shiitake, lions-mane, turkey-tail and amanita-muscaria (post_count 0) and a content HTML string on cordyceps containing the marker text Cordyceps w pigułce; tags []; one author with slug admin per the 16-01 Prerequisite contract; rss.xml with one item. tests/fixtures/expect.txt: 200 /blog/blogbadania-nad-cordycepsem contains=Najnowsze Badania nad Cordycepsem; 404 /blog/badania-nad-cordycepsem; 200 /cordyceps contains=Cordyceps w pigułce; 200 /amanita-muscaria; 404 /nieistnieje; 404 /rss; 404 /feed; 404 /404; 404 /error; 200 /blog; 200 /blog?page=2; 404 /blog?page=3; 404 /en/blog; 200 /autor/admin; 404 /autor/nobody; 200 /rss.xml type=application/rss+xml; 200 /sitemap.xml contains=/blog/blogbadania-nad-cordycepsem; 200 /sitemap.xml contains=/amanita-muscaria. + +Commit in the app (feat: SSR site on vue-journal-layer with the PHP URL map) and push main. Then mount it: in ../sm-grzybyfunkcjonalne-app run git submodule add git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git vue-grzybyfunkcjonalne-app (git adopts the existing nested repo), commit only .gitmodules and the gitlink in the host (chore: mount vue-grzybyfunkcjonalne-app submodule). Never stage files under the submodule from the host repo (.planning/notes/core-plugins-own-repos.md). + + pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app install --frozen-lockfile && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ssr && git -C ../sm-grzybyfunkcjonalne-app ls-files -s vue-grzybyfunkcjonalne-app | grep -q '^160000' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$' + Non-zero exit; output contains "smoke-ssr: FAIL", "ERR_PNPM_OUTDATED_LOCKFILE" or "--- FAIL"; the "smoke-ssr: ok" line or the "--- PASS: TestBootUserTranslateJournal" line is absent; the host index has no 160000 gitlink for vue-grzybyfunkcjonalne-app. + + + - Every line of tests/fixtures/expect.txt passes against the built .output: post by pl slug 200, base slug 404, /cordyceps 200 with its content marker, /amanita-muscaria 200, unknown roots and /404, /error 404, /blog?page=2 200 and ?page=3 404, /en/blog 404, /autor/admin 200, /rss.xml RSS type, sitemap entries for a post and an empty category. + - package.json pins vue-journal-layer to #v0.1.0 and pnpm-lock.yaml records the commit of that tag. + - `grep -nE '^\s*modules:' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts` prints nothing. + - nuxt.config.ts contains category: '/:category', author: '/autor/:author' and tag: false. + - `git -C ../sm-grzybyfunkcjonalne-app ls-files -s vue-grzybyfunkcjonalne-app` starts with 160000 and .gitmodules has url git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git. + - Host go vet and TestBootUserTranslateJournal stay green. + + The site app answers the PHP post, category, blog, author and error URLs with the right status codes from SSR, installed from the layer tag and mounted in the host. + + + + Task 2: Homepage, typed menu, FAQ and footer with the current Polish copy in the refreshed design + ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/index.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/layouts/default.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteFooter.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/HeroSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/LatestPosts.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/AboutSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/CtaSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/ServicesSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/contact.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/base.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/favicon.ico, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/hero-forest.webp, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/public/images/cta.png, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt + ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Content Inventory, Runtime State Inventory: live HTML is the copy authority), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/layouts/default.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/homepage.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/g15_menu, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/g15_faq, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/content/homepage, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu, ../sm-summercmsio-app/vue-summercmsio-app/app/components/SiteHeader.vue, ../sm-summercmsio-app/vue-summercmsio-app/app/assets/css/base.css + Copy authority (RESEARCH Runtime State Inventory): fetch https://grzybyfunkcjonalne.pl/ with curl and take every Polish string from the live HTML; fall back to /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/sources/gf_old.sql sitemanager settings and the g15_faq rows only for text the live page does not show. Carry the copy verbatim (D-11); drop the sitemanager enable_* toggles and the arsha demo blocks (clients, portfolio, pricing, team, whyus, newsletter, contact). + +i18n/locales/pl.json gains the keys home.hero.title, home.hero.text, home.hero.cta, home.latest.title, home.latest.more (Czytaj więcej → /blog), home.about.title, home.about.columns (two rich-text strings), home.about.cta, home.cta.title, home.cta.text, home.services.title, home.services.items (4 entries with title and text), footer.* and menu.* keys. Rich text with markup is stored as HTML strings and rendered through the layer's gateJournalHtml before v-html. + +app/data/faq.pl.json: { slug: what-mushrooms-can-give-you, intro, items: [16 × { question, answer_html }] } with the live answers (D-12). app/data/menu.ts: export type MenuItem = { kind: 'link'; labelKey: string; to: string } | { kind: 'journal-categories'; labelKey: string }, export const MAIN_MENU: MenuItem[] = Blog → /blog, then the Grzyby Funkcjonalne dropdown of kind journal-categories (the live g15_menu main: Blog plus a blog_categories dropdown). app/data/contact.ts: export const CONTACT with name, street Wróblewskiego 1A/1U, city Namysłów, phone 666-681-225, email kontakt@grzybyfunkcjonalne.pl, footer links (Artykuły blogowe → /blog, Golem15, Ciemność.com, Dobra bomba with their live URLs) and social x.com/ophiel118. + +Download the hero background (live las.webp), the CTA image (live 65f9f2e96287d085855960.png) and the favicon from the live site into public/images/hero-forest.webp, public/images/cta.png and public/favicon.ico (keep the original bytes). + +Components (tokens only, no Bootstrap, no hard-coded colours outside tokens.css): SiteHeader (logo text Grzyby Funkcjonalne linking /, MAIN_MENU rendered from the typed config; the journal-categories item lists useJournalApi().listCategories() flattened, each through useJournalLinks().categoryLink; keyboard-accessible dropdown and a mobile toggle), SiteFooter (CONTACT details, tel and mailto links, footer links, social, copyright with the current year), HeroSection (full-width image with a darkening overlay token, h1, text, CTA to /blog), LatestPosts (useJournalApi().listPosts({ perPage: 3 }) rendered with the layer JournalPostCard, plus the Czytaj więcej link to the journal-index route), AboutSection, CtaSection, ServicesSection (4 boxes), FaqSection (accessible details/summary list; answers through gateJournalHtml). index.vue composes Hero, LatestPosts, About, Cta, Services, Faq and sets useSeoMeta title Witamy and the site description. layouts/default.vue renders SiteHeader, main and SiteFooter. base.css: global typography from tokens, section spacing, buttons, cards, responsive breakpoints at 768px and 1100px; the refreshed look is cleaner and roomier than the arsha template (D-01, D-03), not a pixel copy. + +Append to expect.txt: 200 / contains=Grzyby Funkcjonalne; 200 / contains=Czym są grzyby funkcjonalne?; 200 / contains=Dlaczego warto?; 200 / contains=; 200 / contains=kontakt@grzybyfunkcjonalne.pl; 200 / contains=href="/cordyceps"; 200 / contains=href="/amanita-muscaria"; 200 / contains=Najnowsze Badania nad Cordycepsem. + +Commit in the app (feat: homepage, menu, FAQ and footer with the current Polish copy) and push main; bump the host gitlink in a separate host commit (chore: point vue-grzybyfunkcjonalne-app at the homepage). + + pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ssr + Non-zero exit; output contains "smoke-ssr: FAIL"; the "smoke-ssr: ok" line is absent. + + + - GET / answers 200 and contains the hero title, the about and services headings, the first FAQ question, the contact email and the latest post title from the fixtures. + - The header lists all seven category links, including /amanita-muscaria (include_empty=1 through the layer client). + - app/data/faq.pl.json has exactly 16 items, each with non-empty question and answer_html. + - app/data/menu.ts exports MenuItem and MAIN_MENU, and MAIN_MENU contains an item with kind journal-categories. + - `grep -rnE '#[0-9a-fA-F]{3,6}\b' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components` prints nothing (colours live in tokens.css). + + The homepage, menu, FAQ and footer show the current Polish content in the refreshed design, and the menu lists every Journal category. + + + + Task 3: Kontakt, trwaja-prace, error pages, blog header, analytics through runtime config and the app README + ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/kontakt.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/pages/trwaja-prace.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/error.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/PageHero.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/Journal/IndexHeader.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect-ga.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md + ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/app.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../vue-journal-layer/app/components/Journal/IndexHeader.vue, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/kontakt.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/content/content-kontakt.pl.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/trwaja-prace.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/404.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/error.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/pages/blog.htm, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/layouts/default.htm (the GA snippet), ../sm-summercmsio-app/vue-summercmsio-app/README.md, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-14, D-23) + kontakt.vue (D-14): PageHero with Kontakt and the subtitle Skontaktuj Się z Ekspertami od Grzybów Funkcjonalnych (content-kontakt.pl.htm), then the CONTACT details with a tel link and a mailto:kontakt@grzybyfunkcjonalne.pl link. No HTML form element, no input fields and no request to any endpoint; the contact and newsletter forms stay dropped (backlog 999.1). trwaja-prace.vue: PageHero plus the heading Niebawem (trwaja-prace.htm). Both set useSeoMeta titles. + +error.vue: reads the Nuxt error; 404 renders the PHP 404.htm copy (Nie Znaleziono / Nie znaleziono tej strony) and 500 renders the error.htm copy (Wystąpił Błąd / Spróbuj ponownie za chwilę), each with a link to / and the site layout. /404 and /error have no page of their own: they fall into the /:category route, the category lookup misses and the 404 page renders (live status for both is 404). + +Journal/IndexHeader.vue overrides the layer component: PageHero with the blog image (public/images/cta.png, the live blog header image), title Artykuły (plus Strona N when page > 1, matching the live "Artykuły | Strona 2" title) and the description Przeczytaj nasze artykuły na temat grzybów funkcjonalnych (blog.htm meta). PageHero is a shared component (image, title, subtitle, overlay token). + +analytics.ts (D-23): export const GA_ID_PATTERN = /^G-[A-Z0-9]{4,20}$/ and export function gaHeadScripts(id: string) returning [] when id is empty or fails the pattern, else two head script objects: the async loader src https://www.googletagmanager.com/gtag/js?id= and an inline script that defines window.dataLayer, gtag, gtag('js', new Date()) and gtag('config', ''); the id is the only interpolated value and is pattern-checked first. app.vue calls useHead({ script: gaHeadScripts(useRuntimeConfig().public.gaId) }). The production value G-MLSV7WJ6W4 is set only through NUXT_PUBLIC_GA_ID on the server (16-03 env template); the default and staging builds render no analytics. + +nuxt.config.ts: robots { disallow: ['/gfadmin', '/_journal'] }; sitemap { exclude: ['/trwaja-prace'] } (the live sitemap lists /, /blog, categories and posts, not the Niebawem page); linkChecker { excludeLinks: ['/gfadmin', '/gfadmin/**', '/_journal/**', '/storage/**', '/rss.xml'] }. package.json gains the script smoke:ga (the smoke:ssr command plus --env NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 --expect tests/fixtures/expect-ga.txt). + +Append to expect.txt: 200 /kontakt contains=mailto:kontakt@grzybyfunkcjonalne.pl notcontains= + + pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ssr && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app run smoke:ga + Non-zero exit; output contains "smoke-ssr: FAIL"; fewer than two "smoke-ssr: ok" lines are printed. + ! grep -rn ' + Exit status 1 with a matching line printed: the app contains a form element (D-14 dropped forms). + + + - /kontakt answers 200 with the mailto link and no form; /trwaja-prace answers 200 with Niebawem; /404 answers 404 with the PHP 404 copy. + - /blog shows the Artykuły header and description; /blog?page=2 shows Strona 2. + - Without NUXT_PUBLIC_GA_ID the homepage has no googletagmanager script; with NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 it loads gtag/js?id=G-MLSV7WJ6W4. + - gaHeadScripts returns [] for '' and for a value that fails GA_ID_PATTERN. + - /robots.txt disallows /gfadmin; /sitemap.xml does not list /trwaja-prace. + - The host gitlink for vue-grzybyfunkcjonalne-app points at the app's pushed main commit. + + Every non-Journal PHP page has its Nuxt successor, errors render the PHP copy, and analytics is controlled by runtime config only. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Repo-committed site copy → v-html | FAQ answers and rich homepage copy are HTML strings rendered in the DOM | +| Server env → head scripts | NUXT_PUBLIC_GA_ID is interpolated into an inline script | +| Visitor → kontakt page | A contact page could invite input if a form were added | +| pnpm git dependency | The app pulls the layer from git.golem15.com | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-16-06 | Tampering | FaqSection, AboutSection rich text (v-html) | low | mitigate | Copy is committed in the repo and reviewed; every v-html still goes through the layer's gateJournalHtml (same three Go patterns) | +| T-16-07 | Tampering | app.vue gaHeadScripts inline script | medium | mitigate | GA_ID_PATTERN (^G-[A-Z0-9]{4,20}$) gates the only interpolated value; malformed or empty ids emit no script (Task 3 acceptance) | +| T-16-08 | Information Disclosure | analytics on staging | low | mitigate | gaId defaults to ''; staging leaves NUXT_PUBLIC_GA_ID empty (D-23); production sets G-MLSV7WJ6W4 only in the server env | +| T-16-09 | Spoofing | /kontakt | low | accept | D-14 drops the form: no input surface, no new Go endpoint; mailto and tel links only (negative grep for a form element) | +| T-16-SC | Tampering | npm/pnpm installs and the layer git dependency | high | mitigate | Exact versions only (nuxt 4.4.8, vue 3.5.35, typescript 5.9.3, already approved in RESEARCH Package Legitimacy Audit); the layer is pinned by tag and lockfile commit and installed with --frozen-lockfile; no new packages | + + + +- `pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build` then `run smoke:ssr` and `run smoke:ga` print "smoke-ssr: ok". +- The host has the vue-grzybyfunkcjonalne-app gitlink and its go vet and boot test stay green. + + + +- The site app is SSR, pl-only, installed from layer tag v0.1.0 and mounted in the host (D-04, D-07, D-08, D-16). +- Every PHP URL form from D-15 answers with the expected status on the fixture API. +- Homepage, menu, FAQ, kontakt, trwaja-prace and error pages carry the current Polish copy in the refreshed design (D-11, D-12, D-14). +- GA follows NUXT_PUBLIC_GA_ID (D-23). + + + +Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-SUMMARY.md` when done. + diff --git a/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-PLAN.md b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-PLAN.md new file mode 100644 index 0000000..9c70be2 --- /dev/null +++ b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-PLAN.md @@ -0,0 +1,327 @@ +--- +phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views +plan: 03 +type: execute +wave: 3 +depends_on: ["16-02"] +files_modified: + - ../sm-grzybyfunkcjonalne-app/config/backend.yaml + - ../sm-grzybyfunkcjonalne-app/boot_test.go + - ../sm-grzybyfunkcjonalne-app/summer.yaml + - ../sm-grzybyfunkcjonalne-app/go.work + - ../sm-grzybyfunkcjonalne-app/go.mod + - ../sm-grzybyfunkcjonalne-app/go.sum + - ../sm-grzybyfunkcjonalne-app/main.go + - ../sm-grzybyfunkcjonalne-app/plugins.gen.go + - ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/map.jsonl + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/categories/map.jsonl + - ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh + - ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh + - ../sm-grzybyfunkcjonalne-app/scripts/build.sh + - ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh + - ../sm-grzybyfunkcjonalne-app/scripts/urls.txt + - ../sm-grzybyfunkcjonalne-app/scripts/php-export/featured-images.sql + - ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql + - ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md + - ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf + - ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf + - ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf + - ../sm-grzybyfunkcjonalne-app/deploy/env.example + - ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example + - ../sm-grzybyfunkcjonalne-app/README.md + - ../sm-grzybyfunkcjonalne-app/.gitignore + - .planning/todos/pending/journal-imported-html-admin-resave.md +autonomous: true +requirements: [SC-1, SC-3] +user_setup: + - service: local PostgreSQL (rehearsal database) + why: "Docker is not usable for this user, so scripts/smoke.sh needs a Postgres >= 15 with ICU and a role that may CREATE DATABASE to rehearse the migration runbook on a throwaway database" + env_vars: + - name: SMOKE_ADMIN_DSN + source: "A DSN to the local postgres maintenance database for a role with CREATEDB, e.g. postgres://:@127.0.0.1:5432/postgres?sslmode=disable" +estimate: + tokens: 140000 + raw_tokens: 140000 + tasks: 3 + confidence: low +must_haves: + truths: + - "D-09: the admin is mounted at /gfadmin through backend.uri: /gfadmin in config/backend.yaml; no assembled route starts with /backend, and nginx answers 404 for /backend." + - "D-22: grzyby:import-featured-images reads a JSONL slug-to-file map from a PHP database query, copies each original into the Go bucket under its Winter disk name (attach.BlobKey) and inserts the system_files row for the Journal post, so old /storage/app/uploads/public/... image URLs keep resolving; a second run creates nothing." + - "D-21: grzyby:import-categories fills each Journal category's name, description and rich content field from a PHP database query, preferring the pl translation, and the category pages then render that content." + - "D-25: scripts/migrate.sh runs migrate, then makes pl the only default locale and asserts it, then imports the journal export exactly once into a fresh database and refuses a database that already holds posts." + - "D-17: the runbook imports posts with journal:import-posts from the PHP journal export, copies storage/app/media and the featured images, and reports gaps instead of patching them (two UAT-host image references stay broken and are listed)." + - "D-07/D-10: deploy files run two supervisor programs (Go on 127.0.0.1:8096, Nuxt on 127.0.0.1:3016) behind one staging nginx vhost on the PHP server; nginx sends /gfadmin and /_journal to Go, image files under /storage/app/{uploads/public,media} to disk with nosniff, and everything else to Nuxt." + - "D-15/D-18: scripts/urls.txt lists every URL in the live sitemap plus the static and negative cases, and scripts/smoke.sh checks each one either against a local rehearsal (Go + Nuxt + imported posts) or against a base URL." + - "D-14/Phase 15 D-14: no new Go HTTP endpoint is added; the app plugin only adds console commands." + - "D-15: /autor/ keeps resolving by assigning the imported posts to a named admin author through migrate.sh (--author-login, --author-slug)." + - "Pitfall 2 is documented: imported posts must not be re-saved in /gfadmin until the Journal HTML-content follow-up lands (todo recorded)." + artifacts: + - path: "../sm-grzybyfunkcjonalne-app/config/backend.yaml" + provides: "admin mount path" + contains: "uri: /gfadmin" + - path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go" + provides: "grzyby:import-featured-images and ImportFeaturedImages core" + contains: "grzyby:import-featured-images" + - path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go" + provides: "grzyby:import-categories and ImportCategories core" + contains: "grzyby:import-categories" + - path: "../sm-grzybyfunkcjonalne-app/scripts/migrate.sh" + provides: "ordered runbook: migrate, pl default, import once, categories, media, featured images, author" + contains: "is_default" + - path: "../sm-grzybyfunkcjonalne-app/scripts/smoke.sh" + provides: "local rehearsal on a throwaway database or --url checks over urls.txt" + contains: "urls.txt" + - path: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf" + provides: "staging vhost with Go/Nuxt/disk routing" + contains: "nosniff" + - path: "../sm-grzybyfunkcjonalne-app/scripts/urls.txt" + provides: "every live sitemap URL plus static and negative cases" + contains: "/blog/blogbadania-nad-cordycepsem" + key_links: + - from: "../sm-grzybyfunkcjonalne-app/summer.yaml" + to: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go" + via: "plugin golem15.grzyby registered by summer plugin:add; summer build regenerates plugins.gen.go" + pattern: "golem15.grzyby" + - from: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go" + to: "summercms.go/modules/lagoon/attach (BlobKey, File)" + via: "blob key PartitionDirectory(disk_name)+disk_name; system_files row with attachment_type Golem15\\Journal\\Models\\Post and field featured_images" + pattern: "attach.BlobKey" + - from: "../sm-grzybyfunkcjonalne-app/scripts/migrate.sh" + to: "golem15_translate_locales" + via: "UPDATE ... SET is_default = (code = 'pl') before journal:import-posts" + pattern: "code = 'pl'" + - from: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf" + to: "127.0.0.1:8096 and 127.0.0.1:3016" + via: "proxy_pass with Host and X-Forwarded-For" + pattern: "proxy_add_x_forwarded_for" +--- + +## Phase Goal + +ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme. + +MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted. + +This plan's slice: on a throwaway database the Go host migrates, imports the PHP journal export with pl as default, attaches a featured image under its Winter disk name and serves it through the Journal API with the admin at /gfadmin; then the runbook, deploy files and a URL-by-URL rehearsal of the whole site grow from that path. + + +Turn `sm-grzybyfunkcjonalne-app` into the deployable host: admin at /gfadmin (D-09), an app-local plugin with the featured-image (D-22) and category-content (D-21) commands, the ordered migration runbook (D-25, D-17), two-process deploy files for staging next to the PHP site (D-07, D-10), and `urls.txt` plus `smoke.sh` that rehearse every old URL locally (D-15, D-18). + +Purpose: SC-1 (served by the SummerCMS binary) and the URL half of SC-3. +Output: host config, plugin `golem15.grzyby`, scripts, deploy configs and README, verified by a local rehearsal. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-SUMMARY.md +@../sm-grzybyfunkcjonalne-app/boot_test.go +@../sm-grzybyfunkcjonalne-app/summer.yaml +@../sm-grzybyfunkcjonalne-app/config/backend.yaml +@../sm-grzybyfunkcjonalne-app/config/storage.yaml +@../sm-grzybyfunkcjonalne-app/config/http.yaml +@../sm-journal-plugin/console/import_posts.go +@../sm-journal-plugin/console/export_posts.go +@../sm-summercmsio-app/scripts/smoke.sh +@../sm-summercmsio-app/scripts/check-deploy.sh +@../sm-summercmsio-app/scripts/build.sh +@../sm-summercmsio-app/deploy/nginx/summercms.io.conf +@../sm-summercmsio-app/deploy/supervisor/summercms-io.conf +@../sm-summercmsio-app/deploy/env.example + + +Framework and plugin APIs this plan calls (read from source, do not re-derive): +- summer CLI (summercms.go/cmd/summer): `make:plugin ` scaffolds plugins/ as module /plugins/ with plugin.go and go.mod; `plugin:add ` registers a local plugin module in summer.yaml, go.work and the app go.mod; `build` regenerates main.go and plugins.gen.go (never hand-edit those two files). +- bonfire.Command { Name, Description, Flags []bonfire.Flag{ Name, Description, Bare }, Run func(ctx, bonfire.Input, bonfire.Output) error }; in.Flag(name) (string, bool); out.Info, out.Warning, out.Error. +- pact.HasCommands: Commands() []bonfire.Command (main.go appends them); party.Register(&Plugin{}) in init; Plugin has ID(), Requires(), Register(*backpack.App), Boot(*backpack.App). +- app.Lookup[*gorm.DB]() and app.Lookup[*blob.Bucket]() (gocloud.dev/blob); attach.OpenBucket(ctx, app.Config) opens the configured bucket (config/storage.yaml uploads.bucket_url file://./storage/app/uploads/public). +- attach.PartitionDirectory(diskName), attach.BlobKey(diskName) = PartitionDirectory(diskName)+diskName, attach.File { DiskName, FileName, FileSize, ContentType, Field, AttachmentID string, AttachmentType, IsPublic *bool, SortOrder } on table system_files; f.URL() gives /storage/app/uploads/public//. +- Journal: models.Post (golem15_journal_posts, slug host column, MorphName Golem15\Journal\Models\Post), models.Category (golem15_journal_categories, name, slug, description, plus the D-21 field named in the 16-01 Prerequisite contract); journal:import-posts --path DIR [--dry-run] [--force]. +- cabana backend.uri: one or more lowercase path segments; boot fails on an invalid value; env override SUMMER_BACKEND__URI. +- Translate: golem15_translate_locales (code, is_default, is_enabled); seeded default is en (Pitfall 1). + + + +## Spec-less probe fallback + +Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves. + +## Artifacts this phase produces + +- Config key value `backend.uri: /gfadmin` (`config/backend.yaml`). +- App-local plugin `golem15.grzyby` at `plugins/grzyby` (module `git.golem15.com/golem15/sm-grzybyfunkcjonalne-app/plugins/grzyby`, package `grzyby`, `Requires()` = `golem15.journal`, implements `pact.HasCommands`), listed in `summer.yaml`, `go.work` and the host `go.mod`. +- CLI commands `grzyby:import-featured-images --map --source [--export ] [--dry-run]` and `grzyby:import-categories --map [--dry-run]`. +- Go symbols in `plugins/grzyby/console`: `FeaturedImageEntry`, `FeaturedImageOptions`, `FeaturedImageStats`, `ReadFeaturedImageMap`, `ImportFeaturedImages`, `ImportFeaturedImagesCommand`; `CategoryEntry`, `CategoryStats`, `ReadCategoryMap`, `ImportCategories`, `ImportCategoriesCommand`; helpers `flagOrDefault`, `flagOn`, `lookupDB`, `lookupBucket`, `validDiskName`. +- Scripts `scripts/migrate.sh` (flags `--bin`, `--workdir`, `--export`, `--featured-map`, `--uploads`, `--categories-map`, `--media`, `--author-login`, `--author-slug`, `--dry-run`), `scripts/smoke.sh` (local rehearsal; `--go-only`; `--url `; env `SMOKE_ADMIN_DSN`, `SMOKE_EXPORT_DIR`, `SMOKE_GO_PORT`, `SMOKE_NUXT_PORT`), `scripts/build.sh`, `scripts/check-deploy.sh`, `scripts/urls.txt` (grammar `[remote] STATUS PATH [contains=…] [notcontains=…] [type=…]`), `scripts/php-export/{featured-images.sql,categories.sql,README.md}`. +- Deploy files `deploy/nginx/grzybyfunkcjonalne-staging.conf`, `deploy/supervisor/grzybyfunkcjonalne-go.conf` (`[program:grzybyfunkcjonalne-go]`), `deploy/supervisor/grzybyfunkcjonalne-nuxt.conf` (`[program:grzybyfunkcjonalne-nuxt]`), `deploy/env.example` (adds `SUMMER_URL`), `deploy/nuxt.env.example` (`NUXT_JOURNAL_API_BASE`, `NUXT_SITE_URL`, `NUXT_SITE_INDEXABLE`, `NUXT_PUBLIC_GA_ID`, `NITRO_HOST`, `NITRO_PORT`). +- Host `README.md`; `.gitignore` entry `/build/`; todo `.planning/todos/pending/journal-imported-html-admin-resave.md`. + + + + + Task 1: Tracer — /gfadmin, plugin golem15.grzyby and grzyby:import-featured-images serve one imported post with its Winter-named featured image + SMOKE_ADMIN_DSN is set to a Postgres >= 15 maintenance-database DSN for a role with CREATEDB and ICU support (psql "$SMOKE_ADMIN_DSN" -c 'select 1' exits 0), or `docker info` exits 0; and /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export holds the PHP export JSON files. + ../sm-grzybyfunkcjonalne-app/config/backend.yaml, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/summer.yaml, ../sm-grzybyfunkcjonalne-app/go.work, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/go.sum, ../sm-grzybyfunkcjonalne-app/main.go, ../sm-grzybyfunkcjonalne-app/plugins.gen.go, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/map.jsonl, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh + .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-09, D-17, D-22, D-25), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Journal Export vs Importer table, Featured images option a, Pitfalls 1, 3, 6, 7, Migration runbook), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-PATTERNS.md (config/backend.yaml, app-local plugin + import_featured_images.go, smoke.sh), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md (Prerequisite contract), ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/config/backend.yaml, ../sm-grzybyfunkcjonalne-app/config/storage.yaml, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/console/export_posts.go (mustDB, flagOrDefault, flagOn), ../sm-journal-plugin/plugin.go (Commands), ../sm-summercmsio-app/plugins/golem15/summercms/plugin.go, ../summercms.go/modules/lagoon/attach/file.go, ../summercms.go/modules/lagoon/attach/thumb.go, ../summercms.go/modules/lagoon/attach/bucket.go, ../summercms.go/modules/cabana/prefix.go, ../summercms.go/internal/build/scaffold.go, ../sm-summercmsio-app/scripts/smoke.sh + Journal gitlink: compare `git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal` with the sibling ../sm-journal-plugin HEAD that carries the D-20/D-21 commits recorded in 16-01; when the host gitlink is older, fetch into the nested clone with git -c protocol.file.allow=always fetch file:// and check out that commit, then commit the gitlink alone in the host (chore: point journal gitlink at author endpoint and category content) — Phase 15 nested-clone precedent. + +config/backend.yaml: change the mount to uri: /gfadmin and update its comment (D-09: the site keeps the admin on its own domain; the old /backend path is intentionally gone). Commit (feat: mount the admin at /gfadmin). + +Plugin: install the summer CLI from the framework checkout into a temp GOBIN (GOBIN= go -C ../summercms.go install ./cmd/summer), then from the host root run summer make:plugin golem15.grzyby (creates plugins/grzyby) and summer plugin:add plugins/grzyby (summer.yaml, go.work, host go.mod), then summer build (regenerates main.go and plugins.gen.go; never edit them by hand). plugin.go: package grzyby, ID golem15.grzyby, Requires() returns golem15.journal, Register stores *backpack.App, Boot returns nil, var _ pact.HasCommands = (*Plugin)(nil), Commands() returns ImportFeaturedImagesCommand(p.app) (Task 2 adds the category command), init calls party.Register(&Plugin{}). No routes, no admin controllers, no migrations: the plugin adds no HTTP endpoint (Phase 15 D-14 contract unchanged). + +console/helpers.go: flagOrDefault, flagOn, lookupDB (app.Lookup[*gorm.DB]), lookupBucket (app.Lookup[*blob.Bucket], else attach.OpenBucket(ctx, app.Config)), validDiskName (^[A-Za-z0-9]+\.[A-Za-z0-9]+$, so no separators or dot-dot). + +console/import_featured_images.go (D-22, shaped like journal ImportPosts): FeaturedImageEntry { Slug slug, DiskName disk_name, FileName file_name, ContentType content_type, FileSize file_size, SortOrder sort_order } with those JSON keys; ReadFeaturedImageMap(path) reads JSON Lines (one object per line, blank lines skipped, errors name the line number); FeaturedImageOptions { MapPath, SourceDir, ExportDir, DryRun }; FeaturedImageStats { Created, Skipped, Failed }; ImportFeaturedImages(ctx, db *gorm.DB, bucket *blob.Bucket, opts, out bonfire.Output) (FeaturedImageStats, error). Per entry: reject a disk name failing validDiskName or a content type outside image/jpeg, image/png, image/gif, image/webp (Failed, continue); resolve the source file as SourceDir joined with attach.PartitionDirectory(disk_name) and disk_name and refuse it unless filepath.Rel from SourceDir stays inside; find the post by host slug = entry.slug; when no post matches and ExportDir is set, read /.json and retry with translations.pl.slug (base-to-live slug, RESEARCH: live URLs use the pl slug); a miss is Failed with "post not found: ". Skipped when a system_files row already exists with attachment_type = models.Post{}.MorphName(), attachment_id = the post id as a decimal string, field featured_images and the same disk_name, and the blob attach.BlobKey(disk_name) exists. Otherwise (unless DryRun) write the blob under attach.BlobKey(disk_name) with the entry content type (bucket.WriteAll), then insert attach.File { DiskName, FileName, FileSize, ContentType, Field featured_images, AttachmentID, AttachmentType, SortOrder, IsPublic nil } — Created. Fatal DB errors return; per-entry problems print out.Error and count Failed. ImportFeaturedImagesCommand(app) is bonfire command grzyby:import-featured-images with flags map, source, export and the bare flag dry-run; map and source are required; it prints "Results: N created, N skipped, N failed". + +testdata/featured: a valid 1×1 PNG at uploads/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png and map.jsonl with one line for slug badania-nad-cordycepsem (the base slug, so the export fallback is exercised) with that disk_name, file_name cordyceps.png, content_type image/png, the real byte size, sort_order 0. + +boot_test.go: four plugins now; the migrations loop checks only golem15.user, golem15.translate and golem15.journal (the app plugin has none); assert some assembled route Pattern starts with /gfadmin/ and none starts with /backend; assert the golem15.grzyby plugin implements pact.HasCommands and lists grzyby:import-featured-images; keep every existing assertion. + +scripts/migrate.sh (D-25 ordered runbook; summercms.io script conventions: usage header, set -euo pipefail, die to stderr, tool loop): flags --bin, --workdir (holds config/ and .env), --export, --featured-map with --uploads, --dry-run. Steps in order: (1) bin migrate in the workdir; (2) read SUMMER_DATABASE__DSN from /.env and refuse with "migrate: database already holds journal posts; import must run once into a fresh database (Pitfall 3)" when SELECT count(*) FROM golem15_journal_posts is not 0; (3) psql UPDATE golem15_translate_locales SET is_default = (code = 'pl') and die unless SELECT code FROM golem15_translate_locales WHERE is_default AND is_enabled returns exactly pl (Pitfall 1); (4) journal:import-posts --path EXPORT --dry-run, then without --dry-run; (5) when --featured-map is given, grzyby:import-featured-images --map … --source --export EXPORT; print "migrate: ok". With --dry-run only steps that write nothing run. + +scripts/smoke.sh (this task builds the --go-only path; Task 3 adds Nuxt and urls.txt): require bin/grzybyfunkcjonalne (build it in this task with go -C ../sm-grzybyfunkcjonalne-app build -o bin/grzybyfunkcjonalne .); database: when SMOKE_ADMIN_DSN is set, create grzyby_smoke_ with TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL' and drop only that database on exit; else docker postgres:15 like summercms.io; it never touches any other database name. Work dir with config/ copied and a .env of random secrets plus SUMMER_URL=http://127.0.0.1:; run migrate.sh with --export "${SMOKE_EXPORT_DIR:-/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export}" and the testdata featured map; serve Go on SMOKE_GO_PORT (default 18096, refuse a busy port); check: GET /_journal/api/v1/posts?per_page=1 meta.total equals the number of published export files, GET /_journal/api/v1/posts/blogbadania-nad-cordycepsem answers 200 with featured_image_url /storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png and that file exists under the work dir storage, GET /_journal/api/v1/posts/badania-nad-cordycepsem answers 404, GET /gfadmin answers a non-404 status, GET /backend answers 404; a second grzyby:import-featured-images run prints 0 created; print "smoke: ok (go)". + +Commits: plugin and boot test (feat: app plugin golem15.grzyby with featured-image import), scripts (feat: migration runbook and Go rehearsal). go vet and go test stay green at each. + + go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal|TestCORS)$' && go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby vet ./... && go -C ../sm-grzybyfunkcjonalne-app build -o bin/grzybyfunkcjonalne . && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --go-only + Non-zero exit; output contains "--- FAIL", "smoke:" followed by an error, or "migrate:" followed by an error; the lines "--- PASS: TestBootUserTranslateJournal" or "smoke: ok (go)" are absent. + ! grep -q 'uri: /backend' ../sm-grzybyfunkcjonalne-app/config/backend.yaml + Exit status 1: config/backend.yaml still mounts the admin at the old path. + + + - config/backend.yaml contains `uri: /gfadmin`; the boot test asserts a /gfadmin/ route and no /backend route. + - summer.yaml lists golem15.grzyby and plugins.gen.go imports git.golem15.com/golem15/sm-grzybyfunkcjonalne-app/plugins/grzyby, both produced by summer, not hand-edited. + - On the rehearsal database the post blogbadania-nad-cordycepsem answers 200 with featured_image_url /storage/app/uploads/public/665/f1e/2a3/665f1e2a3b4c5d6e7f8091.png and the file exists at that path under the work dir; the base slug answers 404. + - The second featured-image run reports 0 created (idempotent, D-22). + - pl is the only default locale before the import ran (migrate.sh dies otherwise). + - The host journal gitlink points at a commit containing the D-20 and D-21 additions. + + An imported post with its Winter-named featured image is served by the Go host from a scripted, ordered migration, with the admin at /gfadmin. + + + + Task 2: Category content import, the rest of the runbook (categories, media, author) and the PHP export queries + ../sm-grzybyfunkcjonalne-app/plugins/grzyby/plugin.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/categories/map.jsonl, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/scripts/php-export/featured-images.sql, ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql, ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, .planning/todos/pending/journal-imported-html-admin-resave.md + ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-15, D-17, D-20, D-21), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Journal Export vs Importer: categories, authors, inline media refs; Pitfall 2; Open Questions 1 and 3; Content Inventory: category pillar content), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md (Prerequisite contract: D-21 field name, D-20 author slug storage), ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/updates/202610060007_add_author_slug_to_backend_users.go, /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/sources/gf_old.sql (category rows, for the query shape only), .planning/todos/pending/menu-plugin-port.md (todo file format) + console/import_categories.go (D-21): CategoryEntry { Slug slug, Name name, Description description (nullable), Content content (nullable), Translations translations map[locale]map[field]string } with those JSON keys; ReadCategoryMap(path) reads JSON Lines like the featured map; CategoryStats { Updated, Unchanged, Failed }; ImportCategories(ctx, db, entries, dryRun, out). For each entry take the pl translation value of slug, name, description and content when present and non-empty, else the base value (pl is the default locale after D-25, so host columns hold pl). Find the category by host slug (pl slug, then base slug); a miss is Failed with "category not found: " (journal:import-posts already created every category a post references, drafts included, so all seven exist). Update name, description and the D-21 content field (the Go field and column named in the 16-01 Prerequisite contract) in one UPDATE; identical values count Unchanged. ImportCategoriesCommand(app) is grzyby:import-categories with flag map (required) and bare dry-run, printing "Results: N updated, N unchanged, N failed". Register it in Plugin.Commands() and extend the boot test to expect both command names. testdata/categories/map.jsonl: one line for cordyceps with a pl name, a description and content containing the marker text Cordyceps w pigułce. + +migrate.sh gains, in this order after the post import: --categories-map runs grzyby:import-categories; --media rsyncs it to /storage/app/media/ (images and files as they are; Artur Wolak.png and las.webp are referenced by 32 post bodies); --featured-map (Task 1); --author-login with --author-slug (D-15 /autor/, D-20): psql sets the author slug column recorded in the 16-01 contract (assumed backend_users.golem15_bloghub_author_slug) for that login and sets user_id on every golem15_journal_posts row whose user_id is NULL, dying when the login does not exist. Finally it prints a gap report (D-17: flag, do not patch): the count of post bodies still referencing https://gf.uat.golem15.com/storage/ (2 known broken references), and the count of published posts without a featured image. + +smoke.sh --go-only additionally runs --categories-map with the testdata map and checks that GET /_journal/api/v1/categories?include_empty=1 returns cordyceps with the content marker and that amanita-muscaria is present with post_count 0 (D-24). + +scripts/php-export (run on the PHP server, D-22 "SQL query on the PHP database"): featured-images.sql selects one JSON_OBJECT per row (slug, disk_name, file_name, content_type, file_size, sort_order) from system_files where field = 'featured_images' joined to the posts table; the slug is the pl slug from the translate index table when one exists, else the base slug; comments at the top name the two values to check on the server before running (RESEARCH Open Question 3): the post model morph (Golem15\\Journal\\Models\\Post or Golem15\\Blog\\Models\\Post with its table) and the translate tables (winter_translate_* or rainlab_translate_*). categories.sql selects one JSON_OBJECT per category (slug, name, description, content, translations: { pl: the pl attribute_data JSON }). README.md: run each with mysql -N -B --raw < file > out.jsonl (JSON_OBJECT needs MySQL 5.7.8+ or MariaDB 10.2.3+; one object per line, which is exactly the commands' JSONL input), how to copy the outputs and the uploads/media directories to the staging work dir, and that every map is read-only input (no secrets). + +Todo .planning/todos/pending/journal-imported-html-admin-resave.md (summercms.go planning repo, separate docs commit): RESEARCH Pitfall 2 — imported pl content is HTML and an admin re-save of an imported post runs FormatHTML and replaces the body with a raw-HTML-omitted comment; until sm-journal-plugin converts or passes through HTML content, do not edit imported posts in /gfadmin. The host README (Task 3) repeats the warning. + +Commits: plugin (feat: grzyby:import-categories for category content), scripts (feat: runbook categories, media and author steps; PHP export queries), and the todo in summercms.go (docs: todo for Journal HTML content re-save). + + go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal)$' && go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby vet ./... && go -C ../sm-grzybyfunkcjonalne-app build -o bin/grzybyfunkcjonalne . && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --go-only + Non-zero exit; output contains "--- FAIL" or "smoke:" followed by an error; "smoke: ok (go)" or "--- PASS: TestBootUserTranslateJournal" is absent. + + + - The rehearsal's categories response carries the Cordyceps w pigułce marker in the D-21 field for cordyceps and lists amanita-muscaria with post_count 0. + - Re-running grzyby:import-categories with the same map reports 0 updated and 1 unchanged. + - migrate.sh runs migrate, the pl default switch and its assertion, the post import, categories, media, featured images and author assignment in that order; `grep -n 'journal:import-posts' scripts/migrate.sh` appears after the line containing `code = 'pl'`. + - featured-images.sql and categories.sql each emit one JSON_OBJECT per row, and the README states the server checks for the model morph and the translate table names. + - The boot test expects both grzyby:import-featured-images and grzyby:import-categories. + - The todo file exists under .planning/todos/pending/ and names Pitfall 2. + + Category content, media, featured images and the /autor author are all covered by one ordered runbook with PHP-side export queries ready for the server. + + + + Task 3: Two-process deploy files, build script, urls.txt and the full local rehearsal over every old URL + ../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-grzybyfunkcjonalne-app/deploy/env.example, ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example, ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/.gitignore + ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-summercmsio-app/scripts/build.sh, ../sm-summercmsio-app/scripts/check-deploy.sh, ../sm-summercmsio-app/deploy/nginx/summercms.io.conf, ../sm-summercmsio-app/deploy/supervisor/summercms-io.conf, ../sm-summercmsio-app/deploy/env.example, ../sm-summercmsio-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (URL Inventory, Code Examples nginx and supervisor, Pitfalls 5, 6, 7, 12, Security Domain), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-07, D-09, D-10, D-15, D-18, D-23) + scripts/build.sh (summercms.io build.sh conventions; one mode, against the framework working tree because the host requires summercms v0.0.0 through a local replace): tool loop go, pnpm, node, git, rsync; SUMMERCMS_FRAMEWORK (default ../summercms.go); die when vue-grzybyfunkcjonalne-app/package.json or any plugin go.mod is missing (run git submodule update --init); install the summer CLI to a temp GOBIN and run summer build from the host root, then die when git status shows main.go or plugins.gen.go changed ("commit them first"); go vet ./...; CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o bin/grzybyfunkcjonalne .; pnpm -C vue-grzybyfunkcjonalne-app install --frozen-lockfile and build; rsync -a --delete vue-grzybyfunkcjonalne-app/.output/ build/nuxt/.output/; print "build: bin/grzybyfunkcjonalne and build/nuxt/.output ready". .gitignore adds /build/. + +deploy/nginx/grzybyfunkcjonalne-staging.conf (comment header: staging vhost on the PHP site's server next to the live Winter vhost, which stays untouched, D-10; server name and paths are confirmed at 16-04): HTTP server with the ACME webroot location and a 301 to HTTPS; HTTPS server for staging.grzybyfunkcjonalne.pl with the four certbot lines, gzip as summercms.io. Locations: location ^~ /backend returns 404 (D-09); location ^~ /gfadmin and location ^~ /_journal/ proxy to http://127.0.0.1:8096 with proxy_http_version 1.1, Host $host, X-Real-IP, X-Forwarded-For $proxy_add_x_forwarded_for, X-Forwarded-Proto $scheme, client_max_body_size 64m (config/http.yaml upload_bytes); a regex location ^/storage/app/(uploads/public|media)/.+\.(?:jpe?g|png|gif|webp)$ with root /srv/grzybyfunkcjonalne/go, add_header X-Content-Type-Options nosniff always, expires 30d (Pitfall 7, RESEARCH static.go warning); a plain prefix location /storage/ returning 404 — it must not use the ^~ modifier, because ^~ would stop nginx before the regex image location is tried; location / with limit_except GET HEAD deny all, proxy to http://127.0.0.1:3016 with the same headers (Pitfall 5: X-Forwarded-For reaches Nuxt, which forwards it to Go). + +deploy/supervisor/grzybyfunkcjonalne-go.conf: [program:grzybyfunkcjonalne-go] command /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne serve --addr 127.0.0.1:8096, directory /srv/grzybyfunkcjonalne/go (config/ and .env are read from it), user grzybyfunkcjonalne, environment SUMMER_ENV="production", autostart, autorestart, startsecs 3, stopsignal TERM, stopwaitsecs 15, log settings as summercms.io. deploy/supervisor/grzybyfunkcjonalne-nuxt.conf: [program:grzybyfunkcjonalne-nuxt] command /usr/bin/node --env-file=/srv/grzybyfunkcjonalne/nuxt/.env /srv/grzybyfunkcjonalne/nuxt/.output/server/index.mjs, directory /srv/grzybyfunkcjonalne/nuxt, same user and restart/log settings, environment NODE_ENV="production". deploy/env.example: SUMMER_DATABASE__DSN as postgres://grzybyfunkcjonalne:@127.0.0.1:5432/grzybyfunkcjonalne?sslmode=disable, the three secrets SUMMER_APP__KEY, SUMMER_ADMIN__JWT__SECRET and SUMMER_GOLEM15__USER__JWT__SECRET with empty values and their generation hints (summercms.io wording), plus SUMMER_URL=https://staging.grzybyfunkcjonalne.pl (Pitfall 6: RSS links and Go origin must be https behind nginx); header naming the server path, owner and mode 0640. deploy/nuxt.env.example: NITRO_HOST=127.0.0.1, NITRO_PORT=3016, NUXT_JOURNAL_API_BASE=http://127.0.0.1:8096/_journal/api/v1, NUXT_SITE_URL=https://staging.grzybyfunkcjonalne.pl, NUXT_SITE_INDEXABLE=false (staging is never indexed; production removes the line), NUXT_PUBLIC_GA_ID= (empty on staging; production sets G-MLSV7WJ6W4, D-23). + +scripts/check-deploy.sh (adapt summercms.io): nginx -t in a temp prefix with the certbot paths swapped for a throwaway certificate, listen ports moved to loopback 18480/18443 and the root path pointed at a temp dir; then python3 configparser checks both supervisor programs for command, directory, user, autostart, autorestart, stopsignal, that the Go command contains serve --addr 127.0.0.1:8096 and the Nuxt command contains --env-file= and .output/server/index.mjs; then greps the nginx file for X-Content-Type-Options nosniff, location ^~ /backend, location ^~ /gfadmin, location ^~ /_journal/ and limit_except GET HEAD, and dies if it finds location ^~ /storage/; prints "check-deploy: ok". + +scripts/urls.txt (grammar [remote] STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]; lines marked remote are checked only with --url because no nginx runs locally): fetch https://grzybyfunkcjonalne.pl/sitemap.xml now and write 200 PATH for every loc (71 at research time: /, /blog, seven categories, 62 posts; RESEARCH says re-pull before freezing). Then add: 200 /blog?page=2 contains=?page=2 (canonical keeps the query, A7); 200 /blog?page=6; 404 /blog?page=7; 404 /blog?page=abc; 200 /cordyceps?page=2; 200 /reishi?page=2; 404 /chaga?page=2 (pages exist only where a category has more than 10 published posts in the export: cordyceps 14, reishi 13); 200 /amanita-muscaria (D-24); 200 /kontakt contains=mailto:kontakt@grzybyfunkcjonalne.pl; 200 /trwaja-prace contains=Niebawem; 200 /rss.xml type=application/rss+xml; 200 /sitemap.xml type=application/xml; 200 /robots.txt contains=Disallow:; 404 /404; 404 /error; 404 /nieistnieje; 404 /rss; 404 /feed; 404 /blog/badania-nad-cordycepsem (base slug); 404 /en/blog (D-16); 200 /autor/admin; 404 /autor/nobody; 404 /backend; 200 /_journal/api/v1/posts type=application/json; remote 200 /storage/app/media/las.webp type=image/; remote 404 /storage/app/uploads/public/x.php; remote 404 /storage/config.yaml. + +scripts/smoke.sh full mode (default, no flag): requires the build.sh outputs; does the --go-only setup with --author-login and --author-slug admin (before invoking migrate.sh the script runs bin migrate, which is idempotent, and then creates that admin with the cabana command admin:create --email admin@example.test --login admin --superuser, piping a random password from /dev/urandom on stdin, never printed or passed as a flag; migrate.sh's own migrate step is then a no-op and its fresh-database check still passes because no post exists yet; migrate.sh gains no admin creation of its own and the README runbook lists the same order: migrate, admin:create, migrate.sh), then starts Nuxt from build/nuxt/.output/server/index.mjs on SMOKE_NUXT_PORT (default 13016) with NUXT_JOURNAL_API_BASE pointing at the rehearsal Go server and NUXT_SITE_INDEXABLE=false; checks every non-remote urls.txt line, sending paths that start with /gfadmin, /_journal or /backend to Go and everything else to Nuxt, with X-Forwarded-For: 203.0.113.7; prints "smoke: ok (N urls)". --url BASE checks every line (remote included) against BASE only and starts nothing. Any mismatch prints "smoke: FAIL : got " and exits 1. + +README.md (host; it may name the site): what the app is (headless Go host for the Nuxt SSR site, two processes), layout table (summercms.io README style: vue-grzybyfunkcjonalne-app, plugins/golem15/{user,translate,journal}, plugins/grzyby, config, scripts, deploy), build and checks commands, the migration runbook (D-25 order, fresh database only, Pitfall 3 recovery: drop, migrate, pl default, import again), the PHP export steps (scripts/php-export/README.md), the admin at /gfadmin, the Pitfall 2 warning, ports and env files, and a pointer to DEPLOY.md (written in 16-04). + +Commits: deploy and scripts (feat: staging deploy files and full URL rehearsal), README (docs: host README). + + bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh + Non-zero exit; output contains "check-deploy:" followed by an error, "build:" followed by an error, or "smoke: FAIL"; the lines "check-deploy: ok" or "smoke: ok (" are absent. + ! grep -nE '^(SUMMER_APP__KEY|SUMMER_ADMIN__JWT__SECRET|SUMMER_GOLEM15__USER__JWT__SECRET)=.' ../sm-grzybyfunkcjonalne-app/deploy/env.example && grep -qE '^SUMMER_DATABASE__DSN=.*' ../sm-grzybyfunkcjonalne-app/deploy/env.example + Non-zero exit: a secret line in the template has a value, or the DSN line lacks the <password> marker (a real credential was written). + + + - check-deploy.sh prints "check-deploy: ok" (nginx -t passes; both supervisor programs parse with the required keys). + - The local rehearsal passes every non-remote urls.txt line, including all live sitemap URLs, /blog?page=2 keeping ?page=2 in the canonical, /amanita-muscaria 200, /autor/admin 200, /backend 404, /en/blog 404 and /rss.xml with an RSS content type. + - urls.txt contains one 200 line for every loc in the live sitemap fetched during this task (the count is recorded in the SUMMARY). + - The nginx file has no `location ^~ /storage/` and has the image regex location with X-Content-Type-Options nosniff. + - deploy/nuxt.env.example sets NUXT_SITE_INDEXABLE=false and an empty NUXT_PUBLIC_GA_ID. + - README.md describes the runbook order and the Pitfall 2 warning. + + The host builds both processes, its deploy files pass nginx -t and supervisor parsing, and a local rehearsal answers every old URL with the expected status. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Internet → nginx → Go admin | /gfadmin and the Journal write routes are reachable on the public domain | +| Internet → nginx → disk | Uploaded and media files are served from the Go host's storage directory | +| PHP database export → Go commands | JSONL maps produced on the PHP server drive file copies and row writes | +| Operator → server secrets | .env files hold the database DSN and JWT secrets | +| Rehearsal scripts → Postgres | smoke.sh creates and drops databases with an admin DSN | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-16-10 | Elevation of Privilege | /gfadmin admin exposure | high | mitigate | cabana backend auth unchanged; backend.cookie_secure stays true (refused false in production); /backend has no Go route (boot test) and nginx returns 404 for it | +| T-16-11 | Tampering | same-origin XSS through files under /storage | high | mitigate | nginx serves only jpe?g/png/gif/webp under /storage/app/{uploads/public,media} with X-Content-Type-Options nosniff; every other /storage/ path is 404 (plain prefix, not ^~); no Go static handler is mounted; check-deploy.sh asserts all three | +| T-16-12 | Tampering | grzyby:import-featured-images file paths | medium | mitigate | validDiskName rejects separators and dot-dot; the source path must stay inside --source (filepath.Rel); content type allowlist image/jpeg, png, gif, webp; blobs are written only under attach.BlobKey | +| T-16-13 | Information Disclosure | deploy/env.example, nuxt.env.example | medium | mitigate | Templates carry empty values only (negative grep); .env stays on the server, .gitignore keeps it out; smoke.sh generates throwaway secrets | +| T-16-14 | Tampering | journal:import-posts re-runs after the pl switch | medium | mitigate | migrate.sh refuses a database that already holds posts and asserts pl is the only default before importing (Pitfalls 1 and 3) | +| T-16-15 | Spoofing | X-Forwarded-For through nginx and Nuxt | medium | mitigate | nginx appends $proxy_add_x_forwarded_for; Go trusts only 127.0.0.1/32 and reads the rightmost untrusted hop, so a client-supplied XFF cannot pick another bucket | +| T-16-16 | Denial of Service | Nuxt location | low | mitigate | limit_except GET HEAD on location /; Go keeps its 120/min journal buckets; Nuxt swr caching (16-02) | +| T-16-17 | Tampering | smoke.sh with SMOKE_ADMIN_DSN | medium | mitigate | The script creates grzyby_smoke_ and drops only that name on exit; it never runs migrate or DROP against any other database (T-02-05 precedent) | +| T-16-SC | Tampering | Go module additions | high | mitigate | The plugin uses only modules already in the host graph and named in STACK.md (summercms framework, sm-journal-plugin, gorm, gocloud.dev); no new Go or npm package | + + + +- `bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh` prints "check-deploy: ok". +- `bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh` prints "smoke: ok (N urls)". +- `go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1` stays green. + + + +- Admin at /gfadmin, /backend gone (D-09). +- Featured images and category content import idempotently from PHP-side maps (D-21, D-22). +- One ordered runbook: migrate, pl default, single import, categories, media, images, author (D-17, D-25). +- Staging deploy files for two processes pass their checks (D-07, D-10), and a local rehearsal answers every live URL (D-15). + + + +Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-SUMMARY.md` when done, recording the live sitemap URL count frozen into urls.txt. + diff --git a/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-PLAN.md b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-PLAN.md new file mode 100644 index 0000000..bde4c89 --- /dev/null +++ b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-PLAN.md @@ -0,0 +1,226 @@ +--- +phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views +plan: 04 +type: execute +wave: 4 +depends_on: ["16-03"] +files_modified: + - ../sm-grzybyfunkcjonalne-app/DEPLOY.md + - ../sm-grzybyfunkcjonalne-app/README.md + - ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf + - ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf + - ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf + - ../sm-grzybyfunkcjonalne-app/deploy/env.example + - ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example + - ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh + - ../sm-grzybyfunkcjonalne-app/scripts/urls.txt +autonomous: false +requirements: [SC-1, SC-3] +user_setup: + - service: PHP site server (staging host) + why: "D-10/D-18: staging runs on the same server as the live Winter site; the executor cannot create DNS records, certificates, database roles or its own SSH access" + env_vars: + - name: STAGING_SSH + source: "An ssh host alias (in ~/.ssh/config) for the PHP server with a user that may write /srv/grzybyfunkcjonalne and run sudo nginx -t, systemctl reload nginx and supervisorctl" + - name: STAGING_URL + source: "The staging base URL, e.g. https://staging.grzybyfunkcjonalne.pl (DNS record and certificate created by the human)" + dashboard_config: + - task: "DNS A/AAAA record for the staging host pointing at the PHP server" + location: "DNS provider of grzybyfunkcjonalne.pl" + - task: "Run scripts/php-export/featured-images.sql and categories.sql on the PHP database and place the two JSONL outputs on the server" + location: "PHP server, mysql client against the live Winter database (read-only queries)" +estimate: + tokens: 80000 + raw_tokens: 80000 + tasks: 3 + confidence: low +must_haves: + truths: + - "D-18 / SC-1: grzybyfunkcjonalne content runs end to end on staging on the PHP site's server: the Go binary (supervisor program grzybyfunkcjonalne-go) and the Nuxt server (grzybyfunkcjonalne-nuxt) behind one staging nginx vhost, with the imported posts." + - "D-15 / D-18: scripts/smoke.sh --url $STAGING_URL passes every line of scripts/urls.txt, which holds every URL of the live sitemap plus the static and negative cases." + - "D-10: the live Winter vhost, php-fpm and the PHP database are not modified; only new files (one nginx vhost, two supervisor programs, /srv/grzybyfunkcjonalne) are added, and the Winter install stays the rollback." + - "D-25: on the staging database pl was made the only default locale before journal:import-posts, and the import ran once into a fresh database." + - "D-21/D-22/D-17: category pillar content, featured images (old /storage/app/uploads/public URLs) and storage/app/media files are present on staging, filled from the PHP database queries and storage copies." + - "D-09: the admin answers at $STAGING_URL/gfadmin and /backend answers 404." + - "D-23: staging renders no Google Analytics script (NUXT_PUBLIC_GA_ID empty)." + - "Staging is not indexable (NUXT_SITE_INDEXABLE=false), so it never competes with the live site in search." + - "The production nginx/DNS switch is not performed by this plan (deferred operational step)." + artifacts: + - path: "../sm-grzybyfunkcjonalne-app/DEPLOY.md" + provides: "server facts, upload/release steps, migration runbook, smoke, rollback, and the deferred cutover notes" + contains: "Rollback" + - path: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf" + provides: "the vhost as installed on the server" + contains: "server_name" + key_links: + - from: "nginx staging vhost on the PHP server" + to: "grzybyfunkcjonalne-go (127.0.0.1:8096) and grzybyfunkcjonalne-nuxt (127.0.0.1:3016)" + via: "proxy_pass by path: /gfadmin and /_journal/ to Go, image files under /storage/app to disk, everything else to Nuxt" + pattern: "proxy_pass" + - from: "grzybyfunkcjonalne-nuxt" + to: "grzybyfunkcjonalne-go" + via: "NUXT_JOURNAL_API_BASE=http://127.0.0.1:8096/_journal/api/v1 in /srv/grzybyfunkcjonalne/nuxt/.env" + pattern: "NUXT_JOURNAL_API_BASE" +--- + +## Phase Goal + +ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme. + +MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted. + +This plan's slice: one imported post loads over HTTPS from the staging host on the PHP server, served by Nuxt from the Go Journal API; then every old URL is checked on staging and the deploy is documented. + + +Deploy the site to staging on the PHP site's server (D-10, D-18): resolve the server prerequisites at a human checkpoint, install the two processes and the vhost, run the migration runbook against the real PHP data, and check every old URL on staging. + +Purpose: SC-1 counts as met once the site is served by SummerCMS on staging (D-18); SC-3 is proven against the live URL list. +Output: a running staging site, DEPLOY.md, deploy files matching what is installed, and a passing remote smoke. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-SUMMARY.md +@../sm-grzybyfunkcjonalne-app/README.md +@../sm-grzybyfunkcjonalne-app/scripts/migrate.sh +@../sm-grzybyfunkcjonalne-app/scripts/smoke.sh +@../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md +@../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf +@../sm-summercmsio-app/DEPLOY.md + + +## Spec-less probe fallback + +Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves. + +## Artifacts this phase produces + +- Staging site at `$STAGING_URL` on the PHP server: directory `/srv/grzybyfunkcjonalne/{go,nuxt,import}` (exact paths recorded in DEPLOY.md), supervisor programs `grzybyfunkcjonalne-go` and `grzybyfunkcjonalne-nuxt`, nginx vhost `grzybyfunkcjonalne-staging.conf`, Postgres database `grzybyfunkcjonalne` (ICU pl-PL). +- `DEPLOY.md` in the host repo (sections: Server facts, Prerequisites, Build and upload, First install, Migration runbook, Smoke, Release, Rollback, Production cutover (not done in this phase)). +- Deploy files and `scripts/check-deploy.sh` updated to the confirmed server facts; `scripts/urls.txt` re-pulled from the live sitemap when it changed. + + + + + Task 1: Server prerequisites on the PHP site's server (staging host, TLS, Postgres, Node, access, PHP export maps) + .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (Pitfall 12, Open Questions 3 and 4, Environment Availability), ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, ../sm-grzybyfunkcjonalne-app/config/database.yaml, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-summercmsio-app/DEPLOY.md + Print a checklist built from the files above and stop for the human. The human: (1) picks the staging host name (default staging.grzybyfunkcjonalne.pl), creates its DNS record pointing at the PHP server and a certbot certificate for it; (2) makes sure PostgreSQL 15 or newer with ICU runs on the server and creates role grzybyfunkcjonalne and database grzybyfunkcjonalne with the ICU command in config/database.yaml (TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'), keeping the password for the DSN; (3) makes sure Node 22.18 or newer is installed and notes its path; (4) creates system user grzybyfunkcjonalne and /srv/grzybyfunkcjonalne writable by the deploy user, confirms ports 8096 and 3016 are free on 127.0.0.1 (or names free ones); (5) gives the executor SSH access as an ~/.ssh/config alias exported as STAGING_SSH, with sudo for nginx -t, nginx reload and supervisorctl, and exports STAGING_URL; (6) runs scripts/php-export/featured-images.sql and categories.sql against the live PHP database after checking the post model morph and translate table names as the README says, and places featured-images.jsonl and categories.jsonl in /srv/grzybyfunkcjonalne/import/; (7) states the absolute paths of the PHP storage/app/uploads/public and storage/app/media directories and that the deploy user can read them; (8) states the admin email and login to create. The executor then runs the verify command and records every confirmed fact (host alias, staging URL, Node path, ports, PHP storage paths, morph and translate tables used, Postgres version) in the SUMMARY "Server facts" section. + + Claude prepared the deploy files, the runbook and the PHP export queries in 16-03 and printed the checklist. + The human does the steps that need DNS, certificates, database roles, server accounts or SSH keys (items 1-8 of the action), then exports STAGING_SSH and STAGING_URL in the session. + + The verify command prints prerequisites-ok. + Type "server ready" with the staging URL and ssh alias once items 1-8 are done. + + test -n "$STAGING_SSH" && test -n "$STAGING_URL" && NODE_V=$(ssh "$STAGING_SSH" 'node -p process.versions.node') && printf '22.18.0\n%s\n' "$NODE_V" | sort -V -C && ssh "$STAGING_SSH" 'psql --version && sudo nginx -t && sudo supervisorctl version && test -s /srv/grzybyfunkcjonalne/import/featured-images.jsonl && test -s /srv/grzybyfunkcjonalne/import/categories.jsonl' && { curl -sS -o /dev/null --max-time 10 "$STAGING_URL/"; test $? -ne 6; } && echo prerequisites-ok + Non-zero exit or "prerequisites-ok" absent: STAGING_SSH or STAGING_URL unset, ssh fails, Node is older than 22.18, psql/nginx/supervisorctl missing, nginx -t fails, an import map is missing or empty, or curl exit 6 (staging host name does not resolve). + + + - STAGING_SSH and STAGING_URL are set and ssh works with the needed sudo commands. + - Node on the server is 22.18 or newer; psql reports PostgreSQL 15 or newer; nginx -t passes before any change. + - /srv/grzybyfunkcjonalne/import/featured-images.jsonl and categories.jsonl exist and are non-empty. + - The staging host name resolves. + - The SUMMARY "Server facts" section lists host alias, staging URL, Node path, ports, PHP storage paths, morph and translate tables, Postgres version. + + Every server fact the deploy needs is confirmed and recorded, and the PHP export maps are on the server. + + + + Task 2: Tracer — deploy both processes, run the migration runbook on the PHP data and serve one imported post over HTTPS on staging + D-07/D-10: the two-process topology (supervisor programs, vhost, ports) is what the production cutover reuses; changing it means redoing the server layout. + STAGING_SSH and STAGING_URL are set and Task 1's verify printed prerequisites-ok in this session. + ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-grzybyfunkcjonalne-app/deploy/env.example, ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh + The Task 1 "Server facts" in this plan's SUMMARY, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-go.conf, ../sm-grzybyfunkcjonalne-app/deploy/supervisor/grzybyfunkcjonalne-nuxt.conf, ../sm-grzybyfunkcjonalne-app/deploy/env.example, ../sm-grzybyfunkcjonalne-app/deploy/nuxt.env.example, ../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-summercmsio-app/DEPLOY.md (upload, release and cutover order) + Align the deploy files with the confirmed server facts (server_name, certificate paths, /srv paths, user, Node path, ports) and re-run scripts/check-deploy.sh; commit the changes in the host (chore: match staging deploy files to the server). Run scripts/build.sh locally. + +Upload with rsync over $STAGING_SSH: bin/grzybyfunkcjonalne, config/ and scripts/ to /srv/grzybyfunkcjonalne/go/; build/nuxt/.output/ to /srv/grzybyfunkcjonalne/nuxt/.output/; the journal export (/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export) to /srv/grzybyfunkcjonalne/import/journal-export/. Never rsync a local .env. + +On the server, write /srv/grzybyfunkcjonalne/go/.env from deploy/env.example: the DSN from Task 1, and SUMMER_APP__KEY from bin/grzybyfunkcjonalne key:generate, SUMMER_ADMIN__JWT__SECRET and SUMMER_GOLEM15__USER__JWT__SECRET from head -c 32 /dev/urandom | base64, all generated on the server and never printed or copied back; SUMMER_URL=$STAGING_URL; owner root, group grzybyfunkcjonalne, mode 0640. Write /srv/grzybyfunkcjonalne/nuxt/.env from deploy/nuxt.env.example with NUXT_SITE_URL=$STAGING_URL, NUXT_SITE_INDEXABLE=false and NUXT_PUBLIC_GA_ID empty (D-23). + +Run in /srv/grzybyfunkcjonalne/go as the service user: bin/grzybyfunkcjonalne migrate; bin/grzybyfunkcjonalne admin:create --email --login --superuser with a random password piped on stdin from a 0600 file /srv/grzybyfunkcjonalne/admin-initial-password (never on the command line; the human changes it with admin:reset-password); then scripts/migrate.sh --bin bin/grzybyfunkcjonalne --workdir . --export ../import/journal-export --categories-map ../import/categories.jsonl --media --featured-map ../import/featured-images.jsonl --uploads --author-login --author-slug admin (D-25 order; it refuses a database that already holds posts, so a failed run means drop, re-create, migrate and run again, Pitfall 3). Keep its full output in the SUMMARY, including the gap report. + +Install deploy/supervisor/*.conf into the server's supervisor conf.d and deploy/nginx/grzybyfunkcjonalne-staging.conf into the nginx sites directory as new files; do not edit the live Winter vhost or php-fpm (D-10). sudo nginx -t must pass before sudo systemctl reload nginx; then supervisorctl reread, update and start both programs. + + bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh && ssh "$STAGING_SSH" 'sudo supervisorctl status grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt' | grep -c RUNNING | grep -qx 2 && curl -fsS "$STAGING_URL/blog/blogbadania-nad-cordycepsem" | grep -q 'Najnowsze Badania nad Cordycepsem' && curl -fsS "$STAGING_URL/_journal/api/v1/posts/blogbadania-nad-cordycepsem" | grep -q '"featured_image_url"' && echo staging-tracer-ok + Non-zero exit or "staging-tracer-ok" absent: check-deploy fails, fewer than two supervisor programs are RUNNING, the post page does not answer 200 with its title over HTTPS, or the API answer lacks featured_image_url. + + + - Both supervisor programs are RUNNING on the server and nginx -t passed before the reload. + - $STAGING_URL/blog/blogbadania-nad-cordycepsem answers 200 over HTTPS with the post title, rendered by Nuxt from the Go Journal API. + - migrate.sh printed "migrate: ok"; its gap report is recorded in the SUMMARY. + - The live Winter vhost file and php-fpm configuration are unchanged (the SUMMARY lists only new files installed). + - No secret value appears in the repo, the SUMMARY or the shell history (generated on the server). + + Staging serves an imported Journal post over HTTPS from the Go binary and the Nuxt server running side by side with the live PHP site. + + + + Task 3: Smoke every old URL on staging, verify images and media, write DEPLOY.md + Task 2's verify printed staging-tracer-ok in this session. + ../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/DEPLOY.md, ../sm-grzybyfunkcjonalne-app/README.md + ../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-summercmsio-app/DEPLOY.md, the Task 1 "Server facts" and Task 2 runbook output in this plan's SUMMARY, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-10, D-18, deferred production cutover) + Re-pull https://grzybyfunkcjonalne.pl/sitemap.xml; when its loc set differs from the 200 lines frozen in urls.txt by 16-03, update urls.txt and record the difference (RESEARCH: live facts drift). Run scripts/smoke.sh --url "$STAGING_URL" (every line, remote lines included). Additionally check, from the staging API, one featured_image_url and one /storage/app/media/ URL referenced by a post body: each answers 200 with an image content type and X-Content-Type-Options nosniff; /gfadmin answers a non-404 status; the homepage has no googletagmanager script; /robots.txt disallows everything (NUXT_SITE_INDEXABLE=false). Any failing line is fixed at its source (deploy file, data step or app code; app or layer fixes go through their repos, tags and gitlinks) and the smoke re-run until it passes — a URL from the live sitemap is never dropped from urls.txt to make it pass (D-15). + +DEPLOY.md (host repo, summercms.io DEPLOY.md structure): Server facts (from Task 1, no secrets), Prerequisites, Build and upload, First install (.env files, migrate, admin:create with stdin password, migrate.sh with every flag and the D-25 order), Smoke (smoke.sh --url), Release (rebuild, rsync, supervisorctl restart of both programs; swr caches expire by themselves), Rollback (stop both programs and remove the staging vhost; the Winter site was never touched, D-10), Do not re-save imported posts in /gfadmin (Pitfall 2), Production cutover (not part of Phase 16: switch the apex vhost to these upstreams, set NUXT_SITE_URL to the apex, remove NUXT_SITE_INDEXABLE, set NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 and SUMMER_URL to the apex; Winter stays as rollback). README.md points to DEPLOY.md. Commit (docs: staging deploy guide) and, if urls.txt changed, (test: re-pull live sitemap URLs). + + bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --url "$STAGING_URL" + Non-zero exit; output contains "smoke: FAIL"; the line starting "smoke: ok (" is absent. + HOME_HTML=$(curl -fsS "$STAGING_URL/") && ! printf '%s' "$HOME_HTML" | grep -q googletagmanager && curl -fsS "$STAGING_URL/robots.txt" | grep -qx 'Disallow: /' && grep -q '^## Rollback' ../sm-grzybyfunkcjonalne-app/DEPLOY.md && echo staging-checks-ok + Non-zero exit or "staging-checks-ok" absent: the staging homepage loads analytics, robots.txt does not disallow everything, or DEPLOY.md has no Rollback section. + Open $STAGING_URL/, /blog, /blog?page=2, a post, /cordyceps (pillar content above the list), /amanita-muscaria, /kontakt and /autor/admin and compare with the live site: the refreshed design (D-01, D-03) reads cleanly, the Polish copy is complete (D-11, D-12), featured images and inline media show. Log in at $STAGING_URL/gfadmin with the initial password from /srv/grzybyfunkcjonalne/admin-initial-password, change it with admin:reset-password, then delete that file. Reply "staging approved" or list issues. + + + - smoke.sh --url $STAGING_URL passes every urls.txt line, including every live sitemap URL, the negative cases and the remote storage lines. + - One featured image URL and one media URL answer 200 with an image content type and X-Content-Type-Options nosniff on staging. + - Staging renders no analytics and its robots.txt disallows everything. + - DEPLOY.md has the sections Server facts, Prerequisites, Build and upload, First install, Smoke, Release, Rollback and Production cutover, and contains no secret value. + - The human-check reply is recorded in the SUMMARY. + + Every old URL answers correctly on staging, images and media load, and the deploy and rollback are documented. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Executor → PHP production server | The deploy runs next to the live Winter site | +| Server .env files → processes | Secrets live only on the server | +| Public internet → staging host | Staging is reachable before cutover | +| PHP database → JSONL maps | Read-only queries against the live database | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-16-18 | Tampering | live Winter vhost, php-fpm, PHP database | high | mitigate | Only new files are installed (staging vhost, two supervisor programs, /srv/grzybyfunkcjonalne); nginx -t must pass before reload; PHP queries are SELECT-only; the SUMMARY lists every file touched on the server (D-10) | +| T-16-19 | Information Disclosure | /srv/grzybyfunkcjonalne/go/.env, nuxt/.env | high | mitigate | Secrets are generated on the server, written root:grzybyfunkcjonalne 0640, never printed, never rsynced, never committed; DEPLOY.md names keys only | +| T-16-20 | Elevation of Privilege | initial admin account | high | mitigate | Password piped on stdin from a 0600 file, never a command-line flag; the human-check changes it with admin:reset-password and deletes the file; /gfadmin keeps cabana auth with Secure cookies in production | +| T-16-21 | Information Disclosure | staging duplicate content and analytics | medium | mitigate | NUXT_SITE_INDEXABLE=false (robots.txt disallows all, checked); NUXT_PUBLIC_GA_ID empty on staging (checked) | +| T-16-22 | Information Disclosure | featured-images.jsonl, categories.jsonl | low | accept | They hold public post slugs, file names and category copy only; they stay under /srv/grzybyfunkcjonalne/import and are not committed | +| T-16-SC | Tampering | packages on the server | high | mitigate | No package is installed by this plan; the Nuxt .output and the Go binary are built locally from locked dependencies (16-01..16-03) and uploaded; Node and PostgreSQL come from the server's existing package sources at the human checkpoint | + + + +- Task 1 prints prerequisites-ok; Task 2 prints staging-tracer-ok. +- `bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --url "$STAGING_URL"` prints "smoke: ok (N urls)". +- The human-check reply "staging approved" is recorded. + + + +- SC-1 is met on staging: Go + Nuxt + imported posts serve the site on the PHP server (D-18). +- Every live URL and the negative cases pass on staging (D-15). +- The Winter site is untouched and remains the rollback (D-10); production cutover is documented, not performed. + + + +Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-SUMMARY.md` when done, with the Server facts, the runbook output and gap report, the smoke result and the human-check reply. + diff --git a/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-05-PLAN.md b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-05-PLAN.md new file mode 100644 index 0000000..7a2197c --- /dev/null +++ b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-05-PLAN.md @@ -0,0 +1,278 @@ +--- +phase: 16-grzybyfunkcjonalne-pl-on-reusable-blog-views +plan: 05 +type: execute +wave: 5 +depends_on: ["16-04"] +files_modified: + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/analytics.test.ts + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt + - ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app + - ../vue-journal-layer/package.json + - ../vue-journal-layer/README.md + - ../vue-journal-layer/tests/routes.test.ts + - ../vue-journal-layer/tests/client.test.ts + - ../vue-journal-layer/tests/pagination.test.ts + - ../vue-journal-layer/tests/htmlGate.test.ts + - ../vue-journal-layer/tests/sitemap.test.ts + - ../vue-journal-layer/tests/seo.test.ts + - ../vue-journal-layer/tests/i18n.test.ts + - ../sm-grzybyfunkcjonalne-app/boot_test.go + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go + - ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/export/badania-nad-cordycepsem.json + - scripts/check-phase16.sh + - .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md +autonomous: true +requirements: [SC-4] +user_setup: + - service: local PostgreSQL (Go integration tests) + why: "Docker is not usable for this user; the grzyby console integration tests need a Postgres >= 15 with ICU through an admin DSN, or Docker for testcontainers" + env_vars: + - name: GRZYBY_TEST_ADMIN_DSN + source: "Same kind of DSN as SMOKE_ADMIN_DSN (maintenance database, role with CREATEDB); may be the same value" +estimate: + tokens: 120000 + raw_tokens: 120000 + tasks: 3 + confidence: low +must_haves: + truths: + - "SC-4 / CLAUDE.md rule 3: every piece of new phase code has unit tests in this last plan: layer utils (node --test), the site app (node --test, including an SSR test of the built server against the fixture API) and the app-local Go commands (go test)." + - "D-15: the SSR test asserts the status of every PHP URL form on the built app, including static routes beating /:category (RESEARCH A2) and real 404s for unknown roots, /404, /error and out-of-range or non-numeric pages (A5)." + - "D-16: the SSR test asserts /en and /en/blog answer 404 and pl strings render without a prefix." + - "D-23: the SSR test asserts no analytics without NUXT_PUBLIC_GA_ID, gtag for G-MLSV7WJ6W4 with it, and no script for a malformed id." + - "D-06/D-13/D-24: layer unit tests cover route normalization, sitemap entries built from journal.routes, include_empty=1 on every categories request and canonical page titles." + - "T-16-01: htmlGate tests mirror the Go TestFormatHTMLRejectsUnsafeHTML cases (script, iframe, event handler, javascript:, vbscript:, data:) and the SSR test proves an unsafe fixture body renders the blocked notice." + - "D-22: Go tests prove grzyby:import-featured-images writes the blob at attach.BlobKey(disk_name) and the system_files row, is idempotent, resolves base slugs through the export, and refuses traversal disk names (T-16-12)." + - "D-21: Go tests prove grzyby:import-categories prefers pl values, writes the content field, and reports unchanged on a re-run." + - "D-09: the boot test asserts backend.uri resolves to /gfadmin, admin routes live under /gfadmin/ and none under /backend (T-16-10)." + - "Pure tests never touch the network or a database; DB tests use only throwaway databases they create (T-16-23) and fail, not skip, when no database is available outside -short." + - "go vet and go test stay green in every Go repo touched; scripts/check-phase16.sh --all ends with Phase 16 gate passed." + artifacts: + - path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts" + provides: "SSR test of the built server against the fixture API" + contains: "startNuxtServer" + - path: "../vue-journal-layer/tests/htmlGate.test.ts" + provides: "Go-parity cases for the HTML gate" + contains: "vbscript" + - path: "../vue-journal-layer/tests/routes.test.ts" + provides: "journal.routes normalization and path filling" + contains: "normalizeJournalRoutes" + - path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go" + provides: "D-22 command tests" + contains: "TestImportFeaturedImages" + - path: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go" + provides: "D-21 command tests" + contains: "TestImportCategories" + - path: "scripts/check-phase16.sh" + provides: "fail-closed phase gate across the three repos" + contains: "Phase 16 gate passed" + key_links: + - from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts" + to: "../vue-journal-layer/testing/fixture-api.mjs and nuxt-server.mjs" + via: "imported from node_modules/vue-journal-layer/testing (plain .mjs, no type stripping needed)" + pattern: "vue-journal-layer/testing" + - from: "../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go" + to: "Postgres (GRZYBY_TEST_ADMIN_DSN or testcontainers)" + via: "creates grzyby_test_ with ICU pl-PL, migrates translate, journal and attach tables, drops it on exit" + pattern: "GRZYBY_TEST_ADMIN_DSN" + - from: "scripts/check-phase16.sh" + to: "layer, app and host test commands" + via: "fails on FAIL, SKIP, no tests to run, not ok, or a missing named PASS line" + pattern: "no tests to run" +--- + +## Phase Goal + +ROADMAP Goal (verbatim, not in user-story format): grzybyfunkcjonalne.pl runs on SummerCMS with a new blog views layer that other Golem15 blogs can reuse, instead of a one-off theme. + +MVP_MODE note: no user story is invented; run `/msd-mvp-phase 16` to reframe the Goal line if wanted. + +This plan's slice: one SSR test boots the built site against the fixture API and proves the URL map end to end; then unit tests cover every layer util, the app utils and the Go commands, and one gate script runs them all. + + +Deliver full unit test coverage for Phase 16 code as the phase's last plan (CLAUDE.md rule 3, SC-4): the built site (SSR), the layer utils, the app utils and content, the app-local Go commands and the host boot, plus a fail-closed phase gate and the validation sign-off. + +Purpose: SC-4. +Output: test files in the three repos, `scripts/check-phase16.sh`, an updated 16-VALIDATION.md. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-01-SUMMARY.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-02-SUMMARY.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-03-SUMMARY.md +@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-SUMMARY.md +@../sm-summercmsio-app/vue-summercmsio-app/tests/scrollSpy.test.ts +@../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts +@../sm-journal-plugin/admin_harness_test.go +@../sm-journal-plugin/classes/format_html_test.go +@scripts/check-phase15.sh + + +## Spec-less probe fallback + +Visible skip: the phase has no REQUIREMENTS.md IDs to probe, so no probe predicates are generated this run. ROADMAP SC-1..SC-4 and CONTEXT D-01..D-25 are the source of truth for must_haves. + +## Artifacts this phase produces + +- Package scripts `test` (`node --test --test-reporter=tap tests/*.test.ts`) in `vue-journal-layer` and `vue-grzybyfunkcjonalne-app`. +- Layer tests `tests/{routes,client,pagination,htmlGate,sitemap,seo,i18n}.test.ts`. +- App tests `tests/ssr.test.ts`, `tests/analytics.test.ts`, `tests/content.test.ts`; fixture post `test-niebezpieczny-wpis` (unsafe body) with matching expect lines. +- Go tests `TestReadFeaturedImageMap`, `TestValidDiskName`, `TestFeaturedImageSourceEscape`, `TestImportFeaturedImages`, `TestImportFeaturedImagesDryRun`, `TestReadCategoryMap`, `TestImportCategories` (package `console` of plugin `golem15.grzyby`), harness `TestMain` with env `GRZYBY_TEST_ADMIN_DSN`; host tests `TestBackendURIGfadmin`, `TestGrzybyCommandsRegistered`. +- Gate script `scripts/check-phase16.sh` (summercms.go, Phase 15 precedent `scripts/check-phase15.sh`) with `--all`, env overrides `PHASE16_LAYER`, `PHASE16_HOST`, ending with `Phase 16 gate passed`. + + + + + Task 1: Tracer test — the built site answers every PHP URL form correctly against the fixture API (tests/ssr.test.ts) + ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app + ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/api/posts.json, ../vue-journal-layer/testing/fixture-api.mjs, ../vue-journal-layer/testing/nuxt-server.mjs, ../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-RESEARCH.md (URL Inventory, Validation Architecture), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-15, D-16, D-23, D-24) + Add the package script test = node --test --test-reporter=tap tests/*.test.ts and a README line that pnpm build must run before pnpm test (the summercms.io output.test.ts convention). Add fixture post test-niebezpieczny-wpis whose content_html contains a script tag and an onerror attribute, and an expect.txt line 200 /blog/test-niebezpieczny-wpis contains=journal-post-body--blocked notcontains=onerror. + +tests/ssr.test.ts imports startFixtureApi from vue-journal-layer/testing/fixture-api.mjs and startNuxtServer from vue-journal-layer/testing/nuxt-server.mjs (resolved from node_modules; plain .mjs because Node does not strip types there). before() asserts .output/server/index.mjs exists (failing with "run pnpm build before pnpm test") and boots the fixture API and the server (default env); after() closes both. Tests, each a plain test() with node:assert/strict, fetching with redirect manual: +- D-15 status table: 200 for /, /blog, /blog?page=2, /blog/blogbadania-nad-cordycepsem, /cordyceps, /amanita-muscaria, /kontakt, /trwaja-prace, /autor/admin, /rss.xml, /sitemap.xml, /robots.txt; 404 for /blog?page=3, /blog?page=abc, /blog?page=0, /nieistnieje, /rss, /feed, /404, /error, /blog/badania-nad-cordycepsem, /autor/nobody, /blog/tag/anything. +- static routes win over /:category (RESEARCH A2): /kontakt contains mailto:kontakt@grzybyfunkcjonalne.pl and /trwaja-prace contains Niebawem. +- canonical and titles: /blog?page=2 has a canonical link ending /blog?page=2 and a title containing Strona 2; the post page title contains | Grzyby Funkcjonalne (RESEARCH A6, A7). +- D-16: /en and /en/blog answer 404; /blog contains the pl string Artykuły. +- D-24 and D-21: /amanita-muscaria answers 200 with the empty-state string; /cordyceps contains Cordyceps w pigułce before the first post card. +- T-16-01: /blog/test-niebezpieczny-wpis contains journal-post-body--blocked and no onerror; / contains no journal-post-body--blocked (FAQ and homepage copy pass the gate). +- Pitfall 5: a request sent with X-Forwarded-For: 198.51.100.9 makes the fixture API record an x-forwarded-for value containing 198.51.100.9. +- Content types: /rss.xml starts with application/rss+xml; /sitemap.xml lists /blog/blogbadania-nad-cordycepsem and /amanita-muscaria and not /trwaja-prace. +- D-14: no page among /, /kontakt, /blog contains a form element. +- D-23: a second server with NUXT_PUBLIC_GA_ID=G-MLSV7WJ6W4 renders googletagmanager.com/gtag/js?id=G-MLSV7WJ6W4 on /; the default server renders no googletagmanager; a third server with a malformed id (G-X');alert(1)//) renders no googletagmanager and no alert(1). +Commit in the app (test: SSR test of the built site against the fixture API), push, and bump the host gitlink in a separate host commit. + + pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app build && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test + Non-zero exit; TAP output contains "not ok" or "# fail" with a count above 0; the "# pass" line is absent or 0. + + + - tests/ssr.test.ts passes on the built .output with every case above, under node --test with no skipped test. + - Removing the gate call from the layer PostBody would make the T-16-01 case fail (the case asserts the blocked class, not just a 200). + - The fixture API request log is asserted for the forwarded x-forwarded-for value. + - package.json has the test script; the host gitlink points at the pushed app commit. + + The built site is proven against every PHP URL form, the locale, analytics and HTML-gate rules by an automated SSR test. + + + + Task 2: Layer util tests and app util/content tests + ../vue-journal-layer/package.json, ../vue-journal-layer/README.md, ../vue-journal-layer/tests/routes.test.ts, ../vue-journal-layer/tests/client.test.ts, ../vue-journal-layer/tests/pagination.test.ts, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/tests/sitemap.test.ts, ../vue-journal-layer/tests/seo.test.ts, ../vue-journal-layer/tests/i18n.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/analytics.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app + + - normalizeJournalRoutes: defaults round-trip; an override replaces one key and keeps the others; false disables; post without :slug, category without :category, or a path without a leading / throws an Error naming journal.routes.. + - fillJournalPath('/blog/:slug', { slug: 'a b' }) encodes to /blog/a%20b; a slug with / is encoded per segment. + - clampPerPage: 0 → 1, 31 → 30, 'abc' → 9; buildPostListQuery emits page, per_page, sort, category, tag only when set. + - createJournalClient with a fake fetcher: listCategories always sends include_empty=1; findCategory finds a nested child; getPost returns null for errors with status 404 or statusCode 404 and rethrows a 500. + - parsePageParam: undefined → 1, '2' → 2, '0', '-1', '1.5', 'abc', ['2'] → null; pageOutOfRange(3, 2) true, (1, 0) false, (2, 2) false. + - htmlGate: the six Go cases (script, iframe, event handler, javascript:, vbscript:, data:) are unsafe, upper-case variants too; plain headings, tables and links are safe; gateJournalHtml returns { safe: false, html: '' } for unsafe input. + - buildJournalSitemapEntries: uses routes.post and routes.category (custom /:category gives /cordyceps), skips a false route, flattens nested categories, sets lastmod from updated_at. + - postSeoMeta: description falls back to the first 160 characters of the content text; og image becomes absolute; listTitle adds the page title only when page > 1. + - i18n: pl.json and en.json have identical leaf key sets and identical {name} interpolation tokens per key (for example {page}). + - app gaHeadScripts: '' and malformed ids give []; G-MLSV7WJ6W4 gives two scripts with the id in the loader src. + - app content: faq.pl.json has 16 items with non-empty question and answer_html; MAIN_MENU items have known kinds and every labelKey exists in pl.json; every t('…') key used in app/**/*.vue exists in pl.json or in the layer's journal.* keys. + + ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/app/utils/client.ts, ../vue-journal-layer/app/utils/pagination.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/sitemap.ts, ../vue-journal-layer/app/utils/seo.ts, ../vue-journal-layer/i18n/locales/pl.json, ../vue-journal-layer/i18n/locales/en.json, ../sm-journal-plugin/classes/format_html_test.go, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/utils/analytics.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-summercmsio-app/vue-summercmsio-app/tests/scrollSpy.test.ts, ../sm-summercmsio-app/vue-summercmsio-app/tests/output.test.ts (leaves helper) + Layer: add the package script test = node --test --test-reporter=tap tests/*.test.ts and a Testing line in the README. Write the seven test files from the behavior list, importing the utils with explicit .ts paths (scrollSpy.test.ts pattern) and a fake fetcher object for the client (no network). Reuse the summercms.io leaves() helper shape for i18n parity. If a test exposes a bug in a util, fix the util in the same repo (one logical change per commit), and when runtime code changed, tag the layer v0.1.1, bump the app's dependency and lockfile to it, and record that in the SUMMARY. Commit (test: Journal layer util tests) and push. + +App: tests/analytics.test.ts imports ../app/utils/analytics.ts; tests/content.test.ts reads app/data/faq.pl.json, imports ../app/data/menu.ts and scans app/**/*.vue for t('…') and $t('…') keys with a regex, resolving each against pl.json plus the layer's i18n/locales/pl.json read from node_modules/vue-journal-layer (as JSON, no TS import). Commit in the app (test: app util and content tests), push, bump the host gitlink in a separate host commit. + + pnpm -C ../vue-journal-layer test && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test + Non-zero exit; TAP output contains "not ok" or a "# fail" count above 0; either run lacks a "# pass" line with a count above 0. + + + - All seven layer test files and both app unit test files exist and pass under node --test with no skips. + - htmlGate.test.ts contains the six Go cases from sm-journal-plugin classes/format_html_test.go. + - client.test.ts asserts include_empty=1 on the categories request. + - i18n.test.ts fails if a key is added to pl.json without en.json. + - Any util fix is released as layer tag v0.1.1 and the app pins it (or the SUMMARY states no runtime change was needed). + + Every layer util and app util/content rule has a passing unit test. + + + + Task 3: Go tests for the grzyby commands and the host boot, phase gate script and validation sign-off + ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.sum, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/testmain_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/testdata/featured/export/badania-nad-cordycepsem.json, scripts/check-phase16.sh, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md + + - TestReadFeaturedImageMap: two valid lines and a blank line parse to 2 entries; invalid JSON on line 3 returns an error containing "line 3". + - TestValidDiskName: 665f1e2a3b4c5d6e7f8091.png is valid; ../x.png, a/b.png, x, .png, x.png/ and an empty string are invalid. + - TestFeaturedImageSourceEscape: a disk name that would resolve outside --source is refused and counted Failed without reading the file. + - TestImportFeaturedImages (Postgres): a published post with host slug blogbadania-nad-cordycepsem and a map entry keyed by the base slug badania-nad-cordycepsem with --export testdata/featured/export gives Created 1; the mem:// bucket holds attach.BlobKey(disk_name) with the source bytes; the system_files row has attachment_type Golem15\Journal\Models\Post, attachment_id equal to the post id as a string, field featured_images, the map's file_name, content_type and file_size; a second run gives Created 0 and Skipped 1; an unknown slug gives Failed 1; content type text/html gives Failed 1. + - TestImportFeaturedImagesDryRun: no blob, no row, stats report what would be created. + - TestReadCategoryMap and TestImportCategories (Postgres): pl translation values win over base; name, description and the D-21 content field are written; a re-run reports Unchanged; an unknown slug reports Failed. + - TestBackendURIGfadmin: compass.Load("config") gives backend.uri /gfadmin and cabana.AdminPrefix resolves /gfadmin; TestGrzybyCommandsRegistered: the activated golem15.grzyby plugin returns both command names. + + ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_featured_images.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go, ../sm-grzybyfunkcjonalne-app/plugins/grzyby/go.mod, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-journal-plugin/admin_harness_test.go (testcontainers harness, ICU database, fail-closed TestMain), ../summercms.go/modules/lagoon/attach/bucket_test.go (mem:// bucket in tests), ../summercms.go/modules/cabana/prefix.go, scripts/check-phase15.sh, .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-VALIDATION.md + testmain_test.go (package console): when GRZYBY_TEST_ADMIN_DSN is set, create grzyby_test_ with TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL', and drop only that database in TestMain teardown; otherwise start testcontainers postgres (the image and options sm-journal-plugin admin_harness_test.go uses); with neither available and not -short, TestMain exits non-zero with a message naming GRZYBY_TEST_ADMIN_DSN (no false-green skip, Phase 2 house rule); with -short the DB tests call t.Skip and the pure tests still run. Migrate the translate, journal and attach (system_files) tables through the plugins' Migrations() as the journal harness does. Buckets are mem:// (import gocloud.dev/blob/memblob). testcontainers-go and its postgres module are STACK-named and already used by the plugins; add them to the plugin go.mod at the versions the journal plugin pins (v0.44.0). testdata/featured/export/badania-nad-cordycepsem.json holds the journal-export file shape with translations.pl.slug blogbadania-nad-cordycepsem. + +Write the Go tests from the behavior list as plain func TestX(t *testing.T) with testify-free stdlib assertions unless the plugin already uses testify (keep the journal plugin's style). Host boot_test.go gains TestBackendURIGfadmin and TestGrzybyCommandsRegistered next to the existing tests. If a test exposes a bug, fix it in the command in the same repo as a separate commit. + +scripts/check-phase16.sh (summercms.go, Phase 15 gate style: set -euo pipefail, unset FORCE_COLOR, PHASE16_LAYER default ../vue-journal-layer, PHASE16_HOST default ../sm-grzybyfunkcjonalne-app, usage, --all): stages (1) pnpm -C layer test; (2) layer build:playground and smoke-ssr over testing/expect-acme.txt; (3) app pnpm install --frozen-lockfile, build, test, smoke:ssr, smoke:ga; (4) go -C host vet ./... and go -C host test ./... -count=1 -v requiring PASS lines for TestBootUserTranslateJournal, TestCORS, TestBackendURIGfadmin, TestGrzybyCommandsRegistered; (5) go -C host/plugins/grzyby vet ./... and test ./... -count=1 -v -race requiring PASS lines for every Go test named in this task; (6) bash host/scripts/check-deploy.sh. Every stage fails on a non-zero exit, "--- FAIL", "--- SKIP", "no tests to run", "not ok", "DATA RACE" or a missing required PASS line. It ends with "Phase 16 gate passed". + +16-VALIDATION.md: bind each Per-Task Verification Map row to its plan and task (16-01-T2…16-05-T3), fill the Threat Ref column (T-16-01 htmlGate and SSR blocked-notice tests, T-16-10 boot tests, T-16-11 check-deploy, T-16-12 traversal tests, T-16-23 harness), mark rows green after the gate passes, tick the Wave 0 items, set wave_0_complete: true and nyquist_compliant: true. Commits: plugin tests and host tests (test: grzyby command and boot tests) in the host repo; the gate script (test: Phase 16 gate) and the VALIDATION update (docs: Phase 16 validation sign-off) as separate commits in summercms.go. + + go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby vet ./... && go -C ../sm-grzybyfunkcjonalne-app/plugins/grzyby test ./... -count=1 -v -race -run '^(TestReadFeaturedImageMap|TestValidDiskName|TestFeaturedImageSourceEscape|TestImportFeaturedImages|TestImportFeaturedImagesDryRun|TestReadCategoryMap|TestImportCategories)$' && go -C ../sm-grzybyfunkcjonalne-app vet ./... && go -C ../sm-grzybyfunkcjonalne-app test ./... -count=1 -v -run '^(TestBootUserTranslateJournal|TestCORS|TestBackendURIGfadmin|TestGrzybyCommandsRegistered)$' + Non-zero exit; output contains "--- FAIL", "--- SKIP", "no tests to run" or "DATA RACE"; any of the eleven named "--- PASS: Test…" lines is absent. + bash scripts/check-phase16.sh --all + Non-zero exit; the last line is not "Phase 16 gate passed". + + + - Every Go test named in the behavior list prints --- PASS with -race, none skipped, against a throwaway database. + - The harness drops only the grzyby_test_ database it created. + - scripts/check-phase16.sh --all prints "Phase 16 gate passed". + - 16-VALIDATION.md has nyquist_compliant: true, wave_0_complete: true and every row bound to a plan task. + - go vet and go test ./... are green in sm-grzybyfunkcjonalne-app and plugins/grzyby. + + The Go commands and host boot are covered by passing tests and one gate script proves the whole phase green. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Test harness → Postgres | Tests create and drop databases with an admin DSN | +| Gate script → developer machine | The gate runs builds and tests across three repos | +| Tests → security controls | Regression tests are the evidence that the phase's mitigations hold | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-16-23 | Tampering | console TestMain with GRZYBY_TEST_ADMIN_DSN | medium | mitigate | Creates grzyby_test_ and drops only that name; never migrates or drops the DSN's own database (T-02-05 precedent) | +| T-16-24 | Repudiation (false green) | scripts/check-phase16.sh | medium | mitigate | Fails on FAIL, SKIP, no tests to run, not ok, DATA RACE and missing named PASS lines; DB tests fail instead of skipping outside -short | +| T-16-25 | Tampering | regression coverage of T-16-01, T-16-10, T-16-12 | high | mitigate | htmlGate Go-parity cases plus the SSR blocked-notice case (T-16-01); TestBackendURIGfadmin and the boot route assertions (T-16-10); TestValidDiskName and TestFeaturedImageSourceEscape (T-16-12) | +| T-16-SC | Tampering | test dependencies | high | mitigate | Only testcontainers-go v0.44.0 (STACK-named, already pinned by the Journal plugin) is added to plugins/grzyby/go.mod; no npm package is added; node --test is built in | + + + +- `pnpm -C ../vue-journal-layer test` and `pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test` pass. +- The Go command and host tests pass with -race. +- `bash scripts/check-phase16.sh --all` prints "Phase 16 gate passed". + + + +- SC-4: the phase's new code has unit tests, delivered in its last plan. +- Every D-ID with behaviour (D-06, D-09, D-13, D-15, D-16, D-21, D-22, D-23, D-24) has at least one automated test. +- 16-VALIDATION.md is signed off (nyquist_compliant: true). + + + +Create `.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-05-SUMMARY.md` when done. + diff --git a/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/COVERAGE.md b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/COVERAGE.md new file mode 100644 index 0000000..d0fac5d --- /dev/null +++ b/.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/COVERAGE.md @@ -0,0 +1,27 @@ +# API Coverage — Journal API (`/_journal/api/v1`, sm-journal-plugin) + +> Full coverage by default. Opt-outs are explicit, reasoned decisions. + +The phase consumes the internal Journal API that Phase 15 shipped (frozen contract, Phase 15 D-14) plus the two pre-execution additions D-20 and D-21. The consumer is `vue-journal-layer` (16-01) through its typed client, the sitemap source and the `/rss.xml` route. Google Analytics (D-23) is a script tag configured through Nuxt runtime config, not an API integration, so it has no row here. + +| capability | decision | reason | +|---|---|---| +| posts list (GET posts: page, per_page, sort) | INTEGRATE | | +| posts list filtered by category (GET posts?category=) | INTEGRATE | | +| posts list filtered by tag (GET posts?tag=) | INTEGRATE | | +| post by slug (GET posts/{slug}) | INTEGRATE | | +| post previous_post / next_post | INTEGRATE | | +| post related_posts | INTEGRATE | | +| post featured_images / featured_image_url | INTEGRATE | | +| categories tree with include_empty=1 (GET categories) | INTEGRATE | | +| category rich content field (D-21, on categories rows) | INTEGRATE | | +| tags (GET tags) | INTEGRATE | | +| rss (GET rss, proxied at /rss.xml) | INTEGRATE | | +| author profile by slug (D-20 anonymous author endpoint) | INTEGRATE | | +| author posts listing (D-20) | INTEGRATE | | +| posts full-text search (GET posts?search=) | OPT-OUT | explicitly out of scope: the PHP site has no search page or form (D-05 lists no search view); the client can add it without an API change | +| posts pinned_first ordering (GET posts?pinned_first=) | OPT-OUT | not needed: every PHP listing sorts by published_at desc only (blog.htm, index.htm, autor.htm) | +| editor draft preview on GET (Authorization Bearer) | OPT-OUT | explicitly out of scope: the public layer never sends credentials, so drafts stay 404 to visitors (Phase 15 JOURNAL-005) | +| create / update / delete posts (POST, PUT, DELETE posts) | OPT-OUT | explicitly out of scope: backend-JWT admin and importer surface; the layer calls anonymous GET routes only (D-05) | +| featured-image upload / delete (POST, DELETE posts/{id}/featured-images) | OPT-OUT | explicitly out of scope: featured images migrate through the app-local grzyby:import-featured-images command (D-22), not the HTTP write route | +| media upload (POST media/upload) | OPT-OUT | explicitly out of scope: media files are copied from PHP storage by the migration runbook (D-17); uploads stay an admin action |