docs(33-09): complete journal, menu and FAQ layers on the SEO layer plan

This commit is contained in:
Jakub Zych
2026-11-04 14:22:04 +01:00
parent 122aa0786e
commit 80d03e741a
2 changed files with 517 additions and 0 deletions

View File

@@ -0,0 +1,502 @@
---
phase: 33-seo-output-audit-and-content
plan: 09
subsystem: vue-journal-layer, vue-menu-layer, vue-faq-layer, vue-seo-layer, sm-journal-plugin
tags: [nuxt-layer, seo-layer, headings, h1, post-card, body-links, autolink, outbound, breadcrumbs, pagination, itemlist, faqpage, purge, related-posts]
requires:
- phase: 33-08
provides: vue-seo-layer components (SeoBreadcrumbs, SeoPagination, usePagination), useSchemaNode, labels, seo.links.newTabHint, seo.pagination, the shared purge route
- phase: 33-07
provides: vue-seo-layer runtime (resolve middleware, useSeoAnswer, useCmsSeo, one JSON-LD graph, smoke grammar)
- phase: 33-02
provides: content_h1_to_h2 on GET /posts/{slug} and every GET /categories row; the Journal policy keeps rel nofollow/sponsored/ugc and alt=""
provides:
- vue-seo-layer app/utils/bodyLinks.ts (autolink, applyOutbound, imageAttrs)
- vue-journal-layer on vue-seo-layer without @nuxtjs/seo; one H1 in both Journal H1 modes; journal.postTitleTag and journal.breadcrumbs
- renderJournalBody (L7 order), gateJournalHtml/sanitizeJournalHtml demoteH1, rel token policy
- one link per post card (headingLevel 2 | 3), SeoBreadcrumbs on journal pages, JournalPagination as SeoPagination, path-form listing routes, pillar text on page 1, ItemList nodes
- vue-menu-layer and vue-faq-layer on vue-seo-layer without purge copies; FaqBlock headingLevel 0 and schema (FAQPage node)
- sm-journal-plugin golem15.journal.related_mode / related_count (opt-in scored related posts, previous_in_category / next_in_category)
affects: [33-10, 33-11, 33-12, 33-13, 33-14]
actuals:
tokens: 40100
tasks: 4
commits: 13
plan_head_before:
vue-seo-layer: b051bda0809b9a8ca58f2c55c55d12d6477e8127
vue-journal-layer: 1dd31760601e4bbbe93033b5aba6c5edb2c1bef3
vue-menu-layer: ef3a090647ac994fa920baa48464edcd8338a77f
vue-faq-layer: 1f23340a2f8bc14ac3bcd8819485dc13eca1fda3
sm-journal-plugin: 0edf6e1b67d4f7d8f62980e30d52292cce94faf3
plan_head_after:
vue-seo-layer: 7af91fc
vue-journal-layer: adcea4d
vue-menu-layer: 8b5da67
vue-faq-layer: f7ec4fe
sm-journal-plugin: ee3cb26
tech-stack:
added: []
patterns:
- "A content layer extends vue-seo-layer through a link dependency during development (P33-33); 33-14 swaps it for the git tag pin"
- "Body link steps live in vue-seo-layer (pure, auto-imported); the journal gate takes them as injected functions, so its node tests never import across packages"
- "Smoke checks of page structure: headings=strict (one h1, no skipped level) and cardlinks=N, outside <script> elements"
key-files:
created:
- ../vue-seo-layer/app/utils/bodyLinks.ts
- ../vue-seo-layer/tests/bodyLinks.test.ts
- ../vue-journal-layer/app/composables/useJournalItemList.ts
- ../vue-journal-layer/testing/fixtures/acme/seo/resolve.json
- ../vue-journal-layer/testing/fixtures/acme/seo/site.json
- ../vue-journal-layer/testing/expect-acme-title-h1.txt
- ../vue-journal-layer/testing/expect-acme-title-p.txt
- ../vue-journal-layer/testing/expect-acme-hint-off.txt
- ../vue-journal-layer/testing/expect-acme-breadcrumbs-off.txt
- ../vue-faq-layer/app/utils/faqSchema.ts
- ../vue-faq-layer/tests/faqSchema.test.ts
- ../sm-journal-plugin/classes/related.go
- ../sm-journal-plugin/related_media_test.go
modified:
- ../vue-journal-layer/package.json, pnpm-lock.yaml, nuxt.config.ts, lib/journal-module.ts
- ../vue-journal-layer/app/utils/{htmlGate,seo,postView,types,routes,pagination,view}.ts
- ../vue-journal-layer/app/components/Journal/{PostBody,PostCard,PostList,RelatedPosts,Pagination,CategoryHeader}.vue
- ../vue-journal-layer/app/journal-pages/{PostPage,PostIndexPage,CategoryPage,TagPage,AuthorPage,SearchPage}.vue
- ../vue-journal-layer/app/assets/css/{journal-tokens,journal-base}.css
- ../vue-journal-layer/testing/{smoke-ssr,fixture-api,nuxt-server}.mjs, expect-acme.txt, fixtures/acme/{posts,categories}.json
- ../vue-journal-layer/tests/{htmlGate,seo,routes,pagination,postView,embeds}.test.ts, tests/fixtures/editor-corpus.json
- ../vue-journal-layer/.playground/nuxt.config.ts, .playground/app/app.vue, README.md
- ../vue-menu-layer/package.json, pnpm-lock.yaml, nuxt.config.ts, .playground/nuxt.config.ts, testing/smoke-ssr.mjs, README.md
- ../vue-faq-layer/package.json, pnpm-lock.yaml, nuxt.config.ts, app/components/Faq/Block.vue, .playground/nuxt.config.ts, .playground/app/app.vue, testing/smoke-ssr.mjs, testing/expect.txt, README.md
- ../vue-seo-layer/README.md
- ../sm-journal-plugin/controllers/api/posts.go, config/config.yaml, README.md
deleted:
- ../vue-journal-layer/server/api/__sitemap__/journal.ts, app/utils/sitemap.ts, tests/sitemap.test.ts
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts, server/lib/siteCachePurge.ts, server/plugins/site-cache-purge.ts, tests/sitePurge.test.ts
- the same four purge files in ../vue-menu-layer and ../vue-faq-layer
key-decisions:
- "Layer breaking changes approved, option A (Jakub, 2026-11-04): journal drops @nuxtjs/seo and its sitemap source; the three layers extend vue-seo-layer; one link per card, breadcrumbs on by default, related titles h3"
- "P33-33 as planned: \"vue-seo-layer\": \"link:../vue-seo-layer\" in each layer's dependencies, lockfiles updated by pnpm install; 33-14 swaps it for the git tag pin"
- "The deprecated POST /api/__journal__/cache/purge keeps its own helpers (app/utils/siteCache.ts): a bare import of vue-seo-layer's .ts library from a server route would be externalized by Nitro in a git-installed app (Node does not strip types under node_modules)"
- "The body link steps are injected into the journal's renderJournalBody by PostBody (auto-imported from vue-seo-layer), so htmlGate stays pure and its node tests import nothing from another package"
- "Auto-links within one text are taken in document order (longer phrase first at the same place) up to autolink_max; each phrase and each URL once"
- "Post page neighbours: the in-category pair when the Journal answers at least one, else the site-wide pair"
- "Scored related posts: posts sharing nothing are never related; in-category neighbours keep previous_post/next_post's direction (previous = nearest newer)"
- "Page-less apps that extend vue-seo-layer set seo.htmlSitemap false (the menu and FAQ playgrounds do)"
patterns-established:
- "Journal smoke grammar: count=, header=, noheader=, headings=strict, cardlinks=N, resolves PATH N; the fixture API also answers the SEO resolve and site endpoints"
- "Cross-layer check: scratch app outside the repositories with link dependencies, fixture APIs of all three layers, the journal smoke harness"
requirements-completed: [SC-2, D-97, D-50, D-52, D-80, D-81, D-82, D-83, D-71, D-86, D-40, D-41, D-43]
coverage:
- id: D1
description: "The journal layer extends vue-seo-layer without @nuxtjs/seo: one canonical and one JSON-LD script per page, the CMS title wins, the post's own title and description only without a CMS answer"
requirement: "D-97"
verification:
- kind: integration
ref: "vue-journal-layer pnpm smoke:ssr (expect-acme.txt tracer lines: count=rel=\"canonical\":1, count=application/ld+json:1, count=<title>:1, /blog/uncategorised-note fallback)"
status: pass
- kind: unit
ref: "tests/seo.test.ts (cmsHeadPresent, journalSiteOrigin)"
status: pass
human_judgment: false
- id: D2
description: "Exactly one H1 in both Journal H1 modes: title h1 and body h1 demoted while content_h1_to_h2 is on; title p.journal-post__title and body h1 kept while off; postTitleTag h1/p give the documented two and zero H1"
requirement: "D-50"
verification:
- kind: integration
ref: "expect-acme.txt (/blog/heading-on, /blog/heading-off), expect-acme-title-h1.txt, expect-acme-title-p.txt"
status: pass
- kind: unit
ref: "tests/htmlGate.test.ts (demoteH1), tests/seo.test.ts (journalPostTitleTag)"
status: pass
human_judgment: false
- id: D3
description: "Heading convention on every fixture page (one h1, no skipped level), cards h2 / h3 under related, category pillar text follows the category row's setting, page >= 2 suffix"
requirement: "D-52"
verification:
- kind: integration
ref: "expect-acme.txt headings=strict lines (16 count=<h1:1 lines), /blog/category/news and /blog/category/plain"
status: pass
human_judgment: false
- id: D4
description: "One link per post card (title only, no read-more, image not a link), stretched-link CSS with the focus ring on the card"
requirement: "D-80"
verification:
- kind: integration
ref: "expect-acme.txt cardlinks=1 on every listing and post page, notcontains=journal-post-card__more"
status: pass
- kind: other
ref: "journal-base.css review (stretched link, :has focus ring)"
status: pass
human_judgment: true
- id: D5
description: "Body links: auto-links in document order up to autolink_max, never in headings or to the page itself; outbound rel kept plus nofollow/sponsored; target, noopener and hint only while new_tab is on, no hint with newTabHint false; images lazy/async with alt=\"\" kept"
requirement: "D-82, D-83, D-71"
verification:
- kind: unit
ref: "vue-seo-layer tests/bodyLinks.test.ts (21 tests, including quotes, entities, javascript: and protocol-relative URLs refused)"
status: pass
- kind: unit
ref: "vue-journal-layer tests/htmlGate.test.ts (rel tokens, renderJournalBody order)"
status: pass
- kind: integration
ref: "expect-acme.txt (hello-acme, heading-off), expect-acme-hint-off.txt"
status: pass
human_judgment: false
- id: D6
description: "SeoBreadcrumbs at the top of journal pages, none with journal.breadcrumbs false (the layout's instance renders the trail, BreadcrumbList unchanged)"
requirement: "D-86"
verification:
- kind: integration
ref: "expect-acme.txt breadcrumb lines, expect-acme-breadcrumbs-off.txt"
status: pass
human_judgment: false
- id: D7
description: "JournalPagination as SeoPagination with journal class names (first, last, gap items, head prev/next); path-form routes registered for the path pagination form; pillar text on page 1 only"
requirement: "D-40, D-41, D-42"
verification:
- kind: integration
ref: "expect-acme.txt /blog/category/news?page=6 and ?page=2 lines"
status: pass
- kind: unit
ref: "tests/routes.test.ts (journalPagedRoutes), tests/pagination.test.ts (pageFromRoute)"
status: pass
human_judgment: false
- id: D8
description: "ItemList on index, category, tag and author pages and the FAQPage node of FAQ blocks, one JSON-LD script per page"
requirement: "D-43"
verification:
- kind: integration
ref: "expect-acme.txt ItemList lines; vue-faq-layer expect.txt (count=\"@type\":\"FAQPage\":1)"
status: pass
- kind: unit
ref: "tests/seo.test.ts (journalItemList), vue-faq-layer tests/faqSchema.test.ts"
status: pass
human_judgment: false
- id: D9
description: "Purge copies removed from the three layers; the shared route from vue-seo-layer and the deprecated journal route keep working"
requirement: "RESEARCH Pitfall 6"
verification:
- kind: integration
ref: "vue-journal-layer testing/purge-check.mjs (post and category) and purge-token-check.mjs; vue-seo-layer pnpm purge:check"
status: pass
human_judgment: false
- id: D10
description: "An app extending journal, menu and FAQ layers renders each page with one resolve call, one canonical and one JSON-LD script"
requirement: "SC-2"
verification:
- kind: integration
ref: "cross-layer scratch app (command and output below), 17 checks"
status: pass
human_judgment: false
- id: D11
description: "Journal related posts: default answer as before; scored mode ranks by shared tags and categories with count 3 to 6 and adds previous_in_category / next_in_category; drafts and future posts never shown to visitors; the layer renders the in-category neighbours"
requirement: "D-82"
verification:
- kind: integration
ref: "sm-journal-plugin TestRelatedDefaultUnchanged, TestRelatedScored, TestRelatedScoredCountClamped (Postgres)"
status: pass
- kind: integration
ref: "expect-acme.txt neighbour lines; tests/postView.test.ts (postNeighbours)"
status: pass
human_judgment: false
duration: 51min
completed: 2026-11-04
status: complete
---
**Layer breaking changes APPROVED: option A; versions confirmed: vue-journal-layer v0.8.0, vue-menu-layer v0.5.0, vue-faq-layer v0.5.0 (breaking minors, tagged in 33-14) (Jakub, 2026-11-04)**
# Phase 33 Plan 09: content layers on the shared SEO layer, heading convention, body links and opt-in related posts Summary
**vue-journal-layer, vue-menu-layer and vue-faq-layer now extend vue-seo-layer. Every page gets one CMS head, one canonical and one JSON-LD graph. The journal layer drops @nuxtjs/seo and keeps exactly one H1 in both Journal H1 modes. Its post cards have one link, its post bodies follow the CMS link and image rules, and it renders the CMS breadcrumbs and pagination. Journal gains opt-in scored related posts and category neighbours, and its default answer is unchanged.**
## Context rulings applied
- **Breaking changes:** option A as approved (see the line at the top).
- The non-breaking guarantees hold:
- the deprecated `POST /api/__journal__/cache/purge` still works;
- `postTitleTag: 'auto'` gives an h1 while the Journal setting is on or absent;
- FAQ `headingLevel` keeps its default of 2 (0 is new and opt-in);
- Journal's scored related posts are opt-in, and the default answer is the same as before (TestRelatedDefaultUnchanged, the API matrix tests).
- No package.json version was changed; 33-14 does that.
- **Framework:** no framework code changed (D-90), and no `summercms` requires were bumped.
- **No SEO code in Journal (D-96):** Journal imports no SEO plugin. All link and SEO logic is in vue-seo-layer.
- **How the layers reference vue-seo-layer:** P33-33 as planned. Each layer's package.json has `"vue-seo-layer": "link:../vue-seo-layer"`, and `pnpm install` updated the lockfiles.
- 33-14 must swap this for the git tag pin before tagging.
- No package came from the registry ("downloaded 0"). The lockfile diffs only move peer resolutions.
- **Not pushed or tagged.** The consumers (grzybyfunkcjonalne, demo, the summercms.io changelog) were not touched.
## Performance
- **Duration:** about 51 min (2026-11-04T12:28:32Z to 13:19:06Z)
- **Tasks:** 4 (a tracer, two TDD tasks and one auto task)
- **Commits:** 13 across 5 repositories (measured with `git rev-list --count <before>..HEAD` per repository)
## Task Commits
All on the default branch, not pushed:
**vue-journal-layer** (1dd3176..adcea4d, 7 commits)
1. Task 1 (tracer): `289d031` feat!: render the post page on the shared SEO layer with one H1 in both Journal H1 modes (tracer)
2. Task 2:
- A `1010a60` feat: body links, outbound rules and image attributes in the post body
- B `51e5d67` feat!: one link per post card and the heading convention
- C `98904b8` feat: breadcrumbs, CMS pagination and pillar content on page one
- D `642c25a` feat: ItemList nodes on listing pages
3. Task 3: `ed50826` docs: README for the SEO layer
4. Task 4: `adcea4d` feat: category neighbours on the post page
**vue-seo-layer** (b051bda..7af91fc, 2 commits)
- Task 2: `db44ca4` feat: body link utilities (auto-links, outbound rules, image attributes)
- Task 3: `7af91fc` docs: body link utilities and the app-without-pages note
**vue-menu-layer** (ef3a090..8b5da67, 1 commit)
- Task 3: `8b5da67` feat!: extend the SEO layer and drop the copied purge route
**vue-faq-layer** (1f23340..f7ec4fe, 2 commits)
- Task 3:
- A `cb321e9` feat!: extend the SEO layer and drop the copied purge route
- B `f7ec4fe` feat: FAQPage node and widget headings
**sm-journal-plugin** (0edf6e1..ee3cb26, 1 commit)
- Task 4: `ee3cb26` feat: opt-in scored related posts and category neighbours
The untracked `go.work.sum`, which belongs to another session, was left alone.
The tracer gate ran in interactive end-of-phase mode with automated checks only. Its checks (unit tests, playground build, smoke, both purge checks) passed on the committed tree before expansion.
## Accomplishments
### Journal on the SEO layer (Task 1; D-97, D-50, P33-33 to P33-36)
- `extends: ['vue-seo-layer']`, and `@nuxtjs/seo` is gone from the modules, the dependencies and the `ogImage`, `seo` and `sitemap` keys.
- The journal sitemap source and the copied shared purge route, library, plugin and tests are removed.
- The post title element is decided by `journalPostTitleTag(cfg.postTitleTag, post.content_h1_to_h2)`. While the setting is on, the body is sanitized with `demoteH1` (in both the payload and the sink).
- The fallback meta (post title, excerpt, featured image, article times) applies only while `cmsHeadPresent(answer)` is false.
- `useSiteConfig()` is replaced by `journalSiteOrigin(answer.url, request origin)` in PostPage and AuthorPage.
- The search page is marked noindex through page meta (`seo: { robots: 'noindex, follow' }`), so vue-seo-layer sends both the meta tag and the header.
- New tokens `--journal-post-title-size`, `--journal-post-title-weight` and `--journal-post-title-line-height`; `.journal-post__title` is styled by its class.
### Body links, cards, headings, breadcrumbs, pagination, ItemList (Task 2; D-80 to D-83, D-71, D-52, D-86, D-40 to D-43)
- **vue-seo-layer `bodyLinks.ts`:**
- `autolink` works over text nodes only, with a stack of open tags and an entity guard.
- `applyOutbound` handles rel tokens, target, noopener and the hint inserted before `</a>`.
- `imageAttrs` adds the image attributes.
- Everything works on sanitize-html output, with escaped values.
- **Journal `renderJournalBody`** runs in the L7 order. The steps are injected by PostBody.
- The sanitizer keeps a `rel` made only of the tokens nofollow, sponsored and ugc, mirroring the Go regex exactly. This is the 33-02 follow-up.
- `alt=""` was already kept.
- The editor corpus was copied again from the plugin (17 entries).
- **PostCard:** the title link is the only anchor, the image sits in a `div`, the read-more link is gone, and `headingLevel` takes 2 or 3. The stretched link uses `::after`, and the focus ring is on the card (`:has`). RelatedPosts renders its cards with `heading-level="3"`.
- **Breadcrumbs:** `<SeoBreadcrumbs>` at the top of the post, index, category, tag and author pages while `journal.breadcrumbs` is on.
- **JournalPagination** wraps `<SeoPagination class-prefix="journal-pagination">` with the journal labels.
- **Path pagination form:** with `seo.pagination.form` set to `path`, the module registers `<listing>/<segment>/:page` routes, and the pages read the page through `pageFromRoute`.
- **CategoryHeader:** the pillar text renders on page 1 only, demoted by the category row's `content_h1_to_h2`.
- **ItemList:** `useJournalItemList` adds the node to the index, category, tag and author pages.
### Menu and FAQ layers (Task 3; D-52, D-43, D-71)
- Both extend vue-seo-layer through the link dependency. Their purge copies are removed with `git rm`.
- Their smokes point `NUXT_SEO_API_BASE` at the fixture API, which answers 404, so the layer fails open.
- **FaqBlock:**
- `headingLevel: 0` renders `<p class="faq-block__title">` with the same id and `aria-labelledby`.
- The `schema` prop (default true) adds a FAQPage node through `faqSchema(faq, pageUrl)`: `@id` `<url>#faq`, with the answers as plain text.
- The FAQ smoke harness gained `count=` and repeatable options.
- README updates in all four layers (upgrade notes in the journal, menu and FAQ layers).
### Opt-in related posts (Task 4; D-82, P33-44)
- **Config:** `golem15.journal.related_mode` (`category` | `scored`, default `category`) and `golem15.journal.related_count` (3 to 6, default 3, clamped) are in `config/config.yaml`.
- **Code:** `classes.ReadRelatedSettings`, `ScoredRelatedPosts` (one scoring query), `PrimaryCategoryID` and `CategoryNeighbour`.
- **Show handler:** uses the scored ranking only in scored mode, and only then adds `previous_in_category` and `next_in_category`.
- **Layer:** `postNeighbours` prefers the in-category pair when the Journal answers one.
## Cross-layer check (Task 3)
**Scratch app:** a scratch app outside the repositories, in `/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/cc746d7e-76ce-49f4-ae43-0e1debb2bbea/scratchpad/cross-layer-app`. Nothing from it is committed.
- `package.json` has nuxt 4.4.8 and vue 3.5.35, plus link dependencies on vue-journal-layer, vue-menu-layer and vue-faq-layer.
- `nuxt.config.ts` sets `extends: ['vue-journal-layer', 'vue-menu-layer', 'vue-faq-layer']` and the playground's i18n.
- `app.vue` renders `<MenuNav code="main">`, `<NuxtPage>` and a `<FaqBlock slug="acme" :heading-level="0">` widget.
**How it runs:** `run-cross.mjs` starts the menu and FAQ fixture APIs, then runs the journal harness:
```
node vue-journal-layer/testing/smoke-ssr.mjs --output .output --fixtures vue-journal-layer/testing/fixtures/acme --expect expect-cross.txt --env NUXT_MENU_API_BASE=<menu fixture> --env NUXT_FAQ_API_BASE=<faq fixture>
```
**What `expect-cross.txt` checks:**
- For 8 pages (`/blog/hello-acme`, `/blog/heading-on`, `/blog/heading-off`, `/blog`, `/blog?page=2`, `/blog/category/news`, `/blog/tag/release` and `/blog/author/jane-doe`):
- one `application/ld+json` script on every page;
- one `rel="canonical"` on the five pages the CMS fixture answers;
- the menu, the FAQ widget title and the FAQPage node merged into the CMS graph.
- After each page, `resolves <path> 1`.
- At the end, `resolves * 8`.
**Output** (`pnpm exec nuxt build`, then `node run-cross.mjs`, on the final layer trees):
```
build=0
smoke-ssr: ok (17 checks)
```
That is exactly one resolve call per page render.
## Consumer migration notes (for 33-12 and 33-13)
Consumers stay pinned to their current tags until they are migrated. When an app moves to vue-journal-layer v0.8.0, vue-menu-layer v0.5.0 and vue-faq-layer v0.5.0:
**Config and modules**
- Remove `site: { url, name }` and the `@nuxtjs/seo` keys: `ogImage`, `seo.canonicalQueryWhitelist`, `sitemap`, `robots` and `schemaOrg`.
- Set `NUXT_SEO_API_BASE`, or `seo: { cms: false }` for a site without the SEO plugin (planned for summercms.io in 33-12).
- An app that calls `useSiteConfig()`, `useSchemaOrg()` or other `@nuxtjs/seo` composables must add the module itself or move off those composables. An app that adds the module itself gets a second canonical and graph next to the CMS's.
- A site without the SEO plugin loses the `| site.name` title suffix in fallback titles; set `app.head.titleTemplate`.
**Sitemaps and purge**
- Sitemaps now come from the sitemap plugin through vue-seo-layer's root files. `/api/__sitemap__/journal` is gone.
- The purge environment stays the same (`NUXT_CACHE_PURGE_TOKEN`, `NUXT_CACHE_PURGE_ALLOW_PROXIED`, the deprecated `NUXT_SITE_CACHE_*` names). The route is now vue-seo-layer's.
**Markup and CSS**
- **Post cards:**
- `.journal-post-card__more` and the image link are gone, and `.journal-post-card__image` is now a `div`.
- Overrides for those parts have nothing to style any more, and the whole card is clickable.
- Related post card titles are `h3`. CSS on `h2.journal-post-card__title` must target the class instead.
- **Post title:** with the Journal setting off, the title is a `p.journal-post__title`. Style the class or the `--journal-post-title-*` tokens, not `h1`.
- **Breadcrumbs:** they now appear at the top of journal pages. A layout that already renders breadcrumbs sets `journal.breadcrumbs: false`.
- **Pagination:** markup classes stay the same, plus `.journal-pagination__gap` and the page 1 and last-page items. Pagination links follow the CMS form.
- **FAQ:** FAQ blocks add a FAQPage node by default. A page with several FAQ blocks sets `:schema="false"` on all but one.
**Other**
- An app without pages that extends any of these layers sets `seo: { htmlSitemap: false }`. Otherwise every path answers 404 (deferred-items.md).
- The demo app's `/p/**` and `/presentations/**` need `seo.noindexRoutes` (33-08 note), which now comes through every layer.
## Decisions Made
See key-decisions in the frontmatter.
## Deviations from Plan
### Adapted to the actual repositories
**1. [Rule 3 - Blocking] The deprecated journal purge route keeps its own helpers.**
- **Plan:** import the purge library from vue-seo-layer.
- **Why changed:** Nitro externalizes a bare import (`vue-seo-layer/server/lib/siteCachePurge.ts`). In a git-installed app that file sits under node_modules, where Node does not strip types, so the server would fail to start.
- **What I did:** the route still uses `app/utils/siteCache.ts`, unchanged. vue-seo-layer's `seo-cache-purge` plugin already empties the SEO caches after it, so the route keeps working (purge-check).
**2. [Rule 3 - Blocking] `useSiteConfig()` was gone.**
- **Found during:** Task 1. It came from @nuxtjs/seo, and PostPage and AuthorPage used it.
- **Fix:** both use the CMS answer's origin, else the request origin (`journalSiteOrigin`).
- **Search page robots:** they came from nuxt-robots through a route rule and now come from page meta. The route rule and the sitemap exclude stay, for apps that add those modules.
**3. Smoke harness.**
- **Expectation lines removed:** the journal expect file dropped its sitemap lines, since the layer no longer serves a sitemap.
- **Fixture API:** answers the SEO resolve and site endpoints, and `resolveCount` was added.
- **Boot probe:** fetches `/_nuxt/` instead of `/`, as the SEO layer's does, so resolve counts stay exact.
- **New checks:** `headings=strict` and `cardlinks=N`. The plan names the rules but not a grammar.
- **Extra expectation files:** beyond the plan's `expect-acme-title-h1.txt`, there are `expect-acme-title-p.txt`, `expect-acme-hint-off.txt` and `expect-acme-breadcrumbs-off.txt`. The `smoke:ssr` script runs all five, and the plan's two verify commands still pass on their own.
**4. Playground changes.**
- The journal playground drops `site`, shows one post per category page (so the 12-page news category has gap items), and has a layout trail shown only while `journal.breadcrumbs` is off.
- The menu and FAQ playgrounds set `seo.htmlSitemap: false`; see deviation 7.
- The FAQ playground adds a `headingLevel` 0 widget.
**5. [Rule 2] The body pipeline takes its steps by injection.**
- **Plan:** "htmlGate runs the L7 order".
- **What I did:** htmlGate has `renderJournalBody(html, options, steps)`, and PostBody passes vue-seo-layer's auto-imported functions.
- **Why:** journal unit tests must not import `.ts` from another package; that breaks once the dependency is a git install (33-14). The order is unit-tested with recording steps.
**6. [Rule 1 - Bug] The FAQ and menu smokes read the default SEO base.** The smokes now point `NUXT_SEO_API_BASE` at the fixture API. Otherwise a local backend on :8096 could answer and make them nondeterministic. This touched `testing/smoke-ssr.mjs` in both layers, which the plan did not list.
**7. [Rule 3 - Blocking] The menu and FAQ playgrounds answered 404 on every path.**
- **Cause:** vue-seo-layer's HTML sitemap pages made Nuxt turn the router on in an app that has only `app.vue`.
- **Fix:** `seo: { htmlSitemap: false }` in both playgrounds, plus README notes in all three layers.
- **Logged in:** deferred-items.md, as a possible SEO-layer fix.
**8. A commit split folded back.**
- **What happened:** commit A (body links) first went in with a stale source-text assertion in `tests/embeds.test.ts`, which matched `expandEmbeds(sanitizeJournalHtml(` only. I had not re-run the unit tests after the last PostBody edit.
- **Fix:** a fixup commit, folded into A with a non-interactive autosquash before anything later was pushed or depended on it.
- **Check:** A's tree was then exported (`git archive`) and passed `npm run test` (182/182).
- **History:** every journal commit is green.
**9. [Rule 2] Extra commit in vue-seo-layer.** `docs: body link utilities and the app-without-pages note`: the plan lists `../vue-seo-layer/README.md` in Task 3 but no seo commit group there.
**10. Grep and test names.**
- `grep -n SeoPagination Pagination.vue` prints two lines, not one: the doc comment and the template.
- The plan's `TestJournalAPIMatrix` names no test. `journal_api_matrix_test.go` holds `TestJournalAPIShowNeighbors`, `TestJournalAPIMassAssignRedactor` and others, which I ran with `-run TestJournalAPI` and which pass. The related-posts commit does not touch that file.
- `posts_helpers.go` is unchanged: the new logic lives in `classes/related.go`.
**11. [Rule 2] An extra related-posts test, and its rules.**
- `TestRelatedScoredCountClamped` covers the 3..6 clamp and an invalid count.
- A post that shares nothing scores 0 and is never related.
- A future-dated published post is excluded alongside the planted draft (T-33-70).
### TDD gate
- **Task 2:** `tests/bodyLinks.test.ts` was written first.
- **RED:** the module was missing.
- **First GREEN run:** 20 of 21. One test failed because the limit was applied in table order; I changed it to document order and documented that.
- **Final:** 21 of 21. The FAQ `faqSchema` tests went RED then GREEN the same way.
- **Task 4:** `related_media_test.go` was written first.
- **RED:** the scored tests failed on the default ranking.
- **One correction on my side:** my expectation for `previous_post` / `next_post` was wrong. `siblingPost` answers previous as the nearest newer post, and I fixed the test before implementing.
- No separate RED commits, because the plan requires green tests at every commit (as in 33-07 and 33-08).
## Issues Encountered
- **The `scripts/check-phase33.sh --layers` stage still refuses** (`test=0 detect=3`, the vitest detector against `node --test` TAP; deferred-items.md, from 33-08). I ran every step of it by hand; the results are under Self-Check.
- **An off-mode category page has two `h1`.** A category whose row has `content_h1_to_h2` false and whose pillar text has an h1 shows two `h1` (the header and the pillar's). That is what the plan's rule produces; the fixture's `/blog/category/plain` asserts it, and the crawler (33-10) reports it.
- **vue-journal-layer `check-git-install.sh` needs updating in 33-14:**
- its lockfile check expects exactly one commit;
- its scratch app lacks the playground options the expectations use.
- Logged in deferred-items.md.
- **The journal fixture API's previous/next direction is the opposite of Go's.** This is pre-existing and logged.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model.
- **T-33-39 and T-33-40:** autolink and applyOutbound only add escaped markup between tags of sanitized output. URLs are limited to site paths and http(s). target and noopener never come from stored HTML. Covered by unit tests.
- **T-33-70:** the scored and neighbour queries apply the published rule for visitors (TestRelatedScored).
- **T-33-SC:** the only new dependency is the first-party `vue-seo-layer` through a local link, and @nuxtjs/seo was removed. The lockfiles are committed.
## Self-Check: PASSED
- **Files:** every created file listed in key-files exists.
- **Commits:** all 13 commits are present in their repositories:
- vue-seo-layer db44ca4 and 7af91fc;
- vue-journal-layer 289d031, 1010a60, 51e5d67, 98904b8, 642c25a, ed50826 and adcea4d;
- vue-menu-layer 8b5da67;
- vue-faq-layer cb321e9 and f7ec4fe;
- sm-journal-plugin ee3cb26.
- **Unit tests:** vue-seo-layer 127/127, vue-journal-layer 187/187, vue-menu-layer 24/24, vue-faq-layer 14/14.
- **Builds:** all four playgrounds build.
- **Smokes:**
- vue-seo-layer `smoke:ssr`: seven runs (31, 64, 11, 11, 7, 3, 7);
- vue-journal-layer `smoke:ssr`: 84, 2, 2, 2, 2;
- vue-menu-layer 17;
- vue-faq-layer 14.
- **Purge and guard:** journal `purge-check` (post and category) 19/19 each and `purge-token-check` 53; vue-seo-layer `purge:check` 18 and 53, `guard:check` 16.
- **Cross-layer check:** 17 checks.
- **sm-journal-plugin:**
- `go vet ./...` clean, `go test -short ./...` green;
- full Docker `go test ./...` green (root package 50 s);
- `TestRelatedScored`, `TestRelatedDefaultUnchanged`, `TestRelatedScoredCountClamped` and the `TestJournalAPI*` matrix pass;
- editor `npm test` 244/244;
- editor bundle unchanged, so `check-dist.sh` was not needed.
- **Acceptance greps:**
- no `@nuxtjs/seo` in the journal package.json or nuxt.config.ts;
- `vue-seo-layer` appears in the package.json and nuxt.config.ts of all three layers;
- no tracked files under `server/lib`, `server/api/__site__` or `server/api/__sitemap__` in the three layers;
- `postTitleTag` in journal-module.ts and PostPage.vue;
- the three `bodyLinks` exports;
- no `journal-post-card__more` or `readMore` in PostCard.vue;
- 16 `count=<h1:1` lines;
- `content_h1_to_h2` in CategoryPage.vue and CategoryHeader.vue;
- `FAQPage` in faqSchema.ts;
- `related_mode` and `related_count` in classes/related.go and config/config.yaml;
- `previous_in_category` in PostPage.vue;
- the related-posts commit does not list journal_api_matrix_test.go.

View File

@@ -37,3 +37,18 @@ Out-of-scope discoveries logged during execution; not fixed in the plan that fou
status: open
- **Possible fix:** per-request options for core taglist fields (as `pact.FormFieldType.RequestOptions` does for plugin field types), or taglist support in form extensions; then set `customTags: false` with those options in `sm-sitemap-plugin/models/settings/fields.yaml`.
- **Why deferred:** 33-04 commits only to sm-sitemap-plugin; the gap is generic, additive framework work.
## From 33-09 (journal, menu and FAQ layers)
- **Extending vue-seo-layer turns the router on in an app without pages (vue-seo-layer, 33-08 behaviour).** The SEO layer registers its HTML sitemap pages through `extendPages`, and Nuxt counts pages added that way when it decides whether the app has pages. An app that only has `app.vue` (the menu and FAQ playgrounds) therefore gets vue-router, and every path answers "Page not found". 33-09 sets `seo: { htmlSitemap: false }` in those two playgrounds and documents the switch in the vue-seo-layer, vue-menu-layer and vue-faq-layer READMEs. Real sites have pages and are not affected.
status: open
- **Possible fix:** register the sitemap pages only when the app has pages of its own (in `pages:extend`, skip when the list holds only the layer's), or set `pages: true` only when the app has a pages directory.
- **Why deferred:** a change to 33-08's registration logic in vue-seo-layer, outside 33-09's scope; the documented switch covers the case.
- **vue-journal-layer `scripts/check-git-install.sh` will not pass as it is (release tooling, 33-14).** Its lockfile check (`grep … commit: [0-9a-f]{40}`, exactly one commit) fails once vue-seo-layer is a git dependency too (two commits in the lockfile). Its scratch app also lacks the playground config the expectations rely on: `journal.routes.search` (the search lines, already since the search page), `journal.perPage.category: 1` and the layout breadcrumb trail (33-09 lines). And during development the layer depends on `link:../vue-seo-layer`, which a git install cannot resolve.
status: open
- **Possible fix (33-14):** switch the link to the git tag pin first, filter the lockfile check to the vue-journal-layer entry, and give the scratch app the playground's `journal` options and `app.vue` (or run a subset expectation file).
- **Why deferred:** 33-14 replaces the link dependency and tags the layers; the script is part of that release step.
- **The journal fixture API's previous_post/next_post direction is the opposite of the Go API's (test fixture).** `testing/fixture-api.mjs` answers `previous_post` with the next older post; sm-journal-plugin's `siblingPost` answers it with the next newer one (`previous` → `published_at >` ascending). The layer renders whatever it gets, so no page is wrong. The fixture is less faithful than it looks.
status: open
- **Possible fix:** swap the two in the fixture API and adjust the expectation lines that name a neighbour.
- **Why deferred:** pre-existing, not caused by 33-09; changing it changes existing expectations.