docs(quick-261028-wqu): summercms.io changelog dropdown with Framework and Plugins streams

This commit is contained in:
Jakub Zych
2026-10-29 00:31:21 +01:00
parent bac8e7ef22
commit 0e5bb0c44f
3 changed files with 748 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
# Quick 261028-wqu — Context (locked decisions)
Request: summercms.io site -> make Changelog a dropdown with two links: Framework and Plugins. Plugins page looks exactly the same as the main changelog but filtered: /changelog shows only framework posts, /changelog/plugins only plugin posts.
## Decisions (user, 2026-10-28)
- D1: Filtering uses Journal categories `framework` and `plugins` (the public API already supports `GET /posts?category=<slug>`).
- D2: Plugin release posts (`scripts/changelog-post.py publish ... --repo <plugin>`) now get category `plugins` and are PUBLISHED normally (no longer forced draft; `--draft` stays available). Framework posts get category `framework`. Update sm-summercmsio-app CLAUDE.md, DEPLOY.md, README and the framework repo CLAUDE.md changelog line to match.
- D3: Backfill is a changelog-post.py subcommand (creates both categories if missing, assigns `framework` to existing framework posts, `plugins` to existing `sm-*-plugin` posts). Tested locally / in smoke only. The user runs it against production. Do NOT run it against summercms.io; no pushes or deploys.

View File

@@ -0,0 +1,384 @@
---
phase: quick-261028-wqu
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/app/components/SiteHeader.vue
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/app/components/SiteFooter.vue
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/i18n/locales/en.json
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/nuxt.config.ts
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/utils/streams.ts
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/composables/useChangelogStream.ts
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/components/ChangelogIndex.vue
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/changelog/index.vue
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/changelog/plugins.vue
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/components/Journal/IndexHeader.vue
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/components/Journal/RssLink.vue
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/layouts/default.vue
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/error.vue
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/i18n/locales/en.json
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/server/utils/feed.ts
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/server/utils/changelogFeed.ts
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/server/routes/changelog/rss.xml.ts
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/server/routes/changelog/plugins/rss.xml.ts
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/testing/fixtures/summercms/posts.json
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/testing/fixtures/summercms/categories.json
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/testing/fixtures/summercms/rss.xml
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/testing/expect.txt
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/package.json
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/README.md
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/tests/changelog.test.ts
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/scripts/changelog-post.py
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/scripts/test_changelog_post.py
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/scripts/smoke.sh
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/CLAUDE.md
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/README.md
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/DEPLOY.md
- /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app (submodule pointer)
- /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/CLAUDE.md
autonomous: true
requirements:
- quick-261028-wqu
- D1
- D2
- D3
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 3
confidence: low
must_haves:
truths:
- "D1: GET /changelog lists only published Journal posts in category `framework`, 10 per page, with ?page=N pagination (out of range or non-numeric page is a 404)"
- "D1: GET /changelog/plugins renders the same layout (hero, entry list, pagination, RSS link) listing only posts in category `plugins`, with its own ?page=N pagination; the path is not handled by the /changelog/:slug post route"
- "The header's Changelog entry is a disclosure menu with two links, Framework (/changelog) and Plugins (/changelog/plugins), on both the embedded landing and the changelog SSR app; it opens and closes without JavaScript (details/summary), by keyboard and by tap, and is not clipped by the scrolling links strip"
- "Phones can reach both changelogs: the menu stays in the header while it fits, and the footer links both /changelog and /changelog/plugins"
- "GET /changelog/rss.xml carries only `framework` items and GET /changelog/plugins/rss.xml only `plugins` items; every link, guid and atom self link points under https://summercms.io/changelog"
- "D2: changelog-post.py publish sets category `framework` for the framework repo and `plugins` for --repo <plugin>, and publishes plugin posts (no forced draft); --draft still saves unpublished"
- "D3: changelog-post.py backfill creates the `framework` and `plugins` categories when missing, assigns `framework` to summercms-* posts and `plugins` to sm-*-plugin-* posts, is idempotent, previews with --dry-run and refuses to write without --yes; it is exercised only in scripts/smoke.sh against the local smoke binary"
- "No request from this work writes to https://summercms.io; nothing is pushed, tagged or deployed; vue-journal-layer, sm-journal-plugin and deploy/ configs are not edited"
artifacts:
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/utils/streams.ts
provides: "CHANGELOG_STREAMS (framework/plugins: category slug, index path, feed path), type ChangelogStream, streamForPath(path)"
contains: "export function streamForPath"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/components/ChangelogIndex.vue
provides: "Shared stream index view: category-filtered listPosts, 404 rules, header/list/pagination/RSS"
contains: "category"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/app/pages/changelog/plugins.vue
provides: "The /changelog/plugins page"
contains: "plugins"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/server/utils/feed.ts
provides: "rewriteChangelogFeed with per-stream index/self paths, filterFeedItems, findCategoryName (pure)"
contains: "export function filterFeedItems"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/server/routes/changelog/plugins/rss.xml.ts
provides: "GET /changelog/plugins/rss.xml"
contains: "plugins"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/changelog/testing/fixtures/summercms/posts.json
provides: "App-owned fixture: 12 published framework posts, 11 published plugin posts, an uncategorised post, a draft"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/scripts/changelog-post.py
provides: "publish with categories (D2), backfill subcommand (D3), __main__ guard"
contains: "def cmd_backfill"
- path: /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/scripts/test_changelog_post.py
provides: "stdlib unittest for the pure helpers of changelog-post.py, no network"
contains: "unittest"
key_links:
- from: "changelog/nuxt.config.ts journal.routes.index: false"
to: "changelog/app/pages/changelog/index.vue and plugins.vue"
via: "the layer no longer registers its unfiltered index page; the app's pages own /changelog and /changelog/plugins"
pattern: "index: false"
- from: "ChangelogIndex.vue"
to: "Journal API GET /posts?category=<slug>"
via: "useJournalApi().listPosts({ category, page, perPage, sort })"
pattern: "listPosts"
- from: "changelog/app/layouts/default.vue back link"
to: "CHANGELOG_STREAMS[stream].index"
via: "useChangelogStream() (indexLink() is null once the layer index route is off)"
pattern: "useChangelogStream"
- from: "changelogFeed.ts"
to: "Journal API GET /rss and GET /categories?include_empty=1"
via: "category slug -> name, then filterFeedItems on <category> text"
pattern: "include_empty"
- from: "scripts/changelog-post.py publish/backfill"
to: "Journal categories with slugs framework and plugins"
via: "GET /categories?include_empty=1; PUT /posts/{id} {categories}; POST <admin api>/golem15/journal/categories"
pattern: "framework"
---
<objective>
Split the summercms.io changelog into two streams backed by Journal categories (D1): `/changelog` for framework releases (category `framework`) and `/changelog/plugins` for plugin releases (category `plugins`). The plugins page looks exactly like the main changelog. The header's Changelog link becomes a dropdown with Framework and Plugins. Each stream has its own RSS feed. `scripts/changelog-post.py` publishes plugin posts into `plugins` (D2) and gains a `backfill` subcommand for the existing posts (D3). The docs in the app and the framework follow (D2).
Purpose: plugin releases get their own public changelog instead of hidden drafts, and the framework changelog stays framework-only.
Output: changelog streams and the header menu in vue-summercmsio-app (submodule commits first), script, smoke and docs in sm-summercmsio-app, then the submodule pointer bump, and one docs commit in summercms.go. Nothing is pushed, tagged or deployed.
Repos and order of commits (stage files by explicit path, never `git add -A`; no co-author tags; conventional prefixes; keep existing D-xx comments):
1. /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app (submodule `golem15/vue-summercmsio-app`): code, fixture and README (Task 1), unit tests (Task 3).
2. /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app: script and smoke (Task 2), docs (Task 2), script unit tests (Task 3), and last the submodule pointer bump as its own commit (Task 3). Leave the untracked `scripts/__pycache__/` alone (not ours); run Python with `-B`.
3. /media/nvme/dev/golem15/summercms.io/summercms/summercms.go: the CLAUDE.md changelog line only (Task 2), its own docs commit, staged by path (other sessions have uncommitted STATE.md, state.json and G15OFFICE.md there).
HARD SAFETY RULE (D3): never run `changelog-post.py publish` or `backfill` without SUMMERCMS_JOURNAL_API pointing at a loopback smoke binary, SUMMERCMS_JOURNAL_TOKEN set to the smoke token, and HOME set to a temp dir, so `~/.config/summercms/journal-token` (a real production token on this machine) is never read. Unit tests must not open sockets. A read-only `curl` GET against production is allowed for inspection; any write is not.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261028-wqu-summercms-io-changelog-dropdown-framewor/261028-wqu-CONTEXT.md
@/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/CLAUDE.md
@/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/CLAUDE.md
Facts observed at planning time (verified against the live tree, 2026-10-28):
- Layer: vue-journal-layer v0.6.0 is installed at vue-summercmsio-app/node_modules/vue-journal-layer. Its module (lib/journal-module.ts) registers PostIndexPage at journal.routes.index. PostIndexPage calls `api.listPosts({ page, perPage, sort })` without a category, so the layer index cannot be filtered. `normalizeJournalRoutes` accepts `index: false`. `useJournalLinks().indexLink()` then returns null. Its only user in the app is the back link in changelog/app/layouts/default.vue (line 27), which must be replaced. Nothing else (sitemap source, cache purge) depends on routes.index. JournalPagination builds its links from `route.path` plus `?page`, so it works on any path. Importable pure helpers: `parsePageParam` and `pageOutOfRange` in 'vue-journal-layer/app/utils/pagination.ts', `toListPost` in 'vue-journal-layer/app/utils/view.ts'. `useJournalApi()` is auto-imported. `listPosts` accepts `category`.
- Journal API (sm-journal-plugin v0.2.2 in plugins/golem15/journal): `GET /posts?category=<slug>` filters, and an unknown slug returns an empty list, not every post. `GET /rss` has NO category filter and returns the latest RSSPostsPerFeed (default 20) published posts. Each item carries one `<category>NAME</category>` element per category, with XML-escaped names. `GET /categories?include_empty=1` returns `{data:[tree]}`. `previous_post`/`next_post` are computed over ALL published posts, not per category. `PUT /posts/{id}` is a partial update; `categories` replaces the post's whole category set and accepts existing ids only. The Journal API has NO endpoint that creates a category. A token-authenticated editor lists drafts too with `GET /posts` (no `published` param). per_page is capped at 30. The write throttle allows 120 requests per minute, and a 429 answers `{"message":"Too Many Attempts."}`.
- Category creation is only possible through the cabana admin API: `POST /backend/api/v1/golem15/journal/categories` with a JSON object of form fields (`name` and `slug`, both `mltext`). A Bearer `g15_` token skips the CSRF check (cabana csrf.go), and the token scope `journal` covers the `golem15.journal.*` permissions. Production nginx refuses `/backend`, so creating categories in production needs an SSH tunnel to the binary (127.0.0.1:8095 on rome). Once the categories exist, the assignment and publish steps work through the public Journal API without a tunnel.
- Production state (read-only GET): 43 published posts, all slugged `summercms-x-y-z`, none with a category. Any plugin posts are drafts.
- Fixture harness: `pnpm test:changelog` runs node_modules/vue-journal-layer/testing/smoke-ssr.mjs with `--fixtures`. fixture-api.mjs implements category filtering (with children) and serves `rss.xml` from the fixture dir verbatim. It also requires every `/categories` request to carry `include_empty=1` and at least one API request to carry the forwarded X-Forwarded-For. The acme fixture has only categories `news`/`quiet`, so the app needs its own fixture.
- Landing contracts: tests/output.test.ts `assertLeavesRendered(readIndex(), ['meta','header','hero'])` requires EVERY `header.*` string in i18n/locales/en.json to appear in the generated index.html (attributes count). The landing prerender ignores the '/changelog' prefix, and linkChecker excludes '/changelog/**'. The site plugin's nodeServed treats '/changelog/...' as Node-served. nginx `location ^~ /changelog/` already routes the new paths. No deploy config change is needed.
- SiteHeader: `.links-wrap` has `overflow-x: auto`, so an absolutely positioned panel inside it would be clipped or would scroll. At max-width 720px `.links` and `.links-wrap` are hidden and `.docs` gets `margin-left: auto`. The footer already links /changelog.
- scripts/changelog-post.py calls `main()` at module level (no `__main__` guard), so it cannot be imported by tests yet. `publish` currently refuses a non-framework --repo without --draft. No Python tests exist.
- scripts/smoke.sh creates a superuser and a `journal` token (`TOKEN`), posts "SummerCMS 9.9.9" with slug `smoke-9-9-9`, and defines `purge` (it clears Nitro's whole route cache), `changelog_get`, `expect`, `expect_contains` and `expect_prefix`. The binary in smoke has no site_cache purge URL, so list pages cached earlier need the `purge` call before re-checking. The tool check loop covers docker, curl and node.
</context>
<tasks>
<task type="tracer">
<name>Task 1: Framework and plugins streams end to end in the changelog SSR, plus the header menu (vue-summercmsio-app)</name>
<files>
vue-summercmsio-app/app/components/SiteHeader.vue, vue-summercmsio-app/app/components/SiteFooter.vue, vue-summercmsio-app/i18n/locales/en.json,
vue-summercmsio-app/changelog/nuxt.config.ts, changelog/app/utils/streams.ts, changelog/app/composables/useChangelogStream.ts,
changelog/app/components/ChangelogIndex.vue, changelog/app/pages/changelog/index.vue, changelog/app/pages/changelog/plugins.vue,
changelog/app/components/Journal/IndexHeader.vue, changelog/app/components/Journal/RssLink.vue, changelog/app/layouts/default.vue, changelog/app/error.vue,
changelog/i18n/locales/en.json, changelog/server/utils/feed.ts, changelog/server/utils/changelogFeed.ts,
changelog/server/routes/changelog/rss.xml.ts, changelog/server/routes/changelog/plugins/rss.xml.ts,
changelog/testing/fixtures/summercms/{posts.json,categories.json,rss.xml}, changelog/testing/expect.txt, package.json, README.md
(all under /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app)
</files>
<read_first>
vue-summercmsio-app/app/components/SiteHeader.vue, app/components/SiteFooter.vue, changelog/nuxt.config.ts, changelog/app/layouts/default.vue, changelog/app/error.vue, changelog/app/components/Journal/*.vue, changelog/server/utils/feed.ts, changelog/server/routes/changelog/rss.xml.ts, changelog/testing/expect.txt, node_modules/vue-journal-layer/app/journal-pages/PostIndexPage.vue, node_modules/vue-journal-layer/testing/fixtures/acme/posts.json (entry shape only: the first entry and hello-acme / unsafe-sample content_html), app/assets/css/tokens.css (header colours and radii)
</read_first>
<action>
Implements D1 (filtering by the Journal categories `framework` and `plugins`). The layer stays untouched: everything is done in the app, reusing the layer's components. Work in /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app.
Streams (single source of truth):
- New changelog/app/utils/streams.ts, pure TypeScript importable by `node --test` (no Nuxt globals, no enums). Export type `ChangelogStream` ('framework' | 'plugins'). Export `CHANGELOG_STREAMS`, a frozen record mapping each stream to `{ category, index, feed }`: framework → 'framework', '/changelog', '/changelog/rss.xml'; plugins → 'plugins', '/changelog/plugins', '/changelog/plugins/rss.xml'. Export `PLUGIN_POST_SLUG` = /^sm-[a-z0-9-]*-plugin-\d/ (the slug changelog-post.py builds for a plugin tag) and `streamForPath(path)`. `streamForPath` drops the query, hash and trailing slash. It returns 'plugins' for '/changelog/plugins' and anything below it, and for '/changelog/<slug>' when the slug matches PLUGIN_POST_SLUG. Otherwise it returns 'framework'. Comment that the backfill in scripts/changelog-post.py uses the same slug rule.
- New changelog/app/composables/useChangelogStream.ts: `useChangelogStream()` returns `computed(() => streamForPath(useRoute().path))`. It is derived from the route only, so it is deterministic during SSR. A layout renders before the page's data loads, so the post's categories cannot be used there without a hydration mismatch.
Pages (per D1):
- changelog/nuxt.config.ts: set `journal.routes.index: false`, with a comment explaining that the layer's index page lists every post unfiltered, so the app's own pages own /changelog and /changelog/plugins. Keep perPage.index 10, post '/changelog/:slug', and category/tag/author false. Update the header comment block where it mentions the index.
- New changelog/app/components/ChangelogIndex.vue with prop `stream` (ChangelogStream, required). It mirrors the layer's PostIndexPage. It calls `parsePageParam(route.query.page)` and throws a fatal 404 on null. It calls `useAsyncData(\`changelog-${stream}-${page}\`, () => api.listPosts({ category: CHANGELOG_STREAMS[stream].category, page, perPage: cfg.perPage.index, sort: cfg.sort }), { transform: res => ({ ...res, data: res.data.map(toListPost) }) })`, where cfg = useRuntimeConfig().public.journal. It uses the same error status propagation, and throws a fatal 404 when pageOutOfRange. It renders section.journal-index with JournalIndexHeader :page, JournalPostList :posts and the stream's empty text, JournalPagination :current/:last, and JournalRssLink. Use the layer's relative-import style with .ts extensions ('vue-journal-layer/app/utils/pagination.ts', '.../view.ts'). An unknown category gives an empty list from the API, so a missing `plugins` category renders the empty state, never framework posts.
- New changelog/app/pages/changelog/index.vue and changelog/app/pages/changelog/plugins.vue. Each calls `definePageMeta({ key: (route) => route.fullPath })` (same as the layer, so client-side pagination re-runs setup) and renders `<ChangelogIndex stream="framework" />` or `stream="plugins"`. Vue Router ranks the static /changelog/plugins above /changelog/:slug, so a post slug `plugins` is reserved; note that in a comment.
Stream-aware chrome:
- changelog/app/components/Journal/IndexHeader.vue: read `useChangelogStream()`. The framework stream keeps the existing `changelog.*` keys. The plugins stream uses new keys under `changelog.plugins` (meta.title, meta.description, eyebrow, title, lead) for the heading and useSeoMeta. Keep the page suffix logic.
- changelog/app/components/Journal/RssLink.vue: href and head link come from CHANGELOG_STREAMS[stream].feed (keep key 'journal-rss'). On a post page this follows the slug rule too.
- changelog/app/layouts/default.vue: replace `links.indexLink()` (null now). The back link shows on post pages (`isPost` unchanged) and points at CHANGELOG_STREAMS[stream].index, with text `changelog.back` for framework or `changelog.plugins.back` for plugins. Pass the header `current` as 'changelog' for framework and 'changelog-plugins' for plugins.
- changelog/app/error.vue: header `current` from the same composable. "Read the changelog" goes to the stream's index path.
- changelog/i18n/locales/en.json: add `changelog.plugins` with meta.title "Plugin changelog", meta.description, eyebrow, title (for example "What changed in the SummerCMS plugins"), lead, empty ("No plugin release notes yet. …") and back ("← All plugin changes"). Copy is Claude's discretion; match the tone of the existing strings. Use plain English, no em dashes, and no planning IDs in user-facing copy.
Feeds (framework-only /changelog/rss.xml, plus a plugins feed so the plugins page's RSS link is not misleading):
- changelog/server/utils/feed.ts (pure; keep its current exports and behaviour):
- Give `rewriteChangelogFeed(xml, siteUrl, paths?)` an optional third argument `{ index, self }`, defaulting to '/changelog' and '/changelog/rss.xml'. The channel/root link goes to `${site}${index}` and the atom self link to `${site}${self}`; item links stay `${site}/changelog/<slug>`. Existing calls and tests must behave the same.
- Add `filterFeedItems(xml, categoryName)`. It keeps only `<item>` blocks that contain a `<category>` element whose XML-unescaped, trimmed text equals categoryName. A null categoryName removes every item. CDATA sections must be masked before matching and restored after, using the same split approach as the rewrite, so a literal closing item tag inside a description cannot cut an item. The channel elements stay untouched.
- Add `findCategoryName(tree, slug)`. It flattens `children`, matches `slug` and returns the `name`, or null.
- New changelog/server/utils/changelogFeed.ts: `changelogFeed(event, stream)` holds the current handler's logic: runtime apiBase, the forwarded X-Forwarded-For chain built the same way, and a 502 'The feed is unavailable.' text response when an API call fails. It fetches '/rss' (text) and '/categories' with query include_empty=1 in parallel, both with the forwarded header. It resolves the stream's category name, filters, rewrites with the stream's index/self paths and sets the content type as now. Import CHANGELOG_STREAMS relatively from '../../app/utils/streams.ts'.
- changelog/server/routes/changelog/rss.xml.ts becomes a thin `defineEventHandler(e => changelogFeed(e, 'framework'))` (keep the header comment, updated). Add changelog/server/routes/changelog/plugins/rss.xml.ts for 'plugins'. A feed holds the latest N published posts (Journal setting, default 20) filtered to its stream, so it may carry fewer than N items. Say so in the comment.
Header menu (vue-summercmsio-app/app/components/SiteHeader.vue, used by the landing and the changelog):
- Replace the plain Changelog anchor with a disclosure: `<details class="menu">` containing `<summary class="link menu-trigger">` (the text t('header.changelog') plus an aria-hidden caret) and a panel list with two plain anchors: href="/changelog" (t('header.changelogFramework') = "Framework") and href="/changelog/plugins" (t('header.changelogPlugins') = "Plugins"). They stay plain anchors because the changelog is another server. Place the `<details>` OUTSIDE `.links-wrap`, between it and the Docs button, because `.links-wrap` scrolls horizontally and would clip the panel. Keep the trigger's look identical to `.link`. The trigger is `.active` when `current` is 'changelog' or 'changelog-plugins', and the matching panel link gets aria-current="page". Document the prop values in the existing comment (the landing passes none).
- Use no ARIA menu roles; this is the WAI disclosure-navigation pattern. Hide the native marker (list-style none and ::-webkit-details-marker). Give the summary and the links a visible :focus-visible style. Rotate the caret on [open]. Right-align the panel under the trigger (absolute, opaque navy background from the tokens, the header border colour, a radius matching the design, a small shadow) above page content.
- Progressive enhancement only (the menu fully works without JS): in onMounted, add a document pointerdown listener that closes the menu when the press lands outside it, and a keydown listener on Escape that closes it and focuses the summary. Remove both in onBeforeUnmount. Restructure the existing onMounted so its scroll-spy early return does not skip these listeners. No window/document access during setup.
- Phones (Claude's discretion, no locked decision). At max-width 720px the section links stay hidden as today and the menu stays visible: move the `margin-left: auto` from `.docs` to `.menu`. Hide `.pill` before hiding the menu. Below the width where logo + menu + Docs no longer fit (about 400px), hide the menu. The footer then carries both links: in SiteFooter.vue add a "Plugin changelog" link to /changelog/plugins next to the existing Changelog link (new key `footer.pluginsChangelog` in en.json). Pick the exact breakpoints after the screenshot check in verify.
- vue-summercmsio-app/i18n/locales/en.json: add header.changelogFramework, header.changelogPlugins and footer.pluginsChangelog. Every header string must render in the landing HTML (output.test.ts); the panel markup is in the DOM even when closed.
Fixture and SSR expectations:
- Create changelog/testing/fixtures/summercms/ with posts.json, categories.json and rss.xml, in the same entry shape as the acme fixture (all keys of an acme post entry). Generate them once with a throwaway Node script kept in the session scratchpad, not committed.
- categories.json: Framework (id 1, slug framework) and Plugins (id 2, slug plugins), with post_count values.
- posts.json: 12 published framework posts. Among them: `summercms-0-5-0` ("SummerCMS 0.5.0") reusing acme hello-acme's content_html with the youtube-nocookie embed, and `unsafe-sample` reusing acme's unsafe content (category framework). Add 11 published plugin posts (`sm-user-plugin-0-1-1` … titled "sm-user-plugin 0.1.N", or a mix of sm-*-plugin names, category plugins), one published `uncategorised-note` with no category, and one unpublished `draft-note` in framework. Use past published_at dates, interleaving the two streams so that a global listing would mix them.
- rss.xml: a static RSS 2.0 document like the API's, with links on http://127.0.0.1:8095/blog/<slug>, a channel link http://127.0.0.1:8095/ and an atom self link on .../_journal/api/v1/rss. Include items for 2 framework posts (`<category>Framework</category>`), 2 plugin posts (`<category>Plugins</category>`), the uncategorised post, and one framework item whose CDATA description contains the literal text of a closing item tag.
- package.json: point `test:changelog` --fixtures at changelog/testing/fixtures/summercms.
- Rewrite changelog/testing/expect.txt against the new fixture. Keep every existing category of check (index/pagination, single post/sanitizing/missing/draft, landing chrome, SEO canonical, asset prefixes, i18n messages, not-this-server 404s, views, feed), with acme slugs and text swapped for the new ones, and update the header comment (fixture name, counts). Add:
- /changelog lists framework only: contains SummerCMS 0.5.0, notcontains sm-user-plugin, notcontains uncategorised-note; ?page=2 is 200, ?page=3 and ?page=abc are 404.
- /changelog/plugins: contains a plugin slug, notcontains summercms-0-5-0, ?page=2 is 200, ?page=3 and ?page=abc are 404; contains <title>Plugin changelog, contains href="/changelog/plugins/rss.xml", contains class="changelog-entry", and its canonical is https://summercms.io/changelog/plugins.
- Header menu on /changelog: contains href="/changelog/plugins" and class="menu".
- A plugin post page contains class="changelog-back" and the plugins back text; /changelog/uncategorised-note is 200.
- /changelog/rss.xml contains https://summercms.io/changelog/summercms-0-5-0 and notcontains sm-user-plugin.
- /changelog/plugins/rss.xml: type application/rss+xml, contains a plugin item link under https://summercms.io/changelog/, notcontains summercms-0-5-0, contains the atom self link https://summercms.io/changelog/plugins/rss.xml, notcontains 127.0.0.1.
- vue-summercmsio-app/README.md "Changelog" section: describe both streams, the categories (slugs framework/plugins are a contract with scripts/changelog-post.py), both feeds and their latest-N filtering, the reserved slug `plugins`, the app-owned fixture, and the known limitation that a post's previous/next links follow the API's global order and may cross streams.
Commits in the submodule, by explicit path: `feat(header): Changelog menu with Framework and Plugins` (SiteHeader, SiteFooter, landing en.json); `feat(changelog): framework and plugins streams (D1)` (changelog/**, package.json); `docs: changelog streams` (README.md).
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app && pnpm generate && pnpm test && pnpm build:changelog && pnpm test:changelog</automated>
<automated>Visual check of the header (Claude reads the PNGs). Serve the generated landing (for example `npx nuxi preview` or `python3 -m http.server` from .output/public) and run `chromium --headless --disable-gpu --screenshot=<scratchpad>/hdr-W.png --window-size=W,700 http://127.0.0.1:<port>/` for W = 360, 390, 414, 768 and 1280. Confirm one header row with no horizontal overflow, and the menu visible wherever it is meant to be. Open the menu once on the built changelog (node changelog/.output/server/index.mjs with NUXT_JOURNAL_API_BASE pointed at nothing is fine for the header) to confirm the panel is not clipped.</automated>
</verify>
<done>
`pnpm generate`, `pnpm test`, `pnpm build:changelog` and `pnpm test:changelog` pass. The SSR checks prove that /changelog holds only framework posts and /changelog/plugins only plugin posts, each paginated, with the menu in the header, the stream-aware back link, and two category-filtered feeds. Screenshots show the header without overflow at 360 to 1280px. The three submodule commits exist; vue-journal-layer and node_modules are untouched.
</done>
</task>
<task type="auto">
<name>Task 2: changelog-post.py categories on publish, published plugin posts and the backfill subcommand, proven in smoke; docs (sm-summercmsio-app, summercms.go)</name>
<files>
/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/scripts/changelog-post.py,
/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/scripts/smoke.sh,
/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/CLAUDE.md,
/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/README.md,
/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/DEPLOY.md,
/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/CLAUDE.md
</files>
<read_first>
sm-summercmsio-app/scripts/changelog-post.py (183 lines), scripts/smoke.sh lines 1-60 and 226-357, DEPLOY.md "Posting to the changelog" (line 306 on), CLAUDE.md "Commands" and "Release posts", README.md command list; summercms.go/CLAUDE.md "Commits and concurrency" (the changelog bullet)
</read_first>
<action>
Implements D2 and D3 in /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/scripts/changelog-post.py, stdlib only.
Structure:
- Add an `if __name__ == "__main__": main()` guard in place of the bare `main()` call, so tests can import the module.
- Add module constants: the categories (slug, name) framework/"Framework" and plugins/"Plugins"; FRAMEWORK_SLUG = ^summercms-\d; PLUGIN_SLUG = ^sm-[a-z0-9-]*-plugin-\d. Comment that the changelog app's streams.ts uses the same plugin slug rule.
- Add ADMIN_API from SUMMERCMS_ADMIN_API, defaulting to the origin of SUMMERCMS_JOURNAL_API plus '/backend/api/v1'. Put the derivation in a pure `admin_api_base(journal_api)` function.
- Give `call` an optional base (default API) and retry a 429 up to 3 times, sleeping Retry-After seconds (default 5).
- Pure helpers, no I/O, unit-tested in Task 3:
- `classify(slug)` returns 'framework', 'plugins' or None.
- `flatten_categories(tree)` and `category_ids(tree)` return slug → id.
- `merge_categories(existing_ids, target_id)` keeps the existing ids and appends the target once.
- `plan_backfill(posts, ids, publish_drafts)` returns `(updates, in_place, unclassified)`. An update is (post id, slug, payload). The payload carries `categories` only when the target id is missing, and `published: True` only when publish_drafts is set, the post is a plugin post, it is unpublished and it has a published_at. A post needing neither change counts as in place.
- Keep the token rules as they are (env first, then the mode-600 file).
publish (D2):
- Drop the refusal of a non-framework --repo without --draft. The category is framework for the framework repo and plugins otherwise.
- Resolve its id from GET /categories?include_empty=1. When it is missing, exit with "category <slug> does not exist; run `scripts/changelog-post.py backfill` once (it creates it)".
- Set body `categories` to [id]. On --update, merge with the existing post's category ids. `published` = not --draft.
- Output lines: the post URL https://summercms.io/changelog/<slug>, and "listed on /changelog" or "listed on /changelog/plugins". Keep the existing draft and purge messages. Update `--repo` help, the `--draft` help and the module docstring: plugin releases are published on /changelog/plugins; --draft saves either kind unpublished.
backfill (D3), a new subcommand with --dry-run, --yes and --publish-drafts:
- Print the target Journal API first. Refuse to write unless --dry-run or --yes is given, with "backfill writes to <API>; preview with --dry-run, apply with --yes".
- List every post: GET /posts?per_page=30&page=N with the token, which includes drafts, until meta.last_page. Read the categories.
- Missing categories are reported under --dry-run. Otherwise create each with POST {ADMIN_API}/golem15/journal/categories, body {"name": NAME, "slug": SLUG}, with the same Bearer token and an Accept JSON header. If the admin API rejects that body shape, read GET {ADMIN_API}/golem15/journal/categories/schema/form on the smoke binary and adapt. Re-read the categories and fail loudly if either is still missing, naming the admin path. Production nginx refuses /backend, so the error text must say to run through an SSH tunnel or to create the two categories in the admin.
- Apply `plan_backfill`: one PUT /posts/{id} per update, or print the would-be changes under --dry-run. Print a summary: updated, already in place, created categories, and unclassified slugs (left as they are).
- A second run reports 0 updates.
Smoke (scripts/smoke.sh; this is the only place backfill runs):
- Add python3 to the tool check loop.
- Rename the smoke post slug smoke-9-9-9 to summercms-9-9-9 everywhere in the file (the post, the entry check, the purge body), so it is shaped like a real framework post. Add an `expect_not_contains` helper next to `expect_contains` if none exists.
- After the purge-route checks and before "smoke: ok", add a section "Changelog streams and backfill (D3)":
1. With the same token, create "sm-acme-plugin 9.9.9" (slug sm-acme-plugin-9-9-9, published, published_at NOW) and "sm-acme-plugin 9.9.8" (slug sm-acme-plugin-9-9-8, published false, published_at NOW).
2. Run the script as `env -i PATH="$PATH" HOME="$TMP" SUMMERCMS_JOURNAL_API="$API" SUMMERCMS_JOURNAL_TOKEN="$TOKEN" python3 -B "$ROOT/scripts/changelog-post.py" backfill --dry-run` and expect exit 0 and the two would-create lines. Then run `backfill --yes --publish-drafts` and expect exit 0. Then run `backfill --yes` again and expect "0 updated" (or the chosen wording).
3. GET $API/categories?include_empty=1 contains both slugs.
4. Call `purge "$PURGE_TOKEN"` and expect 200 (the list pages were cached before the writes).
5. Check through changelog_get with Accept text/html: /changelog contains /changelog/summercms-9-9-9 and not sm-acme-plugin. /changelog/plugins contains sm-acme-plugin-9-9-9 and sm-acme-plugin-9-9-8 (published by --publish-drafts) and not summercms-9-9-9. /changelog/rss.xml contains changelog/summercms-9-9-9 and not sm-acme-plugin. /changelog/plugins/rss.xml contains changelog/sm-acme-plugin-9-9-9 and not summercms-9-9-9. /changelog/sm-acme-plugin-9-9-9 contains href="/changelog/plugins".
- Update the smoke header comment to mention the streams and the backfill.
Docs (D2), same commit set, separate from code:
- sm-summercmsio-app CLAUDE.md:
- Commands: the plugin example without --draft; add `scripts/changelog-post.py backfill --dry-run | --yes [--publish-drafts]`.
- Rewrite "Release posts": framework tag → category framework on /changelog; plugin tag → `--repo <plugin checkout>`, category plugins, published on /changelog/plugins; `--draft` for an unpublished post; categories created by backfill.
- Cross-repo contracts: the /changelog prefix now holds /changelog/plugins and /changelog/plugins/rss.xml; the category slugs framework/plugins are a contract between the script and the changelog app.
- README.md: the command list and the changelog sentence.
- DEPLOY.md "Posting to the changelog":
- Replace the plugin-draft paragraph with published plugin posts on /changelog/plugins.
- Add a once-only "Categories and backfill" step: open a tunnel with `ssh -N -L 18095:127.0.0.1:8095 rome`, then run `SUMMERCMS_JOURNAL_API=http://127.0.0.1:18095/_journal/api/v1 scripts/changelog-post.py backfill --dry-run` and the same with `--yes`, optionally `--publish-drafts`. Without the tunnel, create the two categories in the admin first; then the backfill needs only the public API.
- Rollout order: backfill before deploying the new changelog build and binary. The new /changelog lists only categorised posts, and publish needs the categories.
- The binary must be rebuilt too, because the landing header is embedded.
- summercms.go/CLAUDE.md: in the changelog bullet, add one sentence: plugin release tags get a post on https://summercms.io/changelog/plugins with `scripts/changelog-post.py publish vX.Y.Z notes.md --excerpt "…" --repo ../sm-<name>-plugin`. Keep the rest of the bullet.
Commits:
- In sm-summercmsio-app: `feat(scripts): changelog-post categories, published plugin posts and backfill (D2, D3)`, `test(smoke): changelog streams and backfill`, and `docs: changelog streams, plugin posts and backfill (D2)`.
- In summercms.go: `docs: plugin release posts go to /changelog/plugins`, staging only CLAUDE.md.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app && python3 -B scripts/changelog-post.py backfill --help && python3 -B scripts/changelog-post.py publish --help && scripts/build.sh dev && scripts/smoke.sh</automated>
</verify>
<done>
`scripts/build.sh dev` and `scripts/smoke.sh` pass on Docker, including the new streams and backfill section. If the Docker socket is denied although the user is in the docker group, rerun through `sudo -u golem` as summercms.go/CLAUDE.md describes. The help texts show the new subcommand and flags. Docs in the app and the framework CLAUDE.md describe D2/D3 accurately. No command touched https://summercms.io with a write.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Unit tests last (streams, feed filter, script helpers), then the submodule pointer bump</name>
<files>
/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app/tests/changelog.test.ts,
/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/scripts/test_changelog_post.py,
/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app (submodule pointer)
</files>
<behavior>
- streamForPath: '/changelog' and '/changelog?page=2' → framework; '/changelog/plugins', '/changelog/plugins/', '/changelog/plugins?page=2' → plugins; '/changelog/sm-user-plugin-0-1-13' and a prerelease slug '/changelog/sm-user-plugin-0-1-0-alpha-1' → plugins; '/changelog/summercms-0-5-0' and '/changelog/plugin-notes' → framework.
- filterFeedItems: keeps only items whose category text equals the name (including an XML-escaped name such as "A &amp; B"); null removes all items; a CDATA description containing the literal closing item tag neither splits nor drops a kept item; channel title/link/atom elements are unchanged; an item with several categories is kept when one matches.
- findCategoryName: finds a nested child by slug; unknown slug → null.
- rewriteChangelogFeed with { index: '/changelog/plugins', self: '/changelog/plugins/rss.xml' }: channel link and atom self follow the paths, item links stay /changelog/<slug>; the existing default-path tests still pass unchanged.
- Python classify: summercms-0-5-3 → framework, sm-user-plugin-0-1-13 → plugins, smoke-9-9-9 / hello → None.
- Python plan_backfill: an uncategorised framework post gets categories [fw]; a post with category 7 gets [7, fw]; an already-categorised post is in place; a plugin draft with published_at gets published only with publish_drafts; a plugin draft without published_at is not published; an unclassified post is listed, not updated; running the plan over its own output yields no updates.
- Python admin_api_base: https://summercms.io/_journal/api/v1 → https://summercms.io/backend/api/v1; http://127.0.0.1:18095/_journal/api/v1 → http://127.0.0.1:18095/backend/api/v1.
- Python merge_categories keeps order and does not duplicate.
</behavior>
<action>
Unit tests are the last plan step per summercms.go/CLAUDE.md rule 2.
- Extend vue-summercmsio-app/tests/changelog.test.ts (node:test, strict assert) with the TypeScript cases above. Import with .ts extensions from '../changelog/app/utils/streams.ts' and '../changelog/server/utils/feed.ts'. Keep every existing test unchanged.
- Create sm-summercmsio-app/scripts/test_changelog_post.py (stdlib unittest). Load the hyphenated script with importlib.util.spec_from_file_location relative to the test file; the __main__ guard prevents main() from running. Before loading, set SUMMERCMS_JOURNAL_API to a loopback placeholder URL and HOME to a temporary directory. In setUp, replace the module's `call` with a function that raises, so any accidental network use fails the test. Cover the Python cases above. If a test exposes a bug in Task 1 or Task 2 code, fix the code in a separate `fix:` commit in the right repo.
- Commits:
- vue-summercmsio-app submodule: `test(changelog): streams, feed filter and per-stream feed paths`.
- sm-summercmsio-app: `test(scripts): changelog-post helpers`.
- Then, last, in sm-summercmsio-app, stage only the vue-summercmsio-app gitlink and commit `chore: bump vue-summercmsio-app for the changelog streams`. Check `git -C vue-summercmsio-app status --short` is clean first, and use `ssu` status if helpful. Do not push.
</action>
<verify>
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app && pnpm generate && pnpm test && cd .. && python3 -B -m unittest discover -s scripts -p 'test_*.py' -v && git -C vue-summercmsio-app status --short && git status --short</automated>
</verify>
<done>
`pnpm test` and the Python unittest discovery pass. The submodule working tree is clean. sm-summercmsio-app has the pointer bump as its own last commit and no unintended staged files (only the pre-existing untracked scripts/__pycache__/ may remain). Nothing is pushed, tagged or deployed.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| visitor → changelog SSR (Node) | untrusted query (?page) and paths under /changelog |
| changelog SSR → Journal API (loopback) | API XML/JSON is trusted-ish backend data, rendered or passed through |
| operator machine → Journal write API / cabana admin API | g15_ token scoped journal; production writes are the user's alone |
| executor session → production | must not cross: no writes to summercms.io from this work |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-261028-wqu-01 | Tampering | changelog-post.py backfill/publish run by the executor | high | mitigate | The production token file exists on this machine. Smoke runs the script with `env -i` and HOME=$TMP, plus explicit loopback SUMMERCMS_JOURNAL_API and the smoke token. Unit tests stub `call` to raise. backfill refuses writes without --yes and prints the target API first. |
| T-261028-wqu-02 | Information disclosure | journal token in smoke and script output | medium | mitigate | The token travels only by env var (never argv, never printed). The script output names post slugs and ids, never the token. The existing no-leak checks in smoke stay. |
| T-261028-wqu-03 | Tampering | filterFeedItems / rewriteChangelogFeed on API XML | medium | mitigate | CDATA is masked before matching and never edited. Filtering only removes whole items. Category names are compared after XML-unescaping, and nothing from the request is interpolated into the feed. Unit tests cover a closing item tag inside CDATA. |
| T-261028-wqu-04 | Denial of service | feed routes now make 2 API calls per uncached request | low | accept | The existing swr 60 route rules cache both feeds. The API throttle (120 per minute) and forwarded X-Forwarded-For buckets are unchanged. |
| T-261028-wqu-05 | Spoofing | /changelog/plugins shadows a post slugged `plugins` | low | accept | The slug is documented as reserved in the README and page comment. The script never generates it. |
| T-261028-wqu-06 | Elevation of privilege | category creation through the cabana admin API with a token | medium | transfer | Cabana's existing token-scope and permission checks (golem15.journal.*) govern it. nginx keeps /backend refused, and production use needs an operator SSH tunnel. No deploy config changes. |
| T-261028-wqu-07 | Tampering | backfill replacing a post's category set | medium | mitigate | merge_categories keeps the existing ids, the plan is idempotent (second run: 0 updates), and --dry-run previews. Covered by unit tests and the smoke re-run. |
| T-261028-wqu-SC | Tampering | npm/pip/cargo installs | high | accept | This plan installs no package: stdlib Python, existing pnpm lockfile, and no change to vue-journal-layer's pin. |
</threat_model>
<verification>
- Task 1: `pnpm generate && pnpm test && pnpm build:changelog && pnpm test:changelog` in vue-summercmsio-app, plus the header screenshots at 360/390/414/768/1280.
- Task 2: `scripts/build.sh dev && scripts/smoke.sh` in sm-summercmsio-app (Docker; this is the only backfill run, against the local smoke binary).
- Task 3: `pnpm test` and `python3 -B -m unittest discover -s scripts -p 'test_*.py' -v`; clean submodule; pointer bump is the last commit.
- Scope: `git -C <layer path> status` is not applicable (the layer is not edited); `git diff --stat` in sm-summercmsio-app shows no change under plugins/ or deploy/.
</verification>
<success_criteria>
- D1: /changelog shows only framework posts and /changelog/plugins only plugin posts, in the same layout, both paginated, with category-filtered feeds. The header Changelog link is a working Framework/Plugins dropdown on the landing and the changelog, with or without JS, on desktop and touch, and phones reach both changelogs.
- D2: publish assigns framework/plugins and publishes plugin posts by default. The docs in sm-summercmsio-app (CLAUDE.md, README.md, DEPLOY.md) and summercms.go/CLAUDE.md match.
- D3: backfill creates the categories when missing, assigns framework/plugins idempotently, previews with --dry-run and is gated by --yes. It is proven only in smoke. DEPLOY.md tells the user how to run it against production (tunnel, order).
- All listed tests pass. Commits are in the order submodule → app → pointer bump, plus one framework docs commit. No push, tag or deploy.
- SUMMARY records these follow-ups for the user:
- A post's previous/next can cross streams: the API's siblings are global, and fixing that needs category-scoped siblings in sm-journal-plugin or an app-owned post page.
- Each feed is the latest N posts (Journal RSS setting) filtered to its stream.
- Production needs the backfill before the new build is deployed, and the binary must be rebuilt for the landing header.
- The user memory note "Changelog: framework tags only" is superseded by D2.
</success_criteria>
<output>
Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261028-wqu-summercms-io-changelog-dropdown-framewor/261028-wqu-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,356 @@
---
phase: quick-261028-wqu
plan: 01
subsystem: summercms.io changelog (vue-summercmsio-app, sm-summercmsio-app)
tags: [changelog, journal, categories, nuxt, header, rss]
status: complete
tasks_done: [1, 2, 3]
tasks_pending: []
requires: [vue-journal-layer v0.6.0 (unchanged), sm-journal-plugin GET /posts?category, GET /rss, GET /categories?include_empty=1]
provides:
- /changelog (category framework) and /changelog/plugins (category plugins), 10 per page
- /changelog/rss.xml and /changelog/plugins/rss.xml filtered by category
- header Changelog disclosure menu (Framework / Plugins), footer plugin changelog link
affects: [scripts/changelog-post.py, scripts/smoke.sh, sm-summercmsio-app submodule pointer (vue-summercmsio-app 072c2f8 -> f1cd587)]
key-files:
created:
- vue-summercmsio-app/changelog/app/utils/streams.ts
- vue-summercmsio-app/changelog/app/composables/useChangelogStream.ts
- vue-summercmsio-app/changelog/app/components/ChangelogIndex.vue
- vue-summercmsio-app/changelog/app/pages/changelog/index.vue
- vue-summercmsio-app/changelog/app/pages/changelog/plugins.vue
- vue-summercmsio-app/changelog/server/utils/changelogFeed.ts
- vue-summercmsio-app/changelog/server/routes/changelog/plugins/rss.xml.ts
- vue-summercmsio-app/changelog/testing/fixtures/summercms/{posts.json,categories.json,rss.xml}
- scripts/test_changelog_post.py
modified:
- vue-summercmsio-app/app/components/SiteHeader.vue
- vue-summercmsio-app/app/components/SiteFooter.vue
- vue-summercmsio-app/i18n/locales/en.json
- vue-summercmsio-app/changelog/nuxt.config.ts
- vue-summercmsio-app/changelog/app/components/Journal/IndexHeader.vue
- vue-summercmsio-app/changelog/app/components/Journal/RssLink.vue
- vue-summercmsio-app/changelog/app/layouts/default.vue
- vue-summercmsio-app/changelog/app/error.vue
- vue-summercmsio-app/changelog/i18n/locales/en.json
- vue-summercmsio-app/changelog/server/utils/feed.ts
- vue-summercmsio-app/changelog/server/routes/changelog/rss.xml.ts
- vue-summercmsio-app/changelog/testing/expect.txt
- vue-summercmsio-app/package.json
- vue-summercmsio-app/README.md
- vue-summercmsio-app/tests/changelog.test.ts
- scripts/changelog-post.py
- scripts/smoke.sh
- CLAUDE.md, README.md, DEPLOY.md (sm-summercmsio-app)
- summercms.go CLAUDE.md
- vue-summercmsio-app (submodule pointer)
commits:
- vue-summercmsio-app ef1c2bc feat(header): Changelog menu with Framework and Plugins
- vue-summercmsio-app dd14075 feat(changelog): framework and plugins streams (D1)
- vue-summercmsio-app 78361b7 docs: changelog streams
- vue-summercmsio-app f1cd587 test(changelog): streams, feed filter and per-stream feed paths
- sm-summercmsio-app e7ae6a9 feat(scripts): changelog-post categories, published plugin posts and backfill (D2, D3)
- sm-summercmsio-app 79edea0 test(smoke): changelog streams and backfill
- sm-summercmsio-app 59d1b0c docs: changelog streams, plugin posts and backfill (D2)
- sm-summercmsio-app 94c1366 test(scripts): changelog-post helpers
- sm-summercmsio-app 29dd1b3 chore: bump vue-summercmsio-app for the changelog streams
- summercms.go bac8e7e docs: plugin release posts go to /changelog/plugins
decisions:
- "Stream of a page comes from its path only (streamForPath): /changelog/plugins/** and slugs matching ^sm-[a-z0-9-]*-plugin-\\d are plugins, all else framework"
- "Header breakpoints from screenshots: pill hidden at max-width 519px, menu hidden at max-width 383px (logo + menu + Docs need about 380px), trigger padding 8px at max-width 420px"
metrics:
task1_duration: 11min
task1_completed: 2026-10-28
task2_duration: 12min
task2_completed: 2026-10-28
task2_commits: 4 (sm-summercmsio-app 2ef10bc..59d1b0c: 3; summercms.go 83a952a..bac8e7e: 1)
task2_tokens: 11900 (chars/4 over the sm-summercmsio-app diff, plus the one-line framework CLAUDE.md change)
task3_duration: 8min
task3_completed: 2026-10-28
task3_commits: 3 (vue-summercmsio-app 78361b7..f1cd587: 1; sm-summercmsio-app 59d1b0c..29dd1b3: 2)
task3_tokens: 5400 (chars/4 over the two test diffs)
actuals:
tasks: 3
commits: 10 (vue-summercmsio-app 4, sm-summercmsio-app 5, summercms.go 1; multi-repo, counted per repo from the commit tables)
---
# Quick 261028-wqu: changelog streams (framework / plugins) and the header menu
All three tasks are done. The summercms.io changelog has a framework stream at `/changelog` and a plugins stream at `/changelog/plugins`, each backed by a Journal category and each with its own feed. The header has a Framework/Plugins menu. `changelog-post.py` files posts by category and has a gated `backfill`. Unit tests cover the stream, feed and script helpers. Nothing is pushed, tagged or deployed.
## Task 1: framework and plugins streams in the changelog SSR, plus the header menu
The changelog now has two streams. `/changelog` lists only Journal posts in category `framework`, and `/changelog/plugins` lists only posts in category `plugins`. Both use the same layout and are paginated with `?page=N`. Each stream has its own feed, filtered by category. The header's Changelog entry is now a `<details>` disclosure with Framework and Plugins links, on both the landing and the changelog. vue-journal-layer is not modified.
Repo: `/media/nvme/dev/golem15/summercms.io/summercms/sm-summercmsio-app/vue-summercmsio-app` (branch master, base 072c2f8).
### Commits
| Hash | Message | Files |
|------|---------|-------|
| ef1c2bc | feat(header): Changelog menu with Framework and Plugins | app/components/SiteHeader.vue, app/components/SiteFooter.vue, i18n/locales/en.json |
| dd14075 | feat(changelog): framework and plugins streams (D1) | changelog/** (20 files), package.json |
| 78361b7 | docs: changelog streams | README.md |
The submodule pointer in sm-summercmsio-app has NOT been bumped. `git -C sm-summercmsio-app status` shows ` M vue-summercmsio-app`, and task 3 commits that bump.
### What was built
- `changelog/app/utils/streams.ts`: `ChangelogStream`, `CHANGELOG_STREAMS` (frozen: category, index and feed for each stream), `PLUGIN_POST_SLUG = /^sm-[a-z0-9-]*-plugin-\d/` and `streamForPath(path)`. `streamForPath` ignores the query, hash and trailing slash. A slug like `/changelog/a/b` (with a slash) is never a plugin.
- `useChangelogStream()` is a computed value over `useRoute().path`.
- `nuxt.config.ts` sets `journal.routes.index: false`. `ChangelogIndex.vue` mirrors the layer's PostIndexPage: `parsePageParam` and `pageOutOfRange` give the 404s, it calls `listPosts({ category, page, perPage, sort })`, and it renders the header, the list (with the stream's empty text), the pagination and an RSS link. The pages `pages/changelog/index.vue` and `plugins.vue` set `definePageMeta({ key: route => route.fullPath })`. The `plugins` slug is reserved, and this is noted in the page comment and the README.
- IndexHeader, RssLink, the layout back link and error.vue all follow the stream. The header's `current` is `changelog` or `changelog-plugins`, and "Read the changelog" on the error page goes to the stream's index.
- `feed.ts`:
- `rewriteChangelogFeed(xml, site, paths?)` takes an optional third argument; the defaults behave as before (existing tests pass).
- New `filterFeedItems(xml, name|null)`: CDATA is masked; names are compared after XML-unescaping outside CDATA, with CDATA content taken raw; an item with several categories is kept if any matches; channel elements are left alone.
- New `findCategoryName(tree, slug)` searches nested trees.
- `changelogFeed.ts` fetches `/rss` and `/categories?include_empty=1` in parallel, forwarding X-Forwarded-For. It returns 502 "The feed is unavailable." when either fails. It then filters the feed and rewrites the channel and self links per stream. Both route files are thin handlers.
- Header menu:
- `<details class="menu">` sits between `.links-wrap` and Docs. The summary has classes `link menu-trigger`, an aria-hidden caret that rotates when open, and `.active` on either changelog stream. The panel is a `ul.menu-panel` with two plain anchors, and `aria-current="page"` marks the current stream.
- Styling: no ARIA menu roles; the marker is hidden; `:focus-visible` outlines; the panel is navy-850 with a border-12 border, 12px radius and a shadow, right-aligned, z-index 20.
- In onMounted, a document pointerdown listener (closes on a press outside) and a keydown listener (Escape closes and focuses the summary) are registered before the scroll-spy early return. Both are removed in onBeforeUnmount.
- Phones: at max-width 720px the menu takes `margin-left: auto`. The pill is hidden at max-width 519px and the menu at max-width 383px, where Docs gets `margin-left: auto` back. The footer adds "Plugin changelog" (`footer.pluginsChangelog`).
- Fixture `changelog/testing/fixtures/summercms/` was generated by a throwaway script in the session scratchpad (not committed). It holds:
- 12 published framework posts: `summercms-0-5-0` with the acme hello-acme body and its youtube embed, `unsafe-sample`, and `summercms-0-4-9` to `summercms-0-4-0`.
- 11 published plugin posts: `sm-{translate,journal,user}-plugin-0-1-11` down to `-0-1-1`, interleaved by date with the framework posts.
- `uncategorised-note`, published, with no category.
- `draft-note`, unpublished, in framework.
- `rss.xml`: 3 framework items (one with `</item>` inside CDATA), 2 plugin items, and the uncategorised item.
- `expect.txt` is rewritten against the new fixture, with 76 checks. Every earlier check category is kept, and the new stream, menu, back link and feed checks are added.
### Verification
- `pnpm generate`: OK (link checker 0 errors).
- `pnpm test`: 38/38 pass (output.test.ts confirms every `header.*` string, including the new menu strings, is in the generated index.html).
- `pnpm build:changelog`: OK. The only warnings are the ones already there (nuxt-seo-utils treeShake on Unhead v2, the sourcemap note).
- `pnpm test:changelog`: `smoke-ssr: ok (76 checks)`.
- Extra HTML checks with Accept text/html (scratch script):
- `/changelog/sm-foo-plugin-1-0-0` gives 404 "Nothing here", the button goes to `/changelog/plugins` and the Plugins entry is aria-current.
- `/changelog/missing-post` gives 404 with the button on `/changelog`.
- `/changelog/plugins?page=9` gives 404 with the button on `/changelog/plugins`.
- The SSR page carries `<details class="menu">` closed, so the menu works without JS.
- Visual check through headless Chromium and CDP. Screenshots are in `/tmp/claude-1000/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/96c1c0a2-c22c-4fd8-a13e-29e8a18b48c2/scratchpad/shots/`.
- Widths checked: 320, 340, 360, 375, 383, 384, 390, 400, 414, 519, 520, 560, 720, 721, 768 and 1280.
- At every width the header is one 64px row and `document.scrollWidth` equals the viewport (minus the scrollbar). The menu is visible from 384px up, and the pill from 520px up.
- With the menu opened on the built changelog at 414, 768 and 1280, the panel is fully visible under the trigger and not clipped.
- Escape closes the menu and moves focus to the SUMMARY. A pointerdown outside closes it too.
- vue-journal-layer and node_modules are untouched. The submodule working tree is clean after the commits.
### Deviations
1. **[Rule 2 - correctness] Header breakpoints and trigger padding.** The plan left the exact breakpoints to the screenshot check. Measured, logo + menu + Docs need about 388px with the default trigger padding. So at max-width 420px the trigger padding drops to 8px, which brings the total to about 380px, and the menu is hidden at max-width 383px. iPhone widths of 390 and 414 keep the menu; 360 and 375 fall back to the footer links. The pill is hidden at max-width 519px, not right at 720px, because it fits up to there.
2. **[Rule 1 - robustness] Entity decoding guard in `filterFeedItems`.** Numeric entities above U+10FFFF are left as they are instead of throwing. CDATA content inside a `<category>` is taken raw and not entity-decoded.
3. **Expect check wording.** The plan's example checks used `notcontains=sm-user-plugin`. The fixture mixes three plugin names, so the framework checks use `notcontains=-plugin-0-1-`, which covers all of them, and `notcontains=summercms-0-` on the plugin pages and feed. Existing check categories are kept, with acme slugs swapped for `summercms-0-5-0`.
4. The menu, back-link and error-page `current` values are computed in the layout and error.vue (not in SiteHeader), so SiteHeader keeps a plain `current` string prop. This matches the plan.
No auth gates. No package was installed.
### Notes for tasks 2 and 3
- **Category slug contract:** exactly `framework` and `plugins`. The feed filter matches the category NAME from `/categories?include_empty=1` against each `<category>` element, so the names can be anything ("Framework"/"Plugins" expected).
- **Plugin slug rule** in streams.ts is `^sm-[a-z0-9-]*-plugin-\d`; the Python `PLUGIN_SLUG` must match it (the smoke slugs `sm-acme-plugin-9-9-9` and `-9-9-8` match). Framework posts are recognised by the category only: on post pages, anything that is not a plugin slug is treated as framework.
- **Smoke checks (task 2) that already hold in SSR:**
- A plugin post page renders `href="/changelog/plugins" class="changelog-back"`, and `href="/changelog/plugins"` also appears in the header menu on every page. A check for `href="/changelog/plugins"` on `/changelog/sm-acme-plugin-9-9-9` therefore passes on the header alone; check `class="changelog-back"` together with the plugins back text "All plugin changes" for a meaningful check.
- `/changelog` now contains the string `/changelog/plugins` (header menu and footer). A `notcontains=sm-acme-plugin` check is still fine.
- **Uncategorised posts:** after deploy, uncategorised posts (all 43 production posts today) disappear from `/changelog` and its feed until the backfill runs. Task 2's DEPLOY.md order (backfill first) matters.
- **Feed size:** `/changelog/plugins/rss.xml` filters the API's latest N items, so with few plugin posts among the latest 20 it may be short or empty.
- **Unit tests for task 3** go into `tests/changelog.test.ts`. Import `../changelog/app/utils/streams.ts` and `../changelog/server/utils/feed.ts`. The exports are `streamForPath`, `CHANGELOG_STREAMS`, `PLUGIN_POST_SLUG`, `filterFeedItems`, `findCategoryName`, `rewriteChangelogFeed(xml, site, { index, self })` and the type `FeedCategory`.
- The README already documents the previous/next cross-stream limitation.
## Task 2: changelog-post.py categories, published plugin posts and backfill; smoke; docs
`publish` now files framework posts under category `framework` and plugin posts (`--repo <plugin>`) under `plugins`, and it publishes plugin posts (D2). The new `backfill` subcommand creates both categories when missing and files the existing posts (D3). Backfill ran only inside `scripts/smoke.sh`, against the local smoke binary. The docs in the app and in the framework CLAUDE.md are updated.
### Commits
| Repo | Hash | Message |
|------|------|---------|
| sm-summercmsio-app | e7ae6a9 | feat(scripts): changelog-post categories, published plugin posts and backfill (D2, D3) |
| sm-summercmsio-app | 79edea0 | test(smoke): changelog streams and backfill |
| sm-summercmsio-app | 59d1b0c | docs: changelog streams, plugin posts and backfill (D2) |
| summercms.go | bac8e7e | docs: plugin release posts go to /changelog/plugins (CLAUDE.md only) |
Bases: sm-summercmsio-app 2ef10bc, summercms.go 83a952a. ` M vue-summercmsio-app` (the submodule pointer) is still unstaged and uncommitted for Task 3. The pre-existing untracked `scripts/__pycache__/` was left alone. In summercms.go only `CLAUDE.md` was staged; the other sessions' `.planning` edits are untouched.
### What was built
- **`scripts/changelog-post.py`** (stdlib only):
- An `if __name__ == "__main__"` guard replaces the bare `main()` call.
- Constants: `CATEGORIES` maps a slug to (name, index path): `framework` → ("Framework", "/changelog") and `plugins` → ("Plugins", "/changelog/plugins"). `FRAMEWORK_SLUG` is `^summercms-\d` and `PLUGIN_SLUG` is `^sm-[a-z0-9-]*-plugin-\d`, the same rule as streams.ts (a comment says so). `PER_PAGE = 30`.
- `ADMIN_API` comes from `SUMMERCMS_ADMIN_API`, or else from `admin_api_base(API)`, which keeps the Journal API's origin and sets the path to `/backend/api/v1`.
- `call(method, path, token, body=None, base=None)` retries a 429 up to 3 times. It waits for Retry-After (default 5 s, capped at 60). A `URLError` now exits with a clear message instead of a traceback.
- Pure helpers: `classify`, `flatten_categories`, `category_ids`, `merge_categories`, `post_category_ids` (accepts `{id,…}` objects or bare ids) and `plan_backfill(posts, ids, publish_drafts)`, which returns `(updates, in_place, unclassified)`. In `ids`, a missing category maps to None; a dry run uses that.
- New I/O helpers: `require_token`, `fetch_category_ids`, `list_all_posts` (pages through `GET /posts?per_page=30&page=N` until `meta.last_page`) and `describe`.
- **publish:**
- The refusal of a non-framework `--repo` without `--draft` is gone.
- The category comes from `GET /categories?include_empty=1`. If it is missing, publish exits with "category <slug> does not exist; run `scripts/changelog-post.py backfill` once (it creates it)".
- The body carries `categories: [id]`. With `--update`, the new category is merged into the existing post's categories.
- Output: the post URL, then "Listed on /changelog[/plugins]." For a draft it prints "Unpublished: … (category X, listed on Y once published)."
- The help texts and the module docstring are updated.
- **backfill** (`--dry-run` and `--yes` are mutually exclusive; `--publish-drafts` is optional):
- It first prints "changelog-post: backfill against <API>". Without either flag it refuses: "backfill writes to <API>; preview with --dry-run, apply with --yes".
- Missing categories are created with `POST {ADMIN_API}/golem15/journal/categories` and the body `{"name","slug"}`. Plain strings work for these mltext fields, because cabana `liftMLValues` takes a non-map value as the host scalar; no schema adaptation was needed. The categories are then read again. If one is still missing, the script fails and names the admin path, the SSH tunnel and the manual admin alternative.
- One `PUT /posts/{id}` is sent per update. Summary line: `backfill: N updated, M already in place, K categories created, U unclassified (left as they are)`. The dry run prints `backfill dry run: N would be updated, … Nothing was written.`
- **scripts/smoke.sh:**
- `python3` is added to the tool check.
- `smoke-9-9-9` is renamed to `summercms-9-9-9` throughout: the post, the entry checks and the purge body.
- New `expect_not_contains` helper.
- The header comment now covers the streams and the backfill.
- The new final section, "Changelog streams and backfill (D3)":
1. Creates `sm-acme-plugin-9-9-9` (published) and `sm-acme-plugin-9-9-8` (a draft with a published_at).
2. Backfill without a flag exits 1 with the refusal text.
3. `--dry-run` shows the target, both would-create lines and "3 would be updated", and no `framework` category exists afterwards.
4. `--yes --publish-drafts` prints "backfill: 3 updated, 0 already in place, 2 categories created", plus "sm-acme-plugin-9-9-8: +category plugins, publish".
5. A second `--yes` prints "backfill: 0 updated, 3 already in place, 0 categories created".
6. No backfill output contains the token.
7. `/categories` lists both slugs.
8. `purge` returns 200.
9. `/changelog` contains `/changelog/summercms-9-9-9` and no `sm-acme-plugin`.
10. `/changelog/plugins` has the plugins title and both plugin posts, without summercms-9-9-9, and leaks no purge key.
11. `/changelog/plugins/rss.xml` returns 200 as application/rss+xml with the plugin item and `<link>https://summercms.io/changelog/plugins</link>`, no framework post and no loopback origin.
12. `/changelog/rss.xml` has the framework item and no plugin posts.
13. `/changelog/sm-acme-plugin-9-9-9` has `href="/changelog/plugins" class="changelog-back"` and "All plugin changes".
- Every script run uses `env -i PATH HOME=$TMP SUMMERCMS_JOURNAL_API=$API SUMMERCMS_JOURNAL_TOKEN=$TOKEN python3 -B`.
- **Docs:**
- sm-summercmsio-app `CLAUDE.md`:
- Commands: the plugin example without `--draft`, plus a `backfill` line and a smoke line.
- Cross-repo contracts: `/changelog/plugins` and its feed; a new bullet on the category slugs, the plugin slug rule and the reserved `plugins` slug as contracts.
- "Release posts" is rewritten. It says to run backfill against production only when the user asks.
- `README.md`: the changelog sentence, the smoke line and a new "Changelog posts" command section.
- `DEPLOY.md`:
- "Release" gains a once-only rollout-order paragraph: backfill before the streams release, and the binary must be rebuilt for the landing header.
- "Verify after cutover" gains curl checks for `/changelog/plugins` and its feed.
- "Posting" now has the framework category, published plugin posts, and a new "Categories and backfill" step. The step covers the tunnel `ssh -N -L 18095:127.0.0.1:8095 rome` with `SUMMERCMS_JOURNAL_API=http://127.0.0.1:18095/_journal/api/v1`, `--dry-run` then `--yes`, and the manual admin alternative.
- A `--publish-drafts` note: it is opt-in and does nothing unless passed. Journal ids 41–44 were left unpublished on purpose, so the user decides.
- Manual step 3 notes that `"categories":[<id>]` is needed.
- summercms.go `CLAUDE.md`: one sentence on plugin release posts going to /changelog/plugins with `--repo ../sm-<name>-plugin`.
### Verification
- `python3 -B scripts/changelog-post.py backfill --help` and `publish --help` show the new subcommand and flags. They ran with `env -i` and a scratch HOME.
- A refusal check ran with no flags against `http://127.0.0.1:1`. It exits 1 with the refusal text and makes no request.
- `scripts/build.sh dev`: OK. The changelog fixture SSR check passed (`smoke-ssr: ok (76 checks)`), as did the plugin tests, and `bin/summercms-io` was built.
- `scripts/smoke.sh`: `smoke: ok` on Docker, first run, with the whole new streams and backfill section. Docker was reachable directly; `sudo -u golem` was not needed.
- summercms.go `go test ./cmd/summer -run TestDocsTree`: ok.
- `git diff --stat 2ef10bc..HEAD -- plugins deploy` is empty. vue-journal-layer, sm-journal-plugin and deploy/ are untouched.
- Nothing was sent to https://summercms.io, and nothing was pushed, tagged or deployed. The production token file was never read.
### Deviations
1. **[Rule 2 - correctness] Slug-shape guard in publish.** publish now exits when the generated slug's `classify()` does not match the repo's stream, for example a plugin origin not named `sm-*-plugin` or a tag without a digit-leading version. The changelog files a post page under a stream by slug alone, so such a post would show the wrong back link and menu state.
2. **[Rule 2 - robustness] `URLError` handling and a Retry-After cap.** A connection failure exits with `changelog-post: <METHOD> <url>: <reason>` instead of a traceback. Retry-After waits are capped at 60 s.
3. **`post_category_ids` helper** (not in the plan list). It accepts category objects or bare ids, so `plan_backfill` can run over its own output in Task 3's idempotency test.
4. **Smoke extras beyond the plan list:** the no-flag refusal check, the check that the dry run creates nothing, the token-in-output check (T-261028-wqu-02), and the plugins feed status, Content-Type and channel link checks.
5. **DEPLOY.md extras:** the verify-after-cutover curls for the plugins stream, and the categories note in manual step 3.
6. **App CLAUDE.md wording:** the session rule says to run `backfill` against production "only when the user asks". It does not forbid `publish`, which the framework CLAUDE.md release workflow runs.
### Notes for Task 3
- Load the hyphenated script with `importlib.util.spec_from_file_location`. The `__main__` guard is in place. The module computes `API`, `ADMIN_API`, `TOKEN_FILE` and `FRAMEWORK` at import time from the env, so set `SUMMERCMS_JOURNAL_API` (loopback placeholder) and `HOME` (temp dir) before `exec_module`. Stub `module.call` to raise.
- Functions to test:
- `classify`: plugins is checked first, then framework, else None.
- `admin_api_base`
- `flatten_categories` / `category_ids`: these read `children`.
- `merge_categories`
- `post_category_ids`
- `plan_backfill`: returns `(updates, in_place, unclassified)`. Each update is a `(id, slug, payload)` tuple, and `in_place` is a list of slugs.
- In `plan_backfill`, a `None` target (category not created yet, a dry run) always produces a `categories` payload.
- For the "plan over its own output yields no updates" case, apply each payload's `categories` (a list of ids) and `published` to the post dicts, then plan again. `post_category_ids` accepts bare ids.
- A plugin draft without `published_at` is never published, even with `publish_drafts`. A framework draft is never published by backfill.
- Run Python with `-B` (the untracked `scripts/__pycache__/` is not ours).
- The submodule pointer bump is still pending: ` M vue-summercmsio-app` in sm-summercmsio-app.
## Task 3: unit tests, then the submodule pointer bump
### Commits
| Repo | Hash | Message |
|------|------|---------|
| vue-summercmsio-app | f1cd587 | test(changelog): streams, feed filter and per-stream feed paths |
| sm-summercmsio-app | 94c1366 | test(scripts): changelog-post helpers |
| sm-summercmsio-app | 29dd1b3 | chore: bump vue-summercmsio-app for the changelog streams (gitlink 072c2f8 -> f1cd587, the last commit) |
Each commit was staged by explicit path, and none has a co-author tag. The untracked `scripts/__pycache__/` was left alone; it still holds only the old `changelog-post.cpython-314.pyc`, because every Python run used `-B`.
### What was built
- **`vue-summercmsio-app/tests/changelog.test.ts`**: 15 new node:test cases. The 6 existing tests are unchanged. The new cases cover:
- `CHANGELOG_STREAMS`: values and freezing.
- `PLUGIN_POST_SLUG`: its source, plus slugs that match and slugs that don't.
- `streamForPath`:
- plugins: the plugins index with a trailing slash, query, hash or feed path, and plugin post slugs including prereleases.
- framework: `/changelog`, `?page=2`, `summercms-0-5-0`, `plugin-notes`, `/changelog/pluginsx`, a nested plugin-looking slug, `/` and the empty path.
- `filterFeedItems`:
- It keeps only the matching items, and the channel head and tail stay byte-identical.
- An item whose CDATA holds `</item></channel>` is kept whole.
- A `<category>` quoted inside CDATA does not count as a match.
- Names are compared after XML-unescaping (`A &amp; B`, numeric entities); CDATA category content is taken raw.
- An item with several categories is kept when one matches.
- A null or unknown name removes every item.
- It composes with `rewriteChangelogFeed`.
- `findCategoryName`: finds nested children; an unknown slug, a name used as a slug, an empty tree, null and undefined all give null.
- `rewriteChangelogFeed`: with plugin stream paths, the channel link and atom self follow the stream, and item links stay `/changelog/<slug>`. A partial paths object falls back to the defaults.
- **`sm-summercmsio-app/scripts/test_changelog_post.py`** (stdlib unittest, 28 tests):
- Loading:
- The script is loaded through `importlib.util.spec_from_file_location`, with `SUMMERCMS_JOURNAL_API=http://127.0.0.1:9/_journal/api/v1` and a `tempfile.mkdtemp` HOME. `SUMMERCMS_ADMIN_API` and `SUMMERCMS_JOURNAL_TOKEN` are cleared during the load.
- The environment is restored afterwards, and `tearDownModule` removes the temp HOME.
- `setUp` replaces `call` with a function that raises.
- A load test asserts that `API`/`ADMIN_API` are the placeholder and that `TOKEN_FILE` is under the temp HOME and doesn't exist.
- Helpers:
- `classify`, including None for `smoke-9-9-9`, `hello`, `""`, `None`, `plugins` and versionless slugs.
- `admin_api_base`: production, loopback with a port, and a URL with a query and fragment.
- `flatten_categories` (parents first) and `category_ids`.
- `merge_categories`: keeps order, no duplicates, does not mutate its input.
- `post_category_ids`: objects, bare ids, None and missing.
- `plan_backfill`:
- An uncategorised framework post gets `[fw]`; a post with category 7 gets `[7, fw]`.
- A plugin post gets `[pl]`; posts already categorised are in place.
- A plugin draft with a published_at is published only with `publish_drafts`; an uncategorised plugin draft gets both changes.
- A plugin draft without a published_at (None or "") is not published. A framework draft only gets its category and is never published.
- Unclassified posts are listed but not updated.
- A None or missing target always yields a categories payload.
- Posts with bare-id categories are handled.
- Idempotency, both with and without `publish_drafts`: the payloads are applied, the plan runs again, there are 0 updates, and the published set is as expected.
- **Contract tests:** `PLUGIN_SLUG.pattern` must equal the regex literal of `PLUGIN_POST_SLUG` in `streams.ts`. Each `CATEGORIES` slug/index must appear in `CHANGELOG_STREAMS`. They skip if the submodule is not checked out. They live in the Python test so the submodule's own test suite stays self-contained.
### Verification
- `cd vue-summercmsio-app && pnpm generate`: OK, link checker 0 errors, 8 routes prerendered.
- `pnpm test`: 53/53 pass (21 in changelog.test.ts).
- `python3 -B scripts/test_changelog_post.py`: 28 tests OK.
- `python3 -B -m unittest discover -s scripts -p 'test_*.py' -v`: 28 tests OK.
- `go vet ./... && go test ./...` in sm-summercmsio-app: OK (`ok git.golem15.com/golem15/sm-summercmsio-app`).
- `git -C vue-summercmsio-app status --short` was empty before the bump. After it, sm-summercmsio-app shows only `?? scripts/__pycache__/`.
- `git diff --stat 2ef10bc..HEAD -- plugins deploy` is empty.
- No socket was opened by the tests, nothing was sent to summercms.io, and the production token file was never read. Nothing was pushed, tagged or deployed.
- `pnpm build:changelog`/`test:changelog` and `scripts/smoke.sh` were not re-run in Task 3; no app code changed after Task 2's runs.
### Deviations
1. **Contract tests added** (not in the behavior list, suggested by Task 2): they pin `PLUGIN_SLUG` and the category slugs/paths against `streams.ts`.
2. **Extra edge cases** beyond the plan list: frozen streams, a nested plugin-looking path, an unknown filter name, a CDATA category name, and `merge_categories` not mutating its input.
No test exposed a bug in the Task 1 or Task 2 code, so no `fix:` commit was needed.
## Follow-ups for the user
- **Cross-stream previous/next:** a post's previous/next links can cross streams, because the Journal API computes siblings over all published posts. This is captured as a todo (g15office `csdgkgez`). Fixing it needs category-scoped siblings in sm-journal-plugin or an app-owned post page.
- **Feed size:** each feed is the latest N posts (the Journal RSS setting, default 20) filtered to its stream, so the plugins feed may be short.
- **Production rollout order** (DEPLOY.md, "Release" and "Categories and backfill"):
1. Create the categories and file the existing posts: `backfill --dry-run`, then `--yes`, over an SSH tunnel to the binary, because nginx refuses `/backend`.
2. Then deploy the new changelog build. Until the backfill runs, the 43 uncategorised production posts appear on neither list.
3. Rebuild and deploy the binary, so the landing page's embedded header gets the menu.
- **`--publish-drafts`:** whether to publish the plugin drafts (Journal ids 41–44) is the user's decision. The flag is opt-in.
- **Memory note:** the user memory note "Changelog: framework tags only" is superseded by D2. Plugin posts are now published on `/changelog/plugins`.
- The submodule and app commits are local only. Pushing (vue-summercmsio-app first, then sm-summercmsio-app, e.g. with `ssu`) is the user's call.
## Known Stubs
None.
## Self-Check: PASSED
- Files exist: streams.ts, useChangelogStream.ts, ChangelogIndex.vue, pages/changelog/{index,plugins}.vue, changelogFeed.ts, routes/changelog/plugins/rss.xml.ts, fixtures/summercms/{posts,categories}.json and rss.xml.
- Commits exist in vue-summercmsio-app: ef1c2bc, dd14075 and 78361b7.
- Task 2: commits exist in sm-summercmsio-app (e7ae6a9, 79edea0, 59d1b0c) and summercms.go (bac8e7e); scripts/changelog-post.py and scripts/smoke.sh carry the new code.
- Task 3: vue-summercmsio-app/tests/changelog.test.ts and scripts/test_changelog_post.py exist; commits f1cd587 (vue-summercmsio-app), 94c1366 and 29dd1b3 (sm-summercmsio-app) exist; the gitlink at HEAD is f1cd587.