diff --git a/.planning/phases/33-seo-output-audit-and-content/33-02-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-02-PLAN.md index d9eebc23..2b18ae84 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-02-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-02-PLAN.md @@ -22,6 +22,8 @@ files_modified: - ../sm-journal-plugin/classes/menu_item_types.go - ../sm-journal-plugin/menu_item_types.go - ../sm-journal-plugin/controllers/api/posts.go + - ../sm-journal-plugin/controllers/api/posts_helpers.go + - ../sm-journal-plugin/controllers/api/categories_test.go - ../sm-journal-plugin/controllers/api/settings.go - ../sm-journal-plugin/controllers/api/maintenance.go - ../sm-journal-plugin/routes.go @@ -57,15 +59,15 @@ estimate: must_haves: truths: - "Journal has a per-site setting content_h1_to_h2 (Settings › Journal, new tab Headings and links, switch default on) with the config key golem15.journal.content_h1_to_h2 as the value when no settings row exists (2026-10-28 Journal H1 setting, amends D-50)" - - "While the setting is on, every H1 saved in post content becomes an H2 on every write path: admin save, the /_journal/api/v1 write API (and so MCP), the markdown path and the import command; the admin save answers the plural notice journal:editor.h1_converted on the editor field and the API answer carries the same phrase in its notices (D-50, D-96, UI-SPEC C27)" - - "While the setting is off, content H1 is stored as written, the editor offers H1 to H4, no conversion notice appears, and journal:fix-headings refuses with exit code 1 and the copy that names the setting (2026-10-28 Journal H1 setting, UI-SPEC partial: H1 setting off)" - - "Switching the setting on or off never rewrites stored content; only journal:fix-headings --apply (dry run by default) and the MCP tool journal_fix_headings with dry_run false convert stored H1 headings (D-50, UI-SPEC Destructive actions)" - - "GET /_journal/api/v1/posts/{slug} carries content_h1_to_h2 (boolean, additive key) so the journal layer reads the setting from the Journal API, not from a layer option (2026-10-28 Journal H1 setting)" + - "While the setting is on, every H1 saved in Journal rich text becomes an H2 on every Journal rich-text write path, post content and category pillar text alike: admin saves of posts and categories (the mlblogeditor and blogeditor field types), the /_journal/api/v1 write API (and so MCP), the markdown path and the import command (categories have no API or import write path today, so their pillar text is written through the admin form only); a post save answers the plural notice journal:editor.h1_converted and a category save journal:editor.h1_converted_category on the editor field, and the API answer carries the post phrase in its notices (D-50, D-96, UI-SPEC C27, CONTEXT 2026-10-28 H1 conversion scope)" + - "While the setting is off, H1 in post content and category pillar text is stored as written, the editor offers H1 to H4, no conversion notice appears, and journal:fix-headings refuses with exit code 1 and the copy that names the setting (2026-10-28 Journal H1 setting, UI-SPEC partial: H1 setting off)" + - "Switching the setting on or off never rewrites stored content; only journal:fix-headings --apply (dry run by default) and the MCP tool journal_fix_headings with dry_run false convert stored H1 headings, in post content (default and translated) and in category pillar text, and report both counts (D-50, UI-SPEC Destructive actions, CONTEXT 2026-10-28 H1 conversion scope)" + - "GET /_journal/api/v1/posts/{slug} and every category row of GET /_journal/api/v1/categories carry content_h1_to_h2 (boolean, additive key) so the journal layer reads the setting from the Journal API for post bodies and category pillar text, not from a layer option (2026-10-28 Journal H1 setting, H1 conversion scope)" - "The Blog Editor receives headingLevels and genericAnchors per request through RequestOptions: H2 to H4 with a pasted H1 parsed as H2 while the setting is on, H1 to H4 while it is off (D-51, UI-SPEC C28 J1)" - "The editor's outline panel (J2) lists headings in document order and flags skipped levels, empty headings, link-only headings, an H1 in Source mode while the setting is on, and links whose whole text is on the generic list; it recomputes 300 ms after a change, starts collapsed, never takes focus on load and never edits content (D-51, D-81)" - "The link form (J3) offers nofollow, sponsored and ugc checkboxes only for absolute http(s) URLs, writes rel with the checked values, never puts rel on internal links, and warns without blocking when the link text is on the generic list; the stored-HTML policy keeps rel with only those three tokens (D-83, D-81, D-80)" - "The image form (J4) sets a slugged file name before upload (lowercase, Polish diacritics transliterated, non-alphanumeric runs to one hyphen, at most 80 characters, empty becomes image), requires alt unless Decorative image is checked, and inserts alt=\"\" for a decorative image; the server applies the same slug rule to uploaded file names (D-85)" - - "Journal's descriptors fill the 33-01 fields: posts give PublishedAt, Parents (their categories in order), Authors (with a journal-author reference when author pages are on), Images (featured image plus body images with alt and decorative), Videos (YouTube embeds), Headings and TopHeading (2 while the setting is on, 0 while off), Kind article and Tools {edit: journal_update_post, headings: journal_fix_headings, image_alt: journal_set_image_alt}; categories give Parents (their parent) and Kind collection; tags give Kind collection (D-93, D-43, D-96)" + - "Journal's descriptors fill the 33-01 fields: posts give PublishedAt, Parents (their categories in order), Authors (with a journal-author reference when author pages are on), Images (featured image plus body images with alt and decorative), Videos (YouTube embeds), Headings and TopHeading (2 while the setting is on, 0 while off), Kind article and Tools {edit: journal_update_post, headings: journal_fix_headings, image_alt: journal_set_image_alt}; categories give Parents (their parent), Kind collection, Headings from their pillar text, TopHeading (2 while the setting is on, 0 while off) and Tools {headings: journal_fix_headings}; tags give Kind collection (D-93, D-43, D-96, CONTEXT 2026-10-28 H1 conversion scope)" - "A new journal-author menu item type describes author pages (Kind profile, Records of authors with at least one published post, exact Match on the author pattern, host key golem15.journal.author_pages default true), so author pages get the SEO tab, ProfilePage data and a sitemap group (D-43, D-34)" - "MCP has journal_get_settings, journal_update_settings, journal_fix_headings (dry_run) and journal_set_image_alt (dry_run, bulk), each bound to a /_journal/api/v1 route with golem15.journal.manage_settings or golem15.journal.access_posts (D-10, D-85, UI-SPEC MCP parity)" - "The admin save notice, the outline counts and every plural phrase use CLDR maps (en one/other, pl one/few/many/other) (UI-SPEC zero-one-many)" @@ -73,7 +75,7 @@ must_haves: - "J4 keeps the existing uploading status line until the upload result arrives, then inserts the image (UI-SPEC loading: J4)" - {statement: "J4 slug rule: \"Lwia Grzywa – w lesie!.JPG\" becomes lwia-grzywa-w-lesie with the extension .jpg, an empty name becomes image, and names are cut at 80 characters (UI-SPEC J4 backstop)", verification: backstop} - "Planner assumption (UI-SPEC unresolved, J2 long text): outline items with long heading text wrap inside the panel with a hanging indent under the level tag and are never truncated; verify in the editor at 360 px" - - "Planner assumption: the H1 setting covers post content only (the setting's label says post content); category pillar content is not converted, and a second H1 there is reported by the crawler as h1_multiple" + - "The setting's label, comment and console output name category content as well as post content, with the P33-54 wording that adapts UI-SPEC C27 to the wider scope (CONTEXT 2026-10-28 H1 conversion scope)" prohibitions: - requirement_id: "D-50" category: values @@ -126,7 +128,7 @@ This plan makes Journal, the first content plugin, supply what the rest of the p Implement in `../sm-journal-plugin` (branch main, not pushed): -- the Journal H1 setting (2026-10-28, amends D-50/D-51): column, config key, settings tab, API flag, conversion with notices on every write path, `journal:fix-headings`; +- the Journal H1 setting (2026-10-28, amends D-50/D-51): column, config key, settings tab, API flag, conversion with notices on every Journal rich-text write path for post content and category pillar text, `journal:fix-headings` over both (CONTEXT 2026-10-28 H1 conversion scope); - Blog Editor J1–J4 (UI-SPEC C28) on RequestOptions (33-01 P33-02), with the stored-HTML policy keeping `rel` (D-83) and server-side slugged media names (D-85); - descriptor facts for posts, categories and tags plus a `journal-author` type (D-93, D-43); - MCP tools `journal_get_settings`, `journal_update_settings`, `journal_fix_headings`, `journal_set_image_alt` (D-10, D-85). @@ -140,6 +142,7 @@ Output: commits in ../sm-journal-plugin (main, not pushed; 33-14 tags). The fram - **P33-05 (bulk alt tool lives in Journal):** the UI-SPEC's `[ASSUMED]` `seo_set_image_alt` becomes `journal_set_image_alt`: alt text is post content, which only Journal may write (D-96); the SEO issue report points at it through `ContentDescriptor.Tools["image_alt"]` (33-10). Research had the tool in 33-11; it moves here with Journal's other content fixes. - **P33-06 (featured image alt):** the featured image alt in descriptors and in the API stays as today (UI-SPEC Q-6): the attachment's title when set, otherwise the post title. - **P33-07 (author type URL):** `journal-author` default pattern `/blog/author/:author` (the journal layer's default route), host override through `menu_items.patterns`, `:author` is the backend user's author_slug. +- **P33-54 (H1 scope wording, CONTEXT 2026-10-28 H1 conversion scope):** Jakub's decision widens the setting from post content to every Journal rich-text field, post content and category pillar text alike, so the C27 copy that names only posts is adapted; every other C27 string stays verbatim. Label `journal:journal.h1_to_h2_label`: EN "Convert H1 to H2 in post and category content", PL "Zamieniaj H1 na H2 w treści wpisów i kategorii". Comment `journal:journal.h1_to_h2_comment`: the first sentence becomes EN "Turn it off only when the page template does not show the post title or the category name as the H1." and PL "Wyłącz tylko wtedy, gdy szablon strony nie wyświetla tytułu wpisu ani nazwy kategorii jako H1."; the rest of C27's comment is unchanged. New notice key `journal:editor.h1_converted_category` for category saves, worded like `editor.h1_converted` with "the category name" in place of "the post title" (EN one/other ":count H1 heading was converted to H2, because the page shows the category name as the H1." / ":count H1 headings were converted to H2, because the page shows the category name as the H1."; PL one/few/many/other "Zamieniono :count nagłówek H1 na H2, bo strona wyświetla nazwę kategorii jako H1." / "…nagłówki…" / "…nagłówków…" / "…nagłówka…"). Console (English only): dry run "Would convert :count H1 headings in :posts posts and :categories categories. Run with --apply to save.", applied "Converted :count H1 headings in :posts posts and :categories categories.", nothing to do "No H1 headings found in post or category content."; the refusal line stays as C27 words it. @@ -190,7 +193,7 @@ Copy: UI-SPEC "Journal settings, save notice and console (C27)" and "Blog Editor - .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C27 and its copy) - Per the 2026-10-28 Journal H1 setting and P33-04: migration `202610280001_add_golem15_journal_settings_headings` adds `content_h1_to_h2 boolean NOT NULL DEFAULT true` and `generic_anchors text NULL` to golem15_journal_settings (Rollback drops both), registered in `updates/registry.go` style; model fields ContentH1ToH2 bool and GenericAnchors *string added to Fillable and Rules (boolean; nullable|string|max:5000). fields.yaml gets the tab `golem15.journal::lang.journal.tab_headings` after General with `content_h1_to_h2` (switch, span left, default true, label h1_to_h2_label, comment h1_to_h2_comment) and `generic_anchors` (textarea size small, span full, default = the en and pl seed lists of C27 joined by newlines). New `config/config.yaml` with the commented key `content_h1_to_h2: true`; plugin.go embeds it, implements `ConfigFS()` and asserts `pact.HasConfig`. `classes/journal_settings.go`: `EffectiveH1ToH2(ctx, db, cfg) bool` and `GenericAnchors(ctx, db) []string` (row value, else the seed lists). `classes/headings.go`: `ConvertH1(html string) (string, int)` rewriting `

`/`

` start and end tags to h2 with the x/net/html tokenizer, keeping attributes and every other byte. decodeBlogHTML calls it when EffectiveH1ToH2 is true and appends the notice `golem15.journal::lang.editor.h1_converted` with Count n (FormFieldNotice). Show adds `content_h1_to_h2` to the post payload (additive key; no existing key changes). Lang keys of C27 in en and pl (CLDR plurals). `TestHeadingConversionTracer` in headings_test.go: admin save of a post whose content holds `

A

x

` stores `

A

x

` and the save response meta.notices has the converted phrase with count 1; GET /_journal/api/v1/posts/{slug} answers content_h1_to_h2 true; with the setting switched off the same save stores the H1 unchanged, adds no notice, and Show answers false. Commit as listed. + Per the 2026-10-28 Journal H1 setting and P33-04: migration `202610280001_add_golem15_journal_settings_headings` adds `content_h1_to_h2 boolean NOT NULL DEFAULT true` and `generic_anchors text NULL` to golem15_journal_settings (Rollback drops both), registered in `updates/registry.go` style; model fields ContentH1ToH2 bool and GenericAnchors *string added to Fillable and Rules (boolean; nullable|string|max:5000). fields.yaml gets the tab `golem15.journal::lang.journal.tab_headings` after General with `content_h1_to_h2` (switch, span left, default true, label h1_to_h2_label, comment h1_to_h2_comment) and `generic_anchors` (textarea size small, span full, default = the en and pl seed lists of C27 joined by newlines). New `config/config.yaml` with the commented key `content_h1_to_h2: true`; plugin.go embeds it, implements `ConfigFS()` and asserts `pact.HasConfig`. `classes/journal_settings.go`: `EffectiveH1ToH2(ctx, db, cfg) bool` and `GenericAnchors(ctx, db) []string` (row value, else the seed lists). `classes/headings.go`: `ConvertH1(html string) (string, int)` rewriting `

`/`

` start and end tags to h2 with the x/net/html tokenizer, keeping attributes and every other byte. decodeBlogHTML calls it when EffectiveH1ToH2 is true and appends the notice `golem15.journal::lang.editor.h1_converted` with Count n (FormFieldNotice). Show adds `content_h1_to_h2` to the post payload (additive key; no existing key changes). Lang keys of C27 in en and pl with the P33-54 wording for the label and comment (CLDR plurals). `TestHeadingConversionTracer` in headings_test.go: admin save of a post whose content holds `

A

x

` stores `

A

x

` and the save response meta.notices has the converted phrase with count 1; GET /_journal/api/v1/posts/{slug} answers content_h1_to_h2 true; with the setting switched off the same save stores the H1 unchanged, adds no notice, and Show answers false. Commit as listed.
go -C ../sm-journal-plugin test ./... -run 'TestHeadingConversionTracer' -count=1 -v 2>&1 | tail -20 @@ -208,28 +211,31 @@ Copy: UI-SPEC "Journal settings, save notice and console (C27)" and "Blog Editor - Task 2: Conversion on every other write path, journal:fix-headings, the settings API and the MCP tools (settings, fix headings, set image alt) - ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/settings.go, ../sm-journal-plugin/controllers/api/maintenance.go, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/image_alt.go, ../sm-journal-plugin/console/fix_headings.go, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/mcp.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/headings_test.go + Task 2: Conversion on every other write path (post content and category pillar text), journal:fix-headings over both, the settings API and the MCP tools (settings, fix headings, set image alt) + ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_helpers.go, ../sm-journal-plugin/controllers/api/categories_test.go, ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/controllers/api/settings.go, ../sm-journal-plugin/controllers/api/maintenance.go, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/image_alt.go, ../sm-journal-plugin/console/fix_headings.go, ../sm-journal-plugin/console/import_posts.go, ../sm-journal-plugin/routes.go, ../sm-journal-plugin/mcp.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/headings_test.go - ../sm-journal-plugin/controllers/api/posts.go (Store, Update, the transaction and RecordSaved emission from 62c75b1/7a03640) - ../sm-journal-plugin/controllers/api/record_events.go and auth.go (backend-audience JWT, permissions) - ../sm-journal-plugin/console/export_posts.go:24-60 and console/import_posts.go - ../sm-journal-plugin/mcp.go and mcp_test.go (tool binding pattern) - ../sm-journal-plugin/journal_api_writes_test.go (API write harness) - - .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C27 console copy, MCP parity row) + - ../sm-journal-plugin/controllers/api/posts_helpers.go (category serializer, post_count) and controllers/api/categories_test.go (exact key lists), models/category.go (Content is the pillar text), categories_admin_editor_test.go (category admin save harness) + - .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C27 console copy, MCP parity row) and P33-54 above (the wording that replaces C27's post-only strings) - API Store and Update of content `

T

` store `

T

` and the JSON answer gains `notices: [{message, count}]` with the h1_converted phrase; translated content in a locale converts the same way; with the setting off nothing converts and `notices` is absent - Markdown content (FormatHTML path) with `# T` stores an h2 while on - `journal:import-posts` converts while on and prints the converted count - - `journal:fix-headings` (dry run default) prints "Would convert :count H1 headings in :posts posts. Run with --apply to save." and changes nothing; `--apply` converts default and translated content, fires RecordSaved per changed post, prints "Converted :count H1 headings in :posts posts."; nothing to do prints "No H1 headings found in post content."; with the setting off it exits 1 with the refusal copy + - An admin save of a category whose pillar text holds an H1 stores an H2 while on, with the notice journal:editor.h1_converted_category on the content field; while off it stores the H1 unchanged without a notice + - GET /_journal/api/v1/categories rows carry content_h1_to_h2 matching the setting (categories_test.go's key lists gain it; every other key unchanged) + - `journal:fix-headings` (dry run default) prints "Would convert :count H1 headings in :posts posts and :categories categories. Run with --apply to save." and changes nothing; `--apply` converts default and translated post content and category pillar text, fires RecordSaved per changed post and category, prints "Converted :count H1 headings in :posts posts and :categories categories."; nothing to do prints "No H1 headings found in post or category content."; with the setting off it exits 1 with the refusal copy - GET/PUT /_journal/api/v1/settings read and write content_h1_to_h2 and generic_anchors (other settings columns too) with golem15.journal.manage_settings; 403 without it - - POST /_journal/api/v1/fix-headings {dry_run} answers {posts, headings, dry_run} and refuses 409 with the refusal phrase while the setting is off + - POST /_journal/api/v1/fix-headings {dry_run} answers {posts, categories, headings, dry_run} and refuses 409 with the refusal phrase while the setting is off - POST /_journal/api/v1/images/alt {dry_run, changes:[{post_id, locale, src, alt}]} sets alt on the matching img in content_html (default or translated), answers the per-change diff, refuses the whole batch 422 when one src is not in the post, and with dry_run writes nothing - MCP lists journal_get_settings, journal_update_settings, journal_fix_headings and journal_set_image_alt, each bound to its route
- Per D-50, D-96, D-85 and P33-05: call ConvertH1 in the API write paths after sanitizing (default and translated content_html, inside the existing transaction) and return the additive `notices` key; apply it in FormatHTML's caller for markdown content and in console/import_posts.go. `console/fix_headings.go`: bonfire command `journal:fix-headings` modeled on export_posts.go (flags `apply` Bare, `dry-run` implied default; Examples; English output exactly as UI-SPEC), registered in plugin.go Commands(). `controllers/api/settings.go`: ShowSettings/UpdateSettings (validation via the model's Rules). `controllers/api/maintenance.go`: FixHeadings and SetImageAlt handlers running in one transaction (dry_run rolls back), with RecordSaved per changed post through the existing emission helper; `classes/image_alt.go` rewrites the alt attribute of `` whose src equals the change's src with the x/net/html tokenizer (escaping through the tokenizer, never string concatenation). Routes in the write group (throttle journal-api). MCP tools in mcp.go: `get_settings` (GET /settings), `update_settings` (PUT /settings), `fix_headings` (POST /fix-headings), `set_image_alt` (POST /images/alt, destructive true), permissions golem15.journal.manage_settings for the first three and golem15.journal.access_posts for set_image_alt. Tests first in headings_test.go: `TestHeadingConversion` (API, markdown, import, translated), `TestFixHeadingsCommand`, `TestJournalSettingsTools`, `TestSetImageAlt`. Commit groups A, B, C as listed. + Per D-50, D-96, D-85, P33-05 and P33-54 (CONTEXT 2026-10-28 H1 conversion scope: every Journal rich-text write path, post content and category pillar text alike): call ConvertH1 in the API write paths after sanitizing (default and translated content_html, inside the existing transaction) and return the additive `notices` key; apply it in FormatHTML's caller for markdown content and in console/import_posts.go. decodeBlogHTML (already converting since Task 1 for both blogeditor types) picks the notice key by the field's record: `editor.h1_converted_category` for a Category, `editor.h1_converted` otherwise. The categories serializer in posts_helpers.go adds `content_h1_to_h2` (EffectiveH1ToH2) to every category row, additive; categories_test.go's exact key lists gain it. `console/fix_headings.go`: bonfire command `journal:fix-headings` modeled on export_posts.go (flags `apply` Bare, `dry-run` implied default; Examples; English output exactly as P33-54), scanning posts (content_html default and translated) and categories (content), registered in plugin.go Commands(). `controllers/api/settings.go`: ShowSettings/UpdateSettings (validation via the model's Rules). `controllers/api/maintenance.go`: FixHeadings (posts and categories, answer {posts, categories, headings, dry_run}) and SetImageAlt handlers running in one transaction (dry_run rolls back), with RecordSaved per changed post and category through the existing emission helper; `classes/image_alt.go` rewrites the alt attribute of `` whose src equals the change's src with the x/net/html tokenizer (escaping through the tokenizer, never string concatenation). Routes in the write group (throttle journal-api). MCP tools in mcp.go: `get_settings` (GET /settings), `update_settings` (PUT /settings), `fix_headings` (POST /fix-headings), `set_image_alt` (POST /images/alt, destructive true), permissions golem15.journal.manage_settings for the first three and golem15.journal.access_posts for set_image_alt. Tests first in headings_test.go: `TestHeadingConversion` (API, markdown, import, translated, category admin save with its notice), `TestFixHeadingsCommand` (posts and categories), `TestJournalSettingsTools`, `TestSetImageAlt`. Commit groups A, B, C as listed. go -C ../sm-journal-plugin test ./... -run 'TestHeadingConversion|TestFixHeadingsCommand|TestJournalSettingsTools|TestSetImageAlt' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30 @@ -241,9 +247,11 @@ Copy: UI-SPEC "Journal settings, save notice and console (C27)" and "Blog Editor - `grep -n "journal:fix-headings" ../sm-journal-plugin/console/fix_headings.go` prints at least one line and `commands_documented_test.go` passes (command documented) - `grep -n '"fix_headings"\|"set_image_alt"\|"get_settings"\|"update_settings"' ../sm-journal-plugin/mcp.go` prints four lines - the refusal copy "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings." appears in console/fix_headings.go - - the existing Journal API answer keys are unchanged (journal_api_matrix_test.go passes) + - `grep -n ":categories categories" ../sm-journal-plugin/console/fix_headings.go` prints at least one line, and `grep -n "h1_converted_category" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml` prints a line in each + - `grep -n '"content_h1_to_h2"' ../sm-journal-plugin/controllers/api/posts_helpers.go` prints at least one line + - the existing Journal API answer keys are unchanged apart from the additive content_h1_to_h2 (journal_api_matrix_test.go and categories_test.go pass) - Every write path converts while the setting is on, stored content is only rewritten on request, and Journal's settings and content fixes are MCP tools. + Every Journal rich-text write path converts post content and category pillar text while the setting is on, stored content is only rewritten on request (both kinds), and Journal's settings and content fixes are MCP tools.
@@ -261,10 +269,11 @@ Copy: UI-SPEC "Journal settings, save notice and console (C27)" and "Blog Editor - Its locales list Images: the featured image first (alt per P33-06), then body images in document order with alt, Decorative true for alt="" and width/height from attributes; Videos for each figure data-embed youtube; Headings from h1 to h6 with trimmed text - TopHeading is 2 while content_h1_to_h2 is on and 0 while off; Kind article; Tools has edit, headings and image_alt with the journal_ tool names - A child category describes Parents [its parent]; categories and tags have Kind collection + - A category with pillar text describes Headings from it, TopHeading 2 while content_h1_to_h2 is on and 0 while off, and Tools {headings: journal_fix_headings} (CONTEXT 2026-10-28 H1 conversion scope) - journal-author: Records lists only authors with a published post, Describe answers the name as Title and the author URL per locale, Match is exact on the pattern, Kind profile; with golem15.journal.author_pages false Describe answers empty URLs and Match never matches - Per D-93, D-43 and P33-06/P33-07: `classes/content_facts.go` parses content_html once per locale with x/net/html into images (src, alt, decorative, width, height), YouTube videos and headings. Fill the 33-01 fields in describePosts, describeCategories and describeTag (same queries batched per page; no N+1 per record). Add the `journal-author` menu item type next to journal-tag (references and Resolve as the tag type does, Content descriptor with Model = the backend user morph type, Controller "", Kind profile), host key constant `AuthorPagesKey = "golem15.journal.author_pages"` (default true when unset), lang keys for its label. Tests first in journal_descriptor_fields_test.go (`TestJournalDescriptorFields`, `TestJournalAuthorType`). Commit groups A and B. + Per D-93, D-43, P33-06/P33-07 and the 2026-10-28 H1 conversion scope: `classes/content_facts.go` parses content_html once per locale with x/net/html into images (src, alt, decorative, width, height), YouTube videos and headings. Fill the 33-01 fields in describePosts, describeCategories and describeTag (same queries batched per page; no N+1 per record); describeCategories also parses the pillar text (content) for Headings and sets TopHeading from EffectiveH1ToH2 and Tools {headings: journal_fix_headings}, so the SEO plugin's CMS check h1_in_content (33-10) covers category pillar text too. Add the `journal-author` menu item type next to journal-tag (references and Resolve as the tag type does, Content descriptor with Model = the backend user morph type, Controller "", Kind profile), host key constant `AuthorPagesKey = "golem15.journal.author_pages"` (default true when unset), lang keys for its label. Tests first in journal_descriptor_fields_test.go (`TestJournalDescriptorFields`, `TestJournalAuthorType`). Commit groups A and B. go -C ../sm-journal-plugin test ./... -run 'TestJournalDescriptorFields|TestJournalAuthorType|TestRecordSavedDescribesNewURL' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -20 @@ -335,12 +344,12 @@ Copy: UI-SPEC "Journal settings, save notice and console (C27)" and "Blog Editor - `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...` green after every commit. -- Docker suites: TestHeadingConversionTracer, TestHeadingConversion, TestFixHeadingsCommand, TestJournalSettingsTools, TestSetImageAlt, TestJournalDescriptorFields, TestJournalAuthorType. +- Docker suites: TestHeadingConversionTracer, TestHeadingConversion (posts and category pillar text), TestFixHeadingsCommand (posts and categories), TestJournalSettingsTools, TestSetImageAlt, TestJournalDescriptorFields, TestJournalAuthorType. - `npm --prefix ../sm-journal-plugin run test` and `bash ../sm-journal-plugin/scripts/check-dist.sh` pass. -- The H1 setting works in both modes on every write path, and the API exposes it. +- The H1 setting works in both modes on every Journal rich-text write path (post content and category pillar text), and the post and category answers expose it. - The editor additions J1–J4 work as UI-SPEC C28 describes. - Descriptors carry the new facts and author pages are a content type. - Four Journal MCP tools exist. @@ -352,11 +361,11 @@ Copy: UI-SPEC "Journal settings, save notice and console (C27)" and "Blog Editor - Settings columns: `golem15_journal_settings.content_h1_to_h2`, `golem15_journal_settings.generic_anchors`; migration `202610280001_add_golem15_journal_settings_headings`. - Go symbols: `classes.ConvertH1`, `classes.EffectiveH1ToH2`, `classes.GenericAnchors`, `classes.MediaFileName`, `classes.AuthorPagesKey`, API handlers `ShowSettings`, `UpdateSettings`, `FixHeadings`, `SetImageAlt`; `Plugin.ConfigFS`. - Menu item type: `journal-author`. -- Routes: `GET /_journal/api/v1/settings`, `PUT /_journal/api/v1/settings`, `POST /_journal/api/v1/fix-headings`, `POST /_journal/api/v1/images/alt`; additive keys `content_h1_to_h2` (post Show) and `notices` (write answers). -- CLI: `journal:fix-headings [--apply]`. +- Routes: `GET /_journal/api/v1/settings`, `PUT /_journal/api/v1/settings`, `POST /_journal/api/v1/fix-headings` (answer `posts`, `categories`, `headings`, `dry_run`), `POST /_journal/api/v1/images/alt`; additive keys `content_h1_to_h2` (post Show and every category row of `GET /_journal/api/v1/categories`) and `notices` (write answers). +- CLI: `journal:fix-headings [--apply]` (posts and categories). - MCP tools: `journal_get_settings`, `journal_update_settings`, `journal_fix_headings`, `journal_set_image_alt`. - Files: `config/config.yaml`, `classes/headings.go`, `classes/journal_settings.go`, `classes/content_facts.go`, `classes/image_alt.go`, `classes/media_names.go`, `console/fix_headings.go`, `controllers/api/settings.go`, `controllers/api/maintenance.go`, `assets/src/blogeditor/{outline,linkform,imageform}.ts`, `headings_test.go`, `journal_descriptor_fields_test.go`. -- Lang keys: the C27 and C28 keys (`journal.tab_headings`, `journal.h1_to_h2_label`, `journal.h1_to_h2_comment`, `journal.generic_anchors_label`, `journal.generic_anchors_comment`, `editor.h1_converted`, and the element labels). +- Lang keys: the C27 and C28 keys (`journal.tab_headings`, `journal.h1_to_h2_label`, `journal.h1_to_h2_comment`, `journal.generic_anchors_label`, `journal.generic_anchors_comment`, `editor.h1_converted`, and the element labels) with the P33-54 wording, plus `editor.h1_converted_category`. Create `.planning/phases/33-seo-output-audit-and-content/33-02-SUMMARY.md` when done. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-03-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-03-PLAN.md index 8d295e62..c5d325a2 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-03-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-03-PLAN.md @@ -95,7 +95,7 @@ must_haves: - "The settings singleton (one tile, AdminSingleton) has the tabs Files (site_url, file_size, the S1 files partial), Content types (per signpost type include switch, file slug, HTML heading) and Additional addresses (url and lastmod rows); it replaces the Phase 17 definitions screen and the old settings tile (D-32, UI-SPEC C26, Q-4)" - "The PHP-parity output is gone: priority tiers, the definitions screen, the update manifest, the festival BeforeAddItem/AddItem events, the PHP goldens and the XSL stylesheet (/sitemap.xsl answers 404); this removal is what resolves the todo .planning/todos/completed/sitemap-xslt-browser-deprecation.md (g15office w8kupqi3), which Jakub moved to completed on 2026-10-28 (D-32)" - "A migration moves every url item of golem15_sitemap_definitions into the Additional addresses rows and frontend_url into site_url; the definitions table stays in the database unread, so a rollback keeps the data (D-32)" - - "The site URL of every is the setting site_url, else the config key golem15.sitemap.site_url, else app.url; the request's Host header is never used" + - "The site URL of every is the setting site_url, else the config key golem15.sitemap.site_url, else app.url; the request's Host header is never used (CONTEXT 2026-10-28 accepted defaults: sitemap site URL precedence, RESEARCH Open Question 5)" - "Rebuilds run as a conga job: debounced to one queued rebuild per golem15.sitemap.rebuild_delay_seconds (default 60) after record events, on every settings save, nightly through the scheduled command sitemap:rebuild (pact.HasSchedule, 03:00), and by hand with sitemap:rebuild" - "The sitemap plugin publishes contract.Listing (Settings with SiteURL, IndexURL, Groups and the HTML-sitemap and IndexNow fields filled by 33-04; Entries paging the included URLs of a group) for other plugins, without depending on any of them (D-95)" - "The index URL stays /sitemap.xml, so robots.txt Sitemap: lines and Search Console submissions need no change (D-22)" @@ -256,7 +256,7 @@ XML namespaces: sitemap 0.9 http://www.sitemaps.org/schemas/sitemap/0.9, xhtml h - .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Pattern 2, Code Examples: index writer) - Per D-30, D-93 and P33-08 to P33-10: create the contract package with every type of P33-08. Migration 202610280001 creates golem15_sitemap_urls (P33-10); migration 202610280002 adds to golem15_sitemap_settings: site_url text, file_size integer NOT NULL DEFAULT 1000, default_locale text, extra_urls jsonb NOT NULL DEFAULT '[]', rebuild_pending_until timestamptz (the url-item copy is Task 3's). models/url.go and the new Settings fields. `classes/slots.go`: assign file numbers per group (keep existing numbers, append to the last file with room by first_seen then id, holes stay). `classes/rebuild.go`: `Rebuild(ctx, app) (RebuildResult, error)` walks signpost.ContentTypes, pages Records (Limit 500), keeps Published records with a URL per locale, upserts rows (title from ContentLocale.Title, lastmod initialised from the record's LastMod), marks vanished rows excluded "deleted", assigns slots in one transaction, and returns added/changed/removed URL lists (IndexNow in 33-04 uses them). `classes/writer.go`: index and urlset writers with bufio and `xml.EscapeText`, RFC 3339 UTC lastmod, no priority and no change frequency. jobs.go: `RebuildArgs` (kind `golem15.sitemap.rebuild`) with conga.MaxAttempts(3); plugin.go asserts pact.HasJobs. routes.go: replace the old handlers with the index (`GET /sitemap.xml`, absolute on the site URL per the precedence in must_haves) and `GET /_sitemap/files/{file}` (P33-09), both reading the table, content type application/xml, X-Robots-Tag noindex on files, 404 for an unknown or empty file; keep the throttle bucket. `TestSitemapIndex` in sitemap_index_test.go (Docker): a fake content type with 5 published records and 1 draft, file_size 2 → after Rebuild the index lists sitemap-pages-1.xml to -3.xml with lastmod; file 1 lists 2 URLs with absolute and ; the draft is absent; no priority or change frequency element anywhere; an unknown file answers 404. Commit as listed. + Per D-30, D-93 and P33-08 to P33-10: create the contract package with every type of P33-08. Migration 202610280001 creates golem15_sitemap_urls (P33-10); migration 202610280002 adds to golem15_sitemap_settings: site_url text, file_size integer NOT NULL DEFAULT 1000, default_locale text, extra_urls jsonb NOT NULL DEFAULT '[]', rebuild_pending_until timestamptz (the url-item copy is Task 3's). models/url.go and the new Settings fields. `classes/slots.go`: assign file numbers per group (keep existing numbers, append to the last file with room by first_seen then id, holes stay). `classes/rebuild.go`: `Rebuild(ctx, app) (RebuildResult, error)` walks signpost.ContentTypes, pages Records (Limit 500), keeps Published records with a URL per locale, upserts rows (title from ContentLocale.Title, lastmod initialised from the record's LastMod), marks vanished rows excluded "deleted", assigns slots in one transaction, and returns added/changed/removed URL lists (IndexNow in 33-04 uses them). `classes/writer.go`: index and urlset writers with bufio and `xml.EscapeText`, RFC 3339 UTC lastmod, no priority and no change frequency. jobs.go: `RebuildArgs` (kind `golem15.sitemap.rebuild`) with conga.MaxAttempts(3); plugin.go asserts pact.HasJobs. routes.go: replace the old handlers with the index (`GET /sitemap.xml`, absolute on the site URL per the precedence in must_haves, which is Jakub's 2026-10-28 accepted default in CONTEXT: setting, then golem15.sitemap.site_url, then app.url) and `GET /_sitemap/files/{file}` (P33-09), both reading the table, content type application/xml, X-Robots-Tag noindex on files, 404 for an unknown or empty file; keep the throttle bucket. `TestSitemapIndex` in sitemap_index_test.go (Docker): a fake content type with 5 published records and 1 draft, file_size 2 → after Rebuild the index lists sitemap-pages-1.xml to -3.xml with lastmod; file 1 lists 2 URLs with absolute and ; the draft is absent; no priority or change frequency element anywhere; an unknown file answers 404. Commit as listed. go -C ../sm-sitemap-plugin test ./... -run 'TestSitemapIndex' -count=1 -v 2>&1 | tail -20 diff --git a/.planning/phases/33-seo-output-audit-and-content/33-06-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-06-PLAN.md index 403e7a14..10a96e26 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-06-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-06-PLAN.md @@ -48,13 +48,13 @@ files_modified: - ../sm-seo-plugin/lang/pl/lang.yaml - ../sm-seo-plugin/README.md - ../sm-seo-plugin/structured_data_test.go -autonomous: true -requirements: [SC-2, SC-3, D-43, D-44, D-45, D-46, D-03, D-86, D-55, D-56, D-57, D-40, D-41, D-42, D-21, D-94, D-10] +autonomous: false +requirements: [SC-2, SC-3, D-43, D-44, D-45, D-46, D-03, D-86, D-55, D-56, D-57, D-40, D-41, D-42, D-21, D-94, D-10, D-70, D-85] estimate: - tokens: 170000 - raw_tokens: 170000 - tasks: 3 + tokens: 172000 + raw_tokens: 172000 + tasks: 4 confidence: low must_haves: @@ -78,6 +78,7 @@ must_haves: - "P1 shows status.primary_gone when the stored primary category is no longer assigned, and the trail falls back to the first category (UI-SPEC partial: primary category gone)" - "E9's JSON pre has max-height 360px and scrolls in both axes (UI-SPEC overflow: E9)" - {statement: "E8 value round-trip: Service fields come back as the same JSON string; an invalid price shows priceInvalid; a JSON parse error names line and column; more than 16 KB shows jsonTooLarge (UI-SPEC E8 backstop)", verification: backstop} + - "Before github.com/chromedp/chromedp (33-10, the rendered comparison) or github.com/gen2brain/webp (33-11, WebP variants) enters any go.mod, Jakub verifies both modules at one blocking checkpoint (Task 4), and the approved version of each, or its rejection, is recorded at the top of 33-06-SUMMARY.md (RESEARCH Package Legitimacy Audit: both [ASSUMED]; CONTEXT 2026-10-28 rendered comparison and image formats; P33-56)" prohibitions: - requirement_id: "D-44" category: safety @@ -121,7 +122,8 @@ Extend ../sm-seo-plugin (main, not pushed): - the JSON-LD @graph builder per content kind with validators (D-43, D-46) and the publisher and local-business settings (D-45, D-03); - breadcrumbs with the primary category (D-86), alternates/x-default/og:locale (D-55..57), pagination forms and suffixes (D-40..42) in resolve and /site; - SEO tab additions E8 (schema), E9 (graph preview), primary category, P1/P2 rows (D-44, D-46); -- MCP `seo_get_graph` and the new keys on existing tools (D-10). +- MCP `seo_get_graph` and the new keys on existing tools (D-10); +- the blocking legitimacy checkpoint for the phase's two new Go modules, chromedp and gen2brain/webp, before 33-10 and 33-11 add them (P33-56). Purpose: SC-2 (one JSON-LD @graph, hreflang and breadcrumbs from the CMS) on the backend side; the layer renders it (33-07 to 33-09). @@ -133,6 +135,7 @@ Output: commits in ../sm-seo-plugin, with the rebuilt element bundle. - **P33-23 (primary parent field):** extension dropdowns accept only listed options (32-01), so the per-record choice is a plugin field type `seoparent` (element `golem15-seo-parent`, a 44px select styled as the built-in dropdown with emptyOption field.primary_category_default) whose Load answers {selected, options} from the descriptor's Parents; it shows only for types whose records have Parents. The value is stored in the entry's data (key primary_parent) on every locale row, as the social image is (Phase 32 Q-5); seo_schema likewise (key schema). - **P33-24 (%pages%):** the resolver cannot know a listing's page count; it fills %page% and leaves the literal %pages% in seo.title and seo.description on page ≥ 2; the layer's usePagination replaces it (33-08). - **P33-25 (search):** config `golem15.seo.search_url` (for example `/szukaj?q={search_term_string}`), empty means no SearchAction. +- **P33-56 (one legitimacy checkpoint for both Go modules, 2026-10-28 revision):** Jakub chose github.com/chromedp/chromedp (CONTEXT, rendered comparison) and github.com/gen2brain/webp (CONTEXT, image formats); RESEARCH's audit marks both [ASSUMED] because its legitimacy seam cannot check Go modules. Both are verified together here, at the end of the plan 33-10 depends on, instead of inside 33-10 and 33-11, so that neither of those plans exceeds its task budget and the two install tasks run without stopping. The checkpoint installs nothing; each consumer task carries a precondition on this answer, and each decision note (written in 33-10 and 33-11 before their go.mod changes) cites it. @@ -166,7 +169,8 @@ Copy: UI-SPEC C16–C18, C20 (General addition, Pagination, Publisher, Local bus |------|-------------------------|--------------| | 1 | `feat: JSON-LD graph, breadcrumbs and hreflang in the resolve answer (tracer)` | TestGraphTracer passes | | 2 | A `feat: publisher, local business and home crumb settings`; B `feat: pagination forms and suffixes in resolve and site` | settings and pagination tests pass | -| 3 | A `feat: graph validation and custom JSON-LD`; B `feat: structured data, graph preview and primary category in the SEO tab`; C `feat: graph MCP tool and new SEO keys`; D `docs: structured data, breadcrumbs, hreflang and pagination in README` | plan complete | +| 3 | A `feat: graph validation and custom JSON-LD`; B `feat: structured data, graph preview and primary category in the SEO tab`; C `feat: graph MCP tool and new SEO keys`; D `docs: structured data, breadcrumbs, hreflang and pagination in README` | element and Go suites pass | +| 4 | none (checkpoint answers recorded at the top of the SUMMARY) | both answers recorded; plan complete | @@ -269,6 +273,18 @@ Copy: UI-SPEC C16–C18, C20 (General addition, Pagination, Publisher, Local bus Editors choose schema types and custom JSON-LD with live checks, see the effective graph with Google's rules, and MCP can do the same. + + Task 4: Package legitimacy of github.com/chromedp/chromedp and github.com/gen2brain/webp before either enters a go.mod + Nothing is installed. Per P33-56, this checkpoint verifies the two Go modules Jakub chose on 2026-10-28 (CONTEXT: rendered comparison through optional chromedp, used by 33-10; WebP variants through gen2brain/webp, used by 33-11). 33-RESEARCH.md's Package Legitimacy Audit marks both [ASSUMED]: the npm/pypi/crates seam cannot check Go modules, so only proxy.golang.org existence was confirmed (chromedp v0.21.0 of 2026-10-11, 82 versions since v0.1.0 in 2018-07; gen2brain/webp v0.6.4 of 2026-07-01, 31 versions since v0.1.0 in 2024-03). + + 1. chromedp: open https://pkg.go.dev/github.com/chromedp/chromedp and https://github.com/chromedp/chromedp: the module path matches, the repository belongs to the chromedp organisation (which also publishes github.com/chromedp/cdproto), the licence is permissive (MIT), the latest tag is v0.21.0 or newer, and its README says it drives a Chrome or Chromium binary through the DevTools protocol without other runtime dependencies. + 2. gen2brain/webp: open https://pkg.go.dev/github.com/gen2brain/webp and https://github.com/gen2brain/webp: the module path matches, the repository is the author's (gen2brain also maintains gen2brain/avif and go-fitz), the licence is permissive, the latest tag is v0.6.4 or newer, and the README describes libwebp through purego when the system library is present and a WASM build through wazero otherwise, with no cgo. + 3. Check proxy.golang.org lists the versions to pin: https://proxy.golang.org/github.com/chromedp/chromedp/@v/list and https://proxy.golang.org/github.com/gen2brain/webp/@v/list + 4. Answer for each module "approved vX.Y.Z" (the version to pin) or "rejected: reason". + + Reply "chromedp: approved vX.Y.Z | rejected: reason; webp: approved vX.Y.Z | rejected: reason". A rejected chromedp means 33-10 Task 4 stops, its crawls keep reporting the comparison as skipped, and the SUMMARY records the decision for revisiting; a rejected webp means 33-11 Tasks 3 and 4 stop and the SUMMARY records WebP as not delivered. + + @@ -288,7 +304,7 @@ Copy: UI-SPEC C16–C18, C20 (General addition, Pagination, Publisher, Local bus | T-33-26 | Spoofing | breadcrumbs and alternates | low | mitigate | built from signpost descriptors and settings only, never from the request | | T-33-27 | Elevation of privilege | graph tool, settings, SEO tab keys | high | mitigate | golem15.seo.manage on every route and widget action (existing manage wrapper), 403 in TestGraphTool | | T-33-28 | Denial of service | graph building per resolve | low | mitigate | descriptor reads per resolve are already one call; parent chain capped at 6 | -| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | no new package; the element bundle rebuilds from the committed lockfile | +| T-33-SC | Tampering | npm/pip/cargo/Go installs | high | mitigate | this plan installs no package (the element bundle rebuilds from the committed lockfile); Task 4 is the blocking human legitimacy checkpoint for the phase's two [ASSUMED] Go modules, github.com/chromedp/chromedp and github.com/gen2brain/webp, whose approved versions 33-10 and 33-11 pin exactly, each after its decision note | @@ -301,6 +317,7 @@ Copy: UI-SPEC C16–C18, C20 (General addition, Pagination, Publisher, Local bus - The resolve answer carries one @graph, breadcrumbs, alternates and pagination data. - Publisher, local business, pagination and home crumb are settings; the SEO tab has E8, E9 and the primary category. - seo_get_graph and the new keys work through MCP. +- Both new Go modules are verified (or rejected) before any go.mod changes, and the answers are on record. ## Artifacts this phase produces @@ -314,7 +331,8 @@ Copy: UI-SPEC C16–C18, C20 (General addition, Pagination, Publisher, Local bus - Route: `GET /_seo/api/v1/graph`; MCP tool `seo_get_graph`. - Config keys: `golem15.seo.og_locales`, `golem15.seo.search_url`. - Tests: `TestGraphTracer`, `TestGraphPerKind`, `TestCustomJSONLD`, `TestPublisherSettings`, `TestPagination`, `TestGraphTool` (plus `TestAlternates` and `TestBreadcrumbs` subtests in the tracer file). +- Checkpoint record: approved versions (or rejections) of `github.com/chromedp/chromedp` and `github.com/gen2brain/webp` at the top of 33-06-SUMMARY.md. -Create `.planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md` when done; copy the final resolve-answer JSON of TestGraphTracer into it so the layer fixtures (33-07 to 33-09) can be compared. +Create `.planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md` when done; put the Task 4 answers (the approved version of chromedp and of gen2brain/webp, or the rejection) at its top for 33-10 and 33-11, and copy the final resolve-answer JSON of TestGraphTracer into it so the layer fixtures (33-07 to 33-09) can be compared. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-07-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-07-PLAN.md index 2900aa56..be69f661 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-07-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-07-PLAN.md @@ -75,6 +75,7 @@ must_haves: - "useSeoLabels() returns the L8 dictionary of the page's content locale (resolve locale, then html_lang's primary subtag, then en), overridable in app.config.ts under seo.labels; the layer has no @nuxtjs/i18n dependency (UI-SPEC L8)" - "seo-tokens.css and seo-base.css load before the app's CSS and style only .seo-* classes (UI-SPEC L8, Spacing, Color)" - "Public layer components render on the server only: the data is fetched before render, so there is no client loading state (UI-SPEC loading: public layer components)" + - "The module option seo.links.newTabHint defaults to true and reaches runtimeConfig.public.seo.links.newTabHint, so the visually hidden new-tab hint is on by default while the CMS's links_new_tab is on, and an app can switch it off (CONTEXT 2026-10-28 accepted defaults, UI-SPEC Q-8; applied to body links in 33-09)" - {statement: "Two concurrent requests for the same uncached path make one resolve call (in-flight de-duplication), and a purge during a request never serves a stale answer afterwards", verification: backstop} prohibitions: - requirement_id: "D-20a" @@ -138,7 +139,7 @@ Output: commits in ../vue-seo-layer (a new local repository, branch main, not pu **Planner decisions recorded here:** - **P33-26 (contract the fixtures follow):** the resolve answer is Phase 32's (action, status, location, url, locale, staging, seo{title, description, canonical, robots, x_robots_tag, og{title, description, image, url, locale, locale_alternates}, twitter{card, site}, verification[{name, content}]}) plus 33-05's `outbound` and 33-06's `html_lang`, `alternates[{hreflang, url}]`, `jsonld`, `breadcrumbs[{name, url, path}]`, `pagination{form, param, segment, page, invalid}` and 33-11's `autolinks[{phrase, url}]`, `autolink_max`; the site answer is 33-05's P33-17 plus 33-06's `pagination{form, param, segments}` and `home_crumb`. Absent keys are treated as empty. The fixture files hold exactly these shapes; 33-06-SUMMARY.md carries a real answer to compare in 33-12. -- **P33-27 (runtime config):** server-only `runtimeConfig.seo.apiBase` (NUXT_SEO_API_BASE, default http://127.0.0.1:8096/_seo/api/v1), `backendBase` (NUXT_SEO_BACKEND_BASE, default http://127.0.0.1:8096), `notfoundToken` (NUXT_SEO_NOTFOUND_TOKEN), `routeReportToken` (NUXT_SEO_ROUTE_REPORT_TOKEN, falls back to notfoundToken), `resolveCacheTtl` (seconds, default 60); `cachePurge.token`, `cachePurge.allowProxied` and the deprecated `siteCache` keys move here from the journal layer's config unchanged; module options (configKey `seo`): `cms` (default true), `breadcrumbs` (true), `links.newTabHint` (true), `rootFiles` (true), `resolveFiles` (['pdf']), plus the 33-08 options. +- **P33-27 (runtime config):** server-only `runtimeConfig.seo.apiBase` (NUXT_SEO_API_BASE, default http://127.0.0.1:8096/_seo/api/v1), `backendBase` (NUXT_SEO_BACKEND_BASE, default http://127.0.0.1:8096), `notfoundToken` (NUXT_SEO_NOTFOUND_TOKEN), `routeReportToken` (NUXT_SEO_ROUTE_REPORT_TOKEN, falls back to notfoundToken), `resolveCacheTtl` (seconds, default 60); `cachePurge.token`, `cachePurge.allowProxied` and the deprecated `siteCache` keys move here from the journal layer's config unchanged; module options (configKey `seo`): `cms` (default true), `breadcrumbs` (true), `links.newTabHint` (true, per CONTEXT 2026-10-28 accepted defaults: the new-tab hint is on by default while new tab is on, UI-SPEC Q-8; copied to `runtimeConfig.public.seo.links.newTabHint` for 33-09's body pipeline), `rootFiles` (true), `resolveFiles` (['pdf']), plus the 33-08 options. - **P33-28 (one head owner):** a single app plugin applies the head on every route; pages and other layers never call useSeoMeta for CMS values. useSchemaNode(node) registers a request-scoped node merged into the one graph by @id. - **P33-29 (smoke harness):** testing/smoke-ssr.mjs is copied from vue-journal-layer and gains `count=TEXT:N` (exactly N occurrences in the body; the count is the number after the last colon) and `header=Name:Value` (response header equals) assertions, and lets `contains=`, `notcontains=`, `count=` and `header=` repeat on one line (every occurrence applies; today a repeated key overwrites the earlier one); the fixture API serves /_seo/api/v1/resolve answers keyed by path from testing/fixtures/seo/resolve.json, /_seo/api/v1/site, records /_seo/api/v1/notfound calls and resolve hit counts, and serves the root files. @@ -206,7 +207,7 @@ Copy: UI-SPEC L8 dictionary (en, pl) and the seo-tokens.css token table. - .planning/phases/32-seo-foundation/32-03-SUMMARY.md and 32-04-SUMMARY.md (resolve answer and its 200/not_found behaviour) - Per D-97, D-43, D-55 and P33-26 to P33-29: `git init -b main ../vue-seo-layer` with the name from Task 1; package.json (name, private, type module, main ./nuxt.config.ts, scripts dev, build:playground, test `node --test --test-reporter=tap tests/*.test.ts`, dev:prepare, `smoke:ssr` = `node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo.txt`; peer and dev nuxt 4.4.8, dev typescript 5.9.3 and vue 3.5.35 as the journal layer pins; no runtime dependency). nuxt.config.ts registers lib/seo-module.ts and the P33-27 runtime config. seo-module.ts (configKey seo) copies its options into runtimeConfig.public.seo. `server/lib/resolve.ts`: `resolveCached(event, path)` fetching `${apiBase}/resolve?url=` with X-Forwarded-For set to the client address (journal-client-address pattern), returns null on any failure or when cms is false. `server/middleware/seo.ts` (Task 2 scope: page-path test with `isPagePath` from app/utils/pagePath.ts, resolve, `event.context.seo = res`). `app/utils/graph.ts`: pure `mergeGraph(cmsJsonld, nodes)` merging by @id. `useCmsSeo(answer)` applies useSeoMeta/useHead per RESEARCH Code Examples with keys canonical, alt- and jsonld, html lang and og:locale/alternate; `app/plugins/seo-head.ts` takes the answer from useRequestEvent().context.seo during SSR (stored in useState for hydration) and from `$fetch('/api/__seo__/resolve', {query: {url}})` on client navigation (`server/api/__seo__/resolve.get.ts` proxies through resolveCached). Playground: pages index and [...slug] rendering an H1; fixture resolve.json with an ok answer for /blog/lwia-grzywa (title, canonical, alternates pl/en/x-default, jsonld with two nodes), and site.json. smoke-ssr.mjs per P33-29; expect-seo.txt tracer lines: `200 /blog/lwia-grzywa contains=Lwia grzywa count=application/ld+json:1 count=rel="canonical":1 contains=hreflang="x-default" contains=<html lang="pl"`. Unit tests graph.test.ts (merge by @id, layer node appended, no duplicate @id) and pagePath.test.ts (/_nuxt/x, /api/x, /favicon.ico skipped; /blog/x and / are pages). Commit as listed. + Per D-97, D-43, D-55 and P33-26 to P33-29: `git init -b main ../vue-seo-layer` with the name from Task 1; package.json (name, private, type module, main ./nuxt.config.ts, scripts dev, build:playground, test `node --test --test-reporter=tap tests/*.test.ts`, dev:prepare, `smoke:ssr` = `node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo.txt`; peer and dev nuxt 4.4.8, dev typescript 5.9.3 and vue 3.5.35 as the journal layer pins; no runtime dependency). nuxt.config.ts registers lib/seo-module.ts and the P33-27 runtime config. seo-module.ts (configKey seo) copies its options into runtimeConfig.public.seo, with the P33-27 defaults (links.newTabHint true per CONTEXT 2026-10-28 accepted defaults). `server/lib/resolve.ts`: `resolveCached(event, path)` fetching `${apiBase}/resolve?url=` with X-Forwarded-For set to the client address (journal-client-address pattern), returns null on any failure or when cms is false. `server/middleware/seo.ts` (Task 2 scope: page-path test with `isPagePath` from app/utils/pagePath.ts, resolve, `event.context.seo = res`). `app/utils/graph.ts`: pure `mergeGraph(cmsJsonld, nodes)` merging by @id. `useCmsSeo(answer)` applies useSeoMeta/useHead per RESEARCH Code Examples with keys canonical, alt-<hreflang> and jsonld, html lang and og:locale/alternate; `app/plugins/seo-head.ts` takes the answer from useRequestEvent().context.seo during SSR (stored in useState for hydration) and from `$fetch('/api/__seo__/resolve', {query: {url}})` on client navigation (`server/api/__seo__/resolve.get.ts` proxies through resolveCached). Playground: pages index and [...slug] rendering an H1; fixture resolve.json with an ok answer for /blog/lwia-grzywa (title, canonical, alternates pl/en/x-default, jsonld with two nodes), and site.json. smoke-ssr.mjs per P33-29; expect-seo.txt tracer lines: `200 /blog/lwia-grzywa contains=<title>Lwia grzywa count=application/ld+json:1 count=rel="canonical":1 contains=hreflang="x-default" contains=<html lang="pl"`. Unit tests graph.test.ts (merge by @id, layer node appended, no duplicate @id) and pagePath.test.ts (/_nuxt/x, /api/x, /favicon.ico skipped; /blog/x and / are pages). Commit as listed. </action> <verify> <automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -12</automated> @@ -319,7 +320,7 @@ Copy: UI-SPEC L8 dictionary (en, pl) and the seo-tokens.css token table. ## Artifacts this phase produces - Repository `../vue-seo-layer` (local, branch main). -- Module: `lib/seo-module.ts` (configKey `seo`; options `cms`, `breadcrumbs`, `links.newTabHint`, `rootFiles`, `resolveFiles`). +- Module: `lib/seo-module.ts` (configKey `seo`; options `cms`, `breadcrumbs`, `links.newTabHint` (default true, CONTEXT 2026-10-28 accepted defaults), `rootFiles`, `resolveFiles`). - Runtime config: `seo.apiBase`, `seo.backendBase`, `seo.notfoundToken`, `seo.routeReportToken`, `seo.resolveCacheTtl`, `cachePurge.token`, `cachePurge.allowProxied`, `siteCache.purgeToken`, `siteCache.allowProxied`; env `NUXT_SEO_API_BASE`, `NUXT_SEO_BACKEND_BASE`, `NUXT_SEO_NOTFOUND_TOKEN`, `NUXT_SEO_ROUTE_REPORT_TOKEN`. - Composables and utils: `useCmsSeo`, `useSchemaNode`, `useSeoLabels`, `mergeGraph`, `isPagePath`, `pickLabels`. - Server: middleware `seo.ts`, `seo-root.ts`; libs `resolve.ts` (`resolveCached`), `lru.ts`, `site.ts`, `siteCachePurge.ts`; routes `GET /api/__seo__/resolve`, `POST /api/__site__/cache/purge`; plugins `site-cache-purge.ts`, `seo-notfound.ts`. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-08-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-08-PLAN.md index c35f2740..173b4157 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-08-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-08-PLAN.md @@ -58,9 +58,9 @@ estimate: must_haves: truths: - "nuxt build fails with \"[vue-seo-layer] indexable routes must render on the server (D-70): <routes>\" when a route rule sets ssr: false, a page is a .client.vue page, or the app sets ssr: false, unless the route is declared in seo.noindexRoutes (or the app sets seo.indexable: false); rules from nuxt.options.routeRules, nitro routeRules and every layer are checked after the layers merge, and experimental.inlineRouteRules is refused (D-70, RESEARCH Pattern 6, Pitfall 10)" - - "A route in seo.noindexRoutes gets X-Robots-Tag: noindex, nofollow through its route rule and a robots noindex,nofollow meta; a page whose meta sets requiresAuth: true or seo.robots gets the same automatically without page code, which is how apps mark signed-in and auth pages (no user-plugin layer exists; RESEARCH Open Question 10) (D-23, D-21, D-08)" + - "A route in seo.noindexRoutes gets X-Robots-Tag: noindex, nofollow through its route rule and a robots noindex,nofollow meta; a page whose meta sets requiresAuth: true or seo.robots gets the same automatically without page code, which is how apps mark signed-in and auth pages (CONTEXT 2026-10-28 accepted defaults: auth pages are marked through route meta; no user layer) (D-23, D-21, D-08)" - "At server start the layer reports its static routes (paths without parameters, outside noindexRoutes) to POST /_seo/api/v1/registry/routes with the route-report token, once per process with one retry after 60 s, so no frontend page is missing from the registry (D-33)" - - "At server start the layer compares the CMS's content-type URL patterns, pagination form and segments and HTML-sitemap paths (from /_seo/api/v1/site) with its build-time routes and options, and logs one warning per mismatch (D-20c drift check, RESEARCH Open Question 9)" + - "At server start the layer compares the CMS's content-type URL patterns, pagination form and segments and HTML-sitemap paths (from /_seo/api/v1/site) with its build-time routes and options, and logs one warning per mismatch (D-20c drift check; RESEARCH Open Question 9, resolved by UI-SPEC L3: build-time options plus a startup drift check)" - "<SeoBreadcrumbs> renders the resolve answer's breadcrumbs as nav > ol with server-rendered <a href> links and the last item as aria-current=\"page\" text, separators CSS-generated; it renders nothing with fewer than two items, when page meta sets seo.breadcrumbs false or when the option seo.breadcrumbs is false; only the first instance per request renders (D-86, D-71, UI-SPEC L1)" - "<SeoPagination> with usePagination() renders numbered server-rendered links in the CMS-chosen form (query or path, per-locale segment), keeps other query parameters, maps page 1 to the unnumbered URL, shows five pages around the current one plus page 1 and the last page with gap items, omits previous/next at the ends, adds <link rel=prev/next> to the head and replaces %pages% in the CMS title and description; it renders nothing when last ≤ 1 (D-40, D-41, D-42, D-71, UI-SPEC L2)" - "The HTML sitemap is served at each locale's html_path (defaults /mapa-strony pl, /sitemap en) with group pages at <html_path>/<group>; it lists the sitemap plugin's HTML groups (an H1 page title, an H2 per group, categories nested, large groups linking to their paginated group page), answers 404 for an unknown group or a page past the last, and a failed API call answers 503 so no empty page is cached (D-34, UI-SPEC L3)" @@ -209,7 +209,7 @@ Copy: UI-SPEC L1–L4 markup, defaults and the L8 dictionary. - useSeoSite() returns the cached site answer on server and client (proxy /api/__seo__/site) </behavior> <action> - Per D-23, D-21, D-33, D-20c and P33-30/P33-31: the module adds `requiresAuth` and `seo` to `nuxt.options.experimental.extraPageMetaExtractionKeys`, so pages:resolved carries those meta keys at build time; `lib/routes.ts` (pure) turns resolved pages into static paths (leaving out pages whose meta has requiresAuth or a noindex seo.robots, and noindexRoutes) and route patterns (`:slug()` and `[...slug]` normalised to `:name`, catch-alls marked); the module writes `#seo-routes` with addServerTemplate. `app/plugins/seo-route-robots.ts`: on every route, if the matched route's meta has requiresAuth true or seo.robots, apply useSeoMeta robots and, during SSR, setResponseHeader x-robots-tag; noindexRoutes matches (isNoindex) get the same meta. `server/plugins/seo-route-report.ts` and `seo-drift.ts` run once after Nitro starts (non-blocking), honour cms and routeReport options and the tokens of P33-27, and log results. `server/api/__seo__/site.get.ts` proxies the cached site answer; `useSeoSite()` wraps useFetch of it with a stable key. Extend fixtures and expect-seo.txt (header and meta lines, the report recorded by the fixture API checked by smoke-ssr through a `report=` expectation or a post-run assertion in fixture-api.mjs). Tests first. Commit groups A and B. + Per D-23, D-21, D-33, D-20c, P33-30/P33-31 and the CONTEXT 2026-10-28 accepted default (auth pages are marked through route meta; no user layer): the module adds `requiresAuth` and `seo` to `nuxt.options.experimental.extraPageMetaExtractionKeys`, so pages:resolved carries those meta keys at build time; `lib/routes.ts` (pure) turns resolved pages into static paths (leaving out pages whose meta has requiresAuth or a noindex seo.robots, and noindexRoutes) and route patterns (`:slug()` and `[...slug]` normalised to `:name`, catch-alls marked); the module writes `#seo-routes` with addServerTemplate. `app/plugins/seo-route-robots.ts`: on every route, if the matched route's meta has requiresAuth true or seo.robots, apply useSeoMeta robots and, during SSR, setResponseHeader x-robots-tag; noindexRoutes matches (isNoindex) get the same meta. `server/plugins/seo-route-report.ts` and `seo-drift.ts` run once after Nitro starts (non-blocking), honour cms and routeReport options and the tokens of P33-27, and log results. `server/api/__seo__/site.get.ts` proxies the cached site answer; `useSeoSite()` wraps useFetch of it with a stable key. Extend fixtures and expect-seo.txt (header and meta lines, the report recorded by the fixture API checked by smoke-ssr through a `report=` expectation or a post-run assertion in fixture-api.mjs). Tests first. Commit groups A and B. </action> <verify> <automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -10</automated> diff --git a/.planning/phases/33-seo-output-audit-and-content/33-09-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-09-PLAN.md index 07556d8b..61e3fc64 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-09-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-09-PLAN.md @@ -68,13 +68,20 @@ files_modified: - ../vue-faq-layer/tests/faqSchema.test.ts - ../vue-faq-layer/testing/expect.txt - ../vue-faq-layer/README.md + - ../vue-journal-layer/testing/fixtures/acme/categories.json + - ../sm-journal-plugin/classes/related.go + - ../sm-journal-plugin/controllers/api/posts.go + - ../sm-journal-plugin/controllers/api/posts_helpers.go + - ../sm-journal-plugin/config/config.yaml + - ../sm-journal-plugin/README.md + - ../sm-journal-plugin/related_media_test.go autonomous: true requirements: [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] estimate: - tokens: 170000 - raw_tokens: 170000 - tasks: 3 + tokens: 200000 + raw_tokens: 200000 + tasks: 4 confidence: low must_haves: @@ -82,9 +89,10 @@ must_haves: - "vue-journal-layer, vue-menu-layer and vue-faq-layer extend the shared SEO layer; the journal layer no longer uses @nuxtjs/seo (no nuxt-schema-org graph, no second canonical, no robots or sitemap module), and its own meta applies only as a fallback when no CMS answer exists (D-97, RESEARCH Pitfall 2)" - "The post page title is an h1 while the Journal API reports content_h1_to_h2 true and a p.journal-post__title (same class, same look through the journal post-title tokens) while it is false; the option journal.postTitleTag ('auto' | 'h1' | 'p', default 'auto') overrides it either way (2026-10-28 layer options: postTitleTag)" - "The journal body gate demotes h1 to h2 only while the Journal API reports content_h1_to_h2 true, read from the API, not from a layer option; while false the body keeps its h1 (2026-10-28 Journal H1 setting, D-50, UI-SPEC L7)" + - "Category pillar text follows the same rule: the category page demotes an h1 in it while the category row's content_h1_to_h2 is true (absent counts as true) and keeps it while false (CONTEXT 2026-10-28 H1 conversion scope: post content and category pillar text alike)" - "Every page of the fixture sites has exactly one <h1 and no skipped heading level: listing headers are h1 (page ≥ 2 keeps the · page suffix), post cards are h2 (h3 inside related posts under their h2), FAQ blocks with headingLevel 0 render a styled <p class=\"faq-block__title\"> for sidebar and footer widgets, menus and breadcrumbs have no headings (D-52, UI-SPEC L6)" - "Each post card has exactly one anchor, the title link: the image is no longer wrapped in a link, the separate read-more link is gone, the whole card stays clickable through a CSS stretched link with a visible focus ring, and post links carry no rel (D-80, D-81, UI-SPEC L5)" - - "Body HTML is rendered at SSR in the L7 order: H1 demotion by setting, phrase auto-links from the resolve answer (first occurrence, at most autolink_max per text, whole words, case-insensitive, never inside headings, links, code, pre, figcaption or button, never to the page itself), outbound rules (editor rel kept, nofollow or sponsored added from the domain lists; target=_blank, rel=noopener and the visually hidden new-tab hint only while outbound.new_tab is true), and images with loading=lazy and decoding=async keeping stored dimensions and alt=\"\" (D-82, D-83, D-71, 2026-10-28 layer options: links_new_tab)" + - "Body HTML is rendered at SSR in the L7 order: H1 demotion by setting, phrase auto-links from the resolve answer (first occurrence, at most autolink_max per text, whole words, case-insensitive, never inside headings, links, code, pre, figcaption or button, never to the page itself), outbound rules (editor rel kept, nofollow or sponsored added from the domain lists; target=_blank and rel=noopener only while outbound.new_tab is true, plus the visually hidden new-tab hint while the SEO-layer option seo.links.newTabHint is also true, its default), and images with loading=lazy and decoding=async keeping stored dimensions and alt=\"\" (D-82, D-83, D-71, 2026-10-28 layer options: links_new_tab; CONTEXT 2026-10-28 accepted defaults: the new-tab hint is on by default while new tab is on, UI-SPEC Q-8)" - "Journal pages render <SeoBreadcrumbs> at the top of their content by default; journal.breadcrumbs false turns that off for sites whose layout places breadcrumbs, while the BreadcrumbList data is unchanged (D-86, 2026-10-28 layer options: journal.breadcrumbs)" - "JournalPagination renders <SeoPagination class-prefix=\"journal-pagination\"> with the journal's labels, so existing journal-pagination class names and app CSS keep working while links follow the CMS page form (path-form routes are registered when the build option is path); category pillar content renders on page 1 only, the short description on every page (D-40, D-41, D-42, UI-SPEC L2)" - "Listing pages (index, category, tag, author) add an ItemList node #itemlist with their posts through useSchemaNode, and FAQ blocks add a FAQPage node (schema prop, default true), so a page still has exactly one JSON-LD script with one @graph (D-43)" @@ -93,7 +101,8 @@ must_haves: - "Journal breadcrumbs turned off (journal.breadcrumbs false): journal pages render no <SeoBreadcrumbs>, a layout instance renders the trail, JSON-LD unchanged (UI-SPEC partial: Journal breadcrumbs turned off)" - {statement: "Post card: each .journal-post-card contains exactly one <a, the title is h2 (h3 inside related posts) and no journal-post-card__more exists (UI-SPEC L5 backstop)", verification: backstop} - {statement: "Heading convention over the fixture pages: exactly one <h1, no skipped level, FAQ headingLevel 0 renders <p>; the post page in both Journal H1 modes (on: title h1, body h1 demoted; off: title p.journal-post__title, the body's single h1 is the page h1); postTitleTag 'h1' with the setting off gives two <h1 and 'p' with it on gives none, asserted as the documented consequence of the override (UI-SPEC L6 backstop)", verification: backstop} - - {statement: "Outbound links with outbound.new_tab false keep their editor rel and domain-list nofollow/sponsored and have no target, no noopener and no .seo-visually-hidden hint; with true they have all three (UI-SPEC L7 backstop)", verification: backstop} + - {statement: "Outbound links with outbound.new_tab false keep their editor rel and domain-list nofollow/sponsored and have no target, no noopener and no .seo-visually-hidden hint; with true and the default seo.links.newTabHint they have all three, and with newTabHint false they have target and noopener without the hint (UI-SPEC L7 backstop, CONTEXT 2026-10-28 accepted defaults)", verification: backstop} + - "Journal related posts stay as today by default (same category, three, newest first); with golem15.journal.related_mode scored they are 3 to 6 (golem15.journal.related_count) ranked by shared tags and categories, and the post answer adds previous_in_category and next_in_category, which the journal layer renders as server-rendered links instead of the site-wide neighbours (D-82; CONTEXT 2026-10-28 accepted defaults: related posts opt-in in Journal, current behaviour as default; porting contract kept)" prohibitions: - requirement_id: "D-83" category: values @@ -140,23 +149,26 @@ must_haves: **As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP. -This plan moves the journal, menu and FAQ layers onto the shared SEO layer: CMS head and one graph, the heading convention in both Journal H1 modes, one link per card, body links and images by the rules, breadcrumbs and pagination from the CMS, and one purge route. +This plan moves the journal, menu and FAQ layers onto the shared SEO layer: CMS head and one graph, the heading convention in both Journal H1 modes, one link per card, body links and images by the rules, breadcrumbs and pagination from the CMS, and one purge route. It also adds Journal's opt-in scored related posts and category neighbours. <objective> Change the three existing layers (each main, not pushed; tags in 33-14) and add the body-link utilities to vue-seo-layer: - journal: drop @nuxtjs/seo, extend the SEO layer, L5 cards, L6 headings, L7 body pipeline, postTitleTag and journal.breadcrumbs options, SeoPagination wrapper, pillar on page 1, ItemList nodes (2026-10-28 layer options; D-50, D-52, D-80..83, D-71, D-86, D-40..42); - menu and FAQ: extend the SEO layer, remove purge copies, FAQ headingLevel 0 and the FAQPage node (D-52, D-43, D-71); -- vue-seo-layer: `app/utils/bodyLinks.ts` (autolink, applyOutbound, imageAttrs). +- vue-seo-layer: `app/utils/bodyLinks.ts` (autolink, applyOutbound, imageAttrs); +- sm-journal-plugin and the journal layer: opt-in scored related posts and category neighbours (D-82; CONTEXT 2026-10-28 accepted defaults: opt-in, current behaviour default). This task moved here from 33-11 in the 2026-10-28 revision so that 33-11 stays within its task budget; it changes the same PostPage.vue this plan already touches. -Purpose: SC-2 in the layers every site uses. +Purpose: SC-2 in the layers every site uses; the related-posts part of D-82. -Output: commits in ../vue-seo-layer, ../vue-journal-layer, ../vue-menu-layer, ../vue-faq-layer. +Output: commits in ../vue-seo-layer, ../vue-journal-layer, ../vue-menu-layer, ../vue-faq-layer and ../sm-journal-plugin (main, not pushed; tags in 33-14). **Planner decisions recorded here:** - **P33-33 (dependency during development):** each layer's package.json gets `"vue-seo-layer": "link:../vue-seo-layer"` in dependencies with the lockfile updated by `pnpm install`; 33-14 replaces it with the git tag pin (`git+https://git.golem15.com/golem15/vue-seo-layer.git#v<tag>`) before tagging (2026-10-28 release cadence: all tags in 33-14). - **P33-34 (fallback meta):** app/utils/seo.ts keeps only the fallback (post title, excerpt, featured image) applied when `useCmsSeo` has no answer (`seo.cms` false, as summercms.io in 33-12, or the CMS unreachable), so a site without the SEO plugin keeps titles and no page gets two titles. - **P33-35 (H1 flag source):** the post page reads `post.content_h1_to_h2` (33-02's additive key in the post Show answer); when the key is absent (an older Journal) it behaves as true, the D-50 default. - **P33-36 (override cases in smoke):** postTitleTag is copied into `runtimeConfig.public.journal.postTitleTag`, so the smoke runs the override cases on the same build with `--env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1` (and `p`) and a second expectation file. +- **P33-44 (related options, moved from 33-11; CONTEXT 2026-10-28 accepted defaults: related posts opt-in, current behaviour default):** config keys `golem15.journal.related_mode` (category | scored, default category) and `golem15.journal.related_count` (3 to 6, default 3); the existing previous_post/next_post keys keep their meaning; previous_in_category and next_in_category (primary = first category) are added only in scored mode. +- **P33-35 also covers category pillar text:** CategoryPage and CategoryHeader sanitize the pillar text with `demoteH1 = category.content_h1_to_h2 !== false` (33-02's additive key on category rows; absent behaves as true), so pillar text follows the Journal setting like post bodies (CONTEXT 2026-10-28 H1 conversion scope). </objective> <execution_context> @@ -176,7 +188,8 @@ Output: commits in ../vue-seo-layer, ../vue-journal-layer, ../vue-menu-layer, .. <interfaces> From 33-07/33-08 (vue-seo-layer): extends target `vue-seo-layer`; useCmsSeo answer in useState; useSchemaNode(node); <SeoBreadcrumbs> (props items, label, hideCurrent; page meta seo.breadcrumbs), <SeoPagination> (props current, last, labels, classPrefix), usePagination({current, last}), useSeoLabels, `seo-visually-hidden` class, runtimeConfig cachePurge/siteCache keys, server/lib/siteCachePurge.ts, smoke-ssr count=/header=. Resolve answer (P33-26): autolinks [{phrase, url}], autolink_max, outbound {new_tab, nofollow_domains, sponsored_domains}, breadcrumbs, pagination, url (absolute page URL, its path is "self" for auto-links). -Journal API (33-02): post Show has content_h1_to_h2 (boolean); featured_images[{id, url, file_name, file_size}]. +Journal API (33-02): post Show and every category row of GET /categories have content_h1_to_h2 (boolean); featured_images[{id, url, file_name, file_size}]. +sm-journal-plugin (for Task 4): controllers/api/posts.go Show (related_posts via relatedPosts, previous_post/next_post via siblingPost), posts_helpers.go (relatedPosts 152-172, siblingPost 122-148), config/config.yaml (33-02); vue-journal-layer PostPage.vue siblings nav (51-66), utils/types.ts (JournalPostSibling). vue-journal-layer today: lib/journal-module.ts (defaults routes, perPage, sort, relatedCount, rss → runtimeConfig.public.journal), app/utils/htmlGate.ts (gateJournalHtml with sanitize-html POLICY and KEEP_ALL), PostCard.vue:15-24 (image link + read-more), PostPage.vue:14 (cfg), :40 (useSeoMeta postSeoMeta), :46 (h1), Pagination.vue, utils/pagination.ts (paginationWindow), CategoryHeader.vue, RelatedPosts.vue, nuxt.config.ts (@nuxtjs/seo, ogImage, seo, sitemap keys), testing/smoke-ssr.mjs and expect-acme.txt. vue-faq-layer Block.vue (headingLevel 2..4, aria-labelledby), useFaq answer {name, description, content[{question, answer}]}. RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text nodes with an open-tag stack). @@ -185,14 +198,15 @@ RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text node ## Execution units and resume points -- Commit groups per repository, staged by path (`git -C ../<layer> add <paths>`, deletions with `git rm`). `npm --prefix ../<layer> run test` green at every commit in that layer. -- Resume check: `git -C ../<layer> log --format=%s | grep -Fx '<subject>'`. +- Commit groups per repository, staged by path (`git -C ../<layer> add <paths>`, deletions with `git rm`). `npm --prefix ../<layer> run test` green at every commit in that layer; in ../sm-journal-plugin `go vet ./...` and `go test -short ./...` green at every commit, and its untracked `go.work.sum` (another session's) stays alone. +- Resume check: `git -C ../<repo> log --format=%s | grep -Fx '<subject>'`. | Task | Commit groups, in order | Resume point | |------|-------------------------|--------------| | 1 | journal: `feat!: render the post page on the shared SEO layer with one H1 in both Journal H1 modes (tracer)` | expect-acme.txt tracer lines pass | | 2 | seo: `feat: body link utilities (auto-links, outbound rules, image attributes)`; journal: A `feat: body links, outbound rules and image attributes in the post body`; B `feat!: one link per post card and the heading convention`; C `feat: breadcrumbs, CMS pagination and pillar content on page one`; D `feat: ItemList nodes on listing pages` | both expectation files pass | -| 3 | menu: `feat!: extend the SEO layer and drop the copied purge route`; faq: A `feat!: extend the SEO layer and drop the copied purge route`; B `feat: FAQPage node and widget headings`; journal: `docs: README for the SEO layer` | plan complete | +| 3 | menu: `feat!: extend the SEO layer and drop the copied purge route`; faq: A `feat!: extend the SEO layer and drop the copied purge route`; B `feat: FAQPage node and widget headings`; journal: `docs: README for the SEO layer` | menu and FAQ smokes pass | +| 4 | journal plugin: `feat: opt-in scored related posts and category neighbours`; journal layer: `feat: category neighbours on the post page` | plan complete | <tasks> @@ -224,7 +238,7 @@ RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text node <task type="auto" tdd="true"> <name>Task 2: Body link utilities and the L7 pipeline, L5 post cards, L6 headings, breadcrumbs, CMS pagination, pillar on page 1 and ItemList nodes</name> - <files>../vue-seo-layer/app/utils/bodyLinks.ts, ../vue-seo-layer/tests/bodyLinks.test.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/components/Journal/Pagination.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/components/Journal/RelatedPosts.vue, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/journal-pages/PostIndexPage.vue, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/journal-pages/TagPage.vue, ../vue-journal-layer/app/journal-pages/AuthorPage.vue, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/tests/routes.test.ts, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/testing/expect-acme-title-h1.txt, ../vue-journal-layer/testing/fixtures/acme/seo/resolve.json</files> + <files>../vue-seo-layer/app/utils/bodyLinks.ts, ../vue-seo-layer/tests/bodyLinks.test.ts, ../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/app/utils/routes.ts, ../vue-journal-layer/lib/journal-module.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/components/Journal/Pagination.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/components/Journal/RelatedPosts.vue, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/journal-pages/PostIndexPage.vue, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/journal-pages/TagPage.vue, ../vue-journal-layer/app/journal-pages/AuthorPage.vue, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/tests/htmlGate.test.ts, ../vue-journal-layer/tests/routes.test.ts, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/testing/expect-acme-title-h1.txt, ../vue-journal-layer/testing/fixtures/acme/seo/resolve.json, ../vue-journal-layer/testing/fixtures/acme/categories.json, ../vue-journal-layer/app/utils/types.ts</files> <read_first> - ../vue-journal-layer/app/components/Journal/{PostCard,Pagination,CategoryHeader,RelatedPosts,FeaturedImage}.vue and the journal pages - ../vue-journal-layer/app/utils/pagination.ts and routes.ts @@ -233,13 +247,14 @@ RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text node </read_first> <behavior> - autolink: first occurrence only, at most max per text, case-insensitive whole words, never inside a, h1–h6, code, pre, figcaption, button, never to the page's own path, phrase with regex characters matched literally, URLs escaped in href; output is a plain <a href> - - applyOutbound: an https link to another host keeps its rel tokens, gains nofollow for a listed domain (subdomains included) or sponsored, and with newTab true gains target=_blank, rel noopener and the hidden hint span; with newTab false none of the three; internal and relative links unchanged + - applyOutbound: an https link to another host keeps its rel tokens, gains nofollow for a listed domain (subdomains included) or sponsored, and with newTab true gains target=_blank, rel noopener and the hidden hint span (hint default on, per CONTEXT 2026-10-28 accepted defaults; with hint false target and noopener only); with newTab false none of the three; internal and relative links unchanged + - Category pillar text: a category row with content_h1_to_h2 true (or the key absent) renders its pillar h1 as h2 on the category page; with false the h1 stays (fixture categories.json has one of each) - imageAttrs: body img gains loading=lazy and decoding=async, keeps width/height and alt="" - Smoke: each .journal-post-card has one <a and an h2 title, related posts are h2 with h3 cards, no read-more link; listing pages have one h1 and on ?page=2 the · Strona 2 suffix; the post page renders one nav.seo-breadcrumbs (none with journal.breadcrumbs false through NUXT_PUBLIC_JOURNAL_BREADCRUMBS=false); the category page shows its pillar content on page 1 only; journal-pagination markup stays with first/last and gap items; index and category pages have one JSON-LD script containing ItemList - Smoke with NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1 (expect-acme-title-h1.txt): the "off" post has two <h1 (documented override); with p, the "on" post has no <h1 </behavior> <action> - Per D-80 to D-83, D-71, D-52, D-86, D-40 to D-42, D-43 and UI-SPEC L1/L2/L5/L6/L7: `vue-seo-layer/app/utils/bodyLinks.ts` (pure, no Vue imports, outside auto-import dirs' side effects) with `autolink(html, links, max, selfPath)`, `applyOutbound(html, {siteHost, newTab, nofollowDomains, sponsoredDomains, hint})`, `imageAttrs(html)`, working on sanitize-html's normalised output with a tag/text split and an open-tag stack (RESEARCH example), attribute values escaped; tests first. Journal htmlGate runs the L7 order after sanitizing (POLICY now allows rel tokens nofollow, sponsored, ugc on a, and target only as added by applyOutbound); PostBody passes the resolve answer's autolinks, autolink_max, outbound and url path. PostCard.vue per L5 (headingLevel 2|3, one title link, image without link, stretched-link CSS in journal-base.css, focus ring with :has); RelatedPosts h2 and cards headingLevel 3; Pagination.vue wraps SeoPagination with class-prefix journal-pagination and the journal labels; journal-module.ts registers path-form listing routes (`<listing>/<segment>/:page`) when the SEO build option pagination.form is path (routes.ts helper, tested); CategoryHeader renders rich content only on page 1; journal pages render SeoBreadcrumbs when journal.breadcrumbs is true; listing pages call useSchemaNode with an ItemList (@id <url>#itemlist, ListItem position and url of each post on the page). Expectation files per behavior list. Commit groups as listed. + Per D-80 to D-83, D-71, D-52, D-86, D-40 to D-42, D-43 and UI-SPEC L1/L2/L5/L6/L7: `vue-seo-layer/app/utils/bodyLinks.ts` (pure, no Vue imports, outside auto-import dirs' side effects) with `autolink(html, links, max, selfPath)`, `applyOutbound(html, {siteHost, newTab, nofollowDomains, sponsoredDomains, hint})`, `imageAttrs(html)`, working on sanitize-html's normalised output with a tag/text split and an open-tag stack (RESEARCH example), attribute values escaped; tests first. Journal htmlGate runs the L7 order after sanitizing (POLICY now allows rel tokens nofollow, sponsored, ugc on a, and target only as added by applyOutbound); PostBody passes the resolve answer's autolinks, autolink_max, outbound, url path and the SEO-layer option seo.links.newTabHint as hint (default true: CONTEXT 2026-10-28 accepted defaults, UI-SPEC Q-8). CategoryPage and CategoryHeader sanitize the pillar text with demoteH1 from the category row's content_h1_to_h2 (P33-35; types.ts adds it to JournalCategory; categories.json gets one category per value, each with a pillar h1). PostCard.vue per L5 (headingLevel 2|3, one title link, image without link, stretched-link CSS in journal-base.css, focus ring with :has); RelatedPosts h2 and cards headingLevel 3; Pagination.vue wraps SeoPagination with class-prefix journal-pagination and the journal labels; journal-module.ts registers path-form listing routes (`<listing>/<segment>/:page`) when the SEO build option pagination.form is path (routes.ts helper, tested); CategoryHeader renders rich content only on page 1; journal pages render SeoBreadcrumbs when journal.breadcrumbs is true; listing pages call useSchemaNode with an ItemList (@id <url>#itemlist, ListItem position and url of each post on the page). Expectation files per behavior list. Commit groups as listed. </action> <verify> <automated>npm --prefix ../vue-seo-layer run test 2>&1 | tail -8 && npm --prefix ../vue-journal-layer run test 2>&1 | tail -8</automated> @@ -252,6 +267,7 @@ RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text node - `grep -n "journal-post-card__more\|readMore" ../vue-journal-layer/app/components/Journal/PostCard.vue` prints nothing - `grep -n "SeoPagination" ../vue-journal-layer/app/components/Journal/Pagination.vue` prints one line - `grep -n "count=<h1:1" ../vue-journal-layer/testing/expect-acme.txt` prints at least five lines (every fixture page) + - `grep -n "content_h1_to_h2" ../vue-journal-layer/app/journal-pages/CategoryPage.vue ../vue-journal-layer/app/components/Journal/CategoryHeader.vue` prints at least one line </acceptance_criteria> <done>Journal pages follow the heading, card, body-link, image, breadcrumb and pagination rules, verified on built pages.</done> </task> @@ -282,6 +298,36 @@ RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text node <done>All three content layers render through the SEO layer with one graph, one canonical and one purge route.</done> </task> +<task type="auto" tdd="true"> + <name>Task 4: Opt-in scored related posts and category neighbours (Journal API and layer)</name> + <files>../sm-journal-plugin/classes/related.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_helpers.go, ../sm-journal-plugin/config/config.yaml, ../sm-journal-plugin/README.md, ../sm-journal-plugin/related_media_test.go, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files> + <read_first> + - ../sm-journal-plugin/controllers/api/posts_helpers.go (relatedPosts 152-172, siblingPost 122-148) and posts.go Show (147-154) + - ../sm-journal-plugin/config/config.yaml (added by 33-02) and journal_api_matrix_test.go (the default answer that must not change) + - ../vue-journal-layer/app/journal-pages/PostPage.vue (siblings nav 51-66, as changed by Tasks 1 and 2) and app/utils/types.ts + </read_first> + <behavior> + - Default mode: Show answers exactly as before (journal_api_matrix_test.go unchanged) + - related_mode scored, related_count 5: up to 5 published posts other than the post, ranked by shared tags (2 points each) plus shared categories (1 point each), ties by newest; count outside 3–6 is clamped; previous_in_category and next_in_category are the neighbours by published_at within the post's first category + - The layer renders previous_in_category/next_in_category links (server-rendered <a href> with the title in the anchor text) when present, else the existing site-wide neighbours + </behavior> + <action> + Per D-82, P33-44 and the CONTEXT 2026-10-28 accepted default (related posts opt-in in Journal, current behaviour as default; the porting contract keeps the default answer byte for byte): `classes/related.go` with the scoring query (one SQL with counts, published only for non-editors) and the category neighbours; Show uses it only in scored mode and adds the two keys; config/config.yaml documents both keys; README section. Layer PostPage prefers the in-category neighbours; types.ts adds them; a fixture post with them and an expectation line. Tests first (`TestRelatedScored`, `TestRelatedDefaultUnchanged` in related_media_test.go, which 33-11 extends with TestImageVariants). Commit as listed. + </action> + <verify> + <automated>go -C ../sm-journal-plugin test ./... -run 'TestRelatedScored|TestRelatedDefaultUnchanged|TestJournalAPIMatrix' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated> + <fails_when>a "FAIL" line, "no tests to run", or TestRelatedScored missing from the "--- PASS" lines</fails_when> + <automated>npm --prefix ../vue-journal-layer run test 2>&1 | tail -6 && npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -2 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -8</automated> + <fails_when>non-zero exit from any command, or a "# fail" count above 0, a "not ok" line or a "FAIL" line</fails_when> + </verify> + <acceptance_criteria> + - `grep -n "golem15.journal.related_mode\|golem15.journal.related_count" ../sm-journal-plugin/classes/related.go ../sm-journal-plugin/config/config.yaml` prints at least two lines + - `grep -n "previous_in_category" ../vue-journal-layer/app/journal-pages/PostPage.vue` prints at least one line + - TestJournalAPIMatrix passes and `git -C ../sm-journal-plugin show --stat --format= HEAD` (the related-posts commit) does not list journal_api_matrix_test.go + </acceptance_criteria> + <done>Sites can switch on richer related posts and category neighbours without changing the default answer.</done> +</task> + </tasks> <threat_model> @@ -300,6 +346,7 @@ RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text node | T-33-40 | Tampering | htmlGate policy (rel/target) | medium | mitigate | the sanitizer accepts only the rel tokens nofollow, sponsored, ugc; target and noopener are added after sanitizing by applyOutbound, never taken from stored HTML | | T-33-41 | Information disclosure | removal of @nuxtjs/seo | low | mitigate | no site config with NUXT_SITE_* is published anymore; purge-token-check stays in the journal testing harness | | T-33-42 | Denial of service | auto-link phrase table size | low | mitigate | at most autolink_max links per text; phrase scan is linear in text length per phrase; the resolver filters phrases per locale | +| T-33-70 | Information disclosure | scored related posts and category neighbours | medium | mitigate | the scoring and neighbour queries return published posts only for visitors (editors keep today's rule), TestRelatedScored plants a draft sharing every tag and asserts it never appears | | T-33-SC | Tampering | npm installs | high | mitigate | @nuxtjs/seo removed, no package added; the only new dependency is the first-party vue-seo-layer through a local link; lockfiles committed | </threat_model> @@ -307,11 +354,13 @@ RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text node - `npm --prefix ../<layer> run test` green in all four layers. - Journal: playground build plus both expectation files; menu and FAQ: playground builds and smoke. - Cross-layer check recorded in the SUMMARY. +- sm-journal-plugin: vet, `test -short`, TestRelatedScored, TestRelatedDefaultUnchanged and the API matrix. </verification> <success_criteria> - The three content layers extend the SEO layer and drop their own SEO module and purge copies. -- Headings, cards, body links, images, breadcrumbs and pagination follow the rules on built pages in both Journal H1 modes. +- Headings, cards, body links, images, breadcrumbs and pagination follow the rules on built pages in both Journal H1 modes, for post bodies and category pillar text. +- Scored related posts and category neighbours are available as an opt-in; the default answer is unchanged. </success_criteria> ## Artifacts this phase produces @@ -321,6 +370,8 @@ RESEARCH Code Examples: phrase auto-linking over sanitize-html output (text node - vue-journal-layer: `gateJournalHtml(html, {demoteH1})`, PostCard prop `headingLevel`, CSS tokens `--journal-post-title-size`, `--journal-post-title-weight`, `--journal-post-title-line-height`, path-form pagination routes, `testing/expect-acme-title-h1.txt`, smoke-ssr `count=`/`header=`; removed: `server/api/__sitemap__/journal.ts`, `app/utils/sitemap.ts`, the shared purge copies, the @nuxtjs/seo dependency. - vue-faq-layer: Block props `headingLevel: 0` and `schema`, `app/utils/faqSchema.ts`. - vue-menu-layer: extends vue-seo-layer; purge copies removed. +- sm-journal-plugin: config keys `golem15.journal.related_mode`, `golem15.journal.related_count`; `classes/related.go`; API keys `previous_in_category`, `next_in_category` (scored mode only); tests `TestRelatedScored`, `TestRelatedDefaultUnchanged` in `related_media_test.go`. +- vue-journal-layer: category neighbours on the post page; `JournalCategory.content_h1_to_h2` and the pillar-text demotion. <output> Create `.planning/phases/33-seo-output-audit-and-content/33-09-SUMMARY.md` when done. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-10-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-10-PLAN.md index 462861e7..4070d40a 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-10-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-10-PLAN.md @@ -13,6 +13,9 @@ files_modified: - ../sm-seo-plugin/classes/crawl/orphans.go - ../sm-seo-plugin/classes/crawl/store.go - ../sm-seo-plugin/classes/crawl/fix.go + - ../sm-seo-plugin/classes/crawl/render.go + - ../sm-seo-plugin/classes/crawl/render_chrome.go + - ../sm-seo-plugin/classes/crawl/compare.go - ../sm-seo-plugin/classes/issues.go - ../sm-seo-plugin/classes/settings_write.go - ../sm-seo-plugin/classes/partials.go @@ -54,15 +57,20 @@ files_modified: - ../sm-seo-plugin/lang/en/lang.yaml - ../sm-seo-plugin/lang/pl/lang.yaml - ../sm-seo-plugin/README.md + - ../sm-seo-plugin/go.mod + - ../sm-seo-plugin/go.sum - ../sm-seo-plugin/crawler_test.go - ../sm-seo-plugin/crawl_site_test.go + - ../sm-seo-plugin/crawl_render_test.go + - .planning/notes/2026-10-28-chromedp-rendered-crawl.md + - .planning/research/STACK.md autonomous: true requirements: [SC-3, D-14, D-46, D-51, D-70, D-71, D-80, D-81, D-82, D-85, D-24, D-50, D-10, D-94] estimate: - tokens: 180000 - raw_tokens: 180000 - tasks: 3 + tokens: 215000 + raw_tokens: 215000 + tasks: 4 confidence: low must_haves: @@ -70,9 +78,11 @@ must_haves: - "A built-in crawler runs as a conga job (on demand from SEO › Crawls or Site audit, from MCP, or on the crawl_schedule setting off/daily/weekly at night): it fetches the site's own pages over HTTP from the home page, the sitemap index and its files, and same-host links, with at most two requests at a time, crawl_delay between requests (default 250 ms), a 10 s timeout, a 5 MiB body cap and crawl_max_pages (default 5000); one crawl runs at a time and older crawls beyond crawl_keep are deleted with their pages and issues (D-14)" - "Outbound requests go through a fetchguard client limited to the site's host (and its www twin), HTTPS only and without following redirects; plain http (staging, local) works only when the config key golem15.seo.crawl_trusted is true (D-14, security)" - "The crawler reports per URL every crawl kind of UI-SPEC C23: page_status, sitemap_error, sitemap_noindex, sitemap_blocked, sitemap_missing, broken_link, redirected_link, canonical_missing, canonical_error, missing/duplicate title and description, h1_missing, h1_multiple, heading_skipped, heading_empty, h1_title_mismatch, jsonld_invalid, jsonld_multiple, jsonld_required, jsonld_recommended, hreflang_reciprocal, hreflang_error, generic_anchor, image_alt, image_size, featured_lazy, content_js and orphan_page, with the severities of the kinds table (D-14, D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24)" - - "The crawler checks the rendered (server) HTML against CMS expectations without a browser: the descriptor title must appear in the H1, the word count must reach half of the descriptor's Words, and CMS-known links of the page must be present; a miss is content_js ('Content needs JavaScript') (D-70, planner default: raw-HTML check only, no headless browser)" - - "CMS-data checks run at the end of every crawl (and when the list is opened before any crawl) and are stored with the crawl's issues under source cms; they now include image_alt from the descriptors' images (empty alt that is not decorative) and h1_in_content, which runs only for records whose descriptor sets TopHeading 2, so it is off when Journal's H1 setting is off (D-85, D-50, 2026-10-28 Journal H1 setting)" - - "Every issue names the MCP tool that fixes it (seo_ tools for SEO data, the content type's ContentDescriptor.Tools for content: edit, headings, image_alt) or carries fix code when the cause is the site's templates; no issue has neither (SC-3, D-10, RESEARCH Open Question 4)" + - "The raw-HTML checks always run: the server-rendered HTML fetched without JavaScript is checked against CMS expectations (the descriptor title must appear in the H1, the word count must reach half of the descriptor's Words, the CMS-known links of the page must be present); a miss is content_js ('Content needs JavaScript') with details.check cms (D-70, D-71; CONTEXT 2026-10-28 rendered comparison: raw-HTML checks always run)" + - "When the config key golem15.seo.crawl_chrome_path names a Chrome or Chromium binary, the crawler also renders up to golem15.seo.crawl_render_max pages (default 200, in crawl order) in headless Chrome through github.com/chromedp/chromedp and compares each with its raw HTML: same-host links, an H1 or a substantial amount of text that appear only after JavaScript are reported as content_js with details.check rendered and details.what naming them; without a Chrome path the crawl records render_status skipped, and P6, GET /_seo/api/v1/crawls/{id} and seo_get_crawl say that the comparison was skipped; a renderer failure records render_status failed with its note and never fails the crawl (D-70; CONTEXT 2026-10-28 rendered comparison: optional chromedp; P33-55)" + - "The headless browser is held to the crawler's fetchguard limits: Chrome resolves only the site host and its www twin, pinned through --host-resolver-rules to addresses checked with fetchguard.IsPrivateAddr (private addresses refused unless golem15.seo.crawl_trusted is true) while every other host resolves to NOTFOUND; every request is intercepted and failed unless it is https (http only in trusted mode) to an allowed host; each crawl uses a fresh temporary profile deleted afterwards, with no cookies, credentials or extensions and downloads denied; each page has a 20 s timeout and a 5 MiB DOM cap, one tab renders at a time, and Chrome is closed when the crawl finishes, fails or is cancelled (D-14 security, P33-55)" + - "CMS-data checks run at the end of every crawl (and when the list is opened before any crawl) and are stored with the crawl's issues under source cms; they now include image_alt from the descriptors' images (empty alt that is not decorative) and h1_in_content, which runs only for records whose descriptor sets TopHeading 2 (Journal posts and, since the 2026-10-28 H1 conversion scope, categories' pillar text), so it is off when Journal's H1 setting is off (D-85, D-50, 2026-10-28 Journal H1 setting)" + - "Every data issue names the MCP tool that fixes it (seo_ tools for SEO data, the content type's ContentDescriptor.Tools for content: edit, headings, image_alt), and an issue caused by the site's template code carries fix code instead (UI-SPEC C23); no issue has neither (SC-3, D-10; CONTEXT 2026-10-28 success criterion 3 reading)" - "orphan_page lists indexable sitemap pages with no inbound internal link in the crawl, with suggestions of pages whose text mentions the page's title or focus keyphrase (D-82)" - "SEO › Site audit lists the stored issues (errors first) with the C23 columns, filters (status defaults to Open), the P7 preview (what was found, how to fix, page and Rich Results links), and the record actions Check this address again, Ignore and Stop ignoring plus bulk Ignore; ignored issues stay ignored in later crawls (UI-SPEC C23)" - "SEO › Crawls lists runs with the C24 columns, the P6 preview (one status callout, stats strip, per-kind table linking to the filtered audit), Start crawl (toolbar, also on Site audit) refusing with crawls.already_running or crawls.no_site_url, Cancel on a queued or running crawl that keeps what was fetched, and delete refusing a running crawl with crawls.delete_running (UI-SPEC C24)" @@ -98,6 +108,11 @@ must_haves: status: resolved verification: test statement: "The crawler never changes data: it reads pages and writes only its own crawl and issue tables" + - requirement_id: "D-70" + category: safety + status: resolved + verification: test + statement: "The headless browser never loads a URL outside the site's host and its www twin, never reaches a private address unless trusted mode is configured in Go config, and never carries cookies or credentials; its executable path comes only from Go config, never from an admin setting or MCP" artifacts: - path: "../sm-seo-plugin/classes/crawl/crawler.go" provides: "the crawl job: frontier, politeness, limits, cancel" @@ -109,8 +124,17 @@ must_haves: provides: "P7" contains: "summer-callout" - path: "../sm-seo-plugin/crawler_test.go" - provides: "TestCrawlerTracer, TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestCrawlAdmin, TestCrawlTools, TestCrawlSchedule" + provides: "TestCrawlerTracer, TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestRenderSkipped, TestCrawlAdmin, TestCrawlTools, TestCrawlSchedule" contains: "func TestCrawlerTracer(" + - path: "../sm-seo-plugin/classes/crawl/compare.go" + provides: "compareRendered: raw versus rendered page facts (pure)" + contains: "func compareRendered(" + - path: "../sm-seo-plugin/classes/crawl/render_chrome.go" + provides: "the chromedp-backed Renderer with the request guard" + contains: "chromedp" + - path: ".planning/notes/2026-10-28-chromedp-rendered-crawl.md" + provides: "decision note naming github.com/chromedp/chromedp, committed before the go.mod change" + contains: "github.com/chromedp/chromedp" key_links: - from: "../sm-seo-plugin/classes/crawl/crawler.go" to: "summercms.go/modules/fetchguard" @@ -124,33 +148,50 @@ must_haves: to: "summercms.go/modules/pact/content.go" via: "content issues name ContentDescriptor.Tools[edit|headings|image_alt]" pattern: "Tools\\[" + - from: "../sm-seo-plugin/classes/crawl/render_chrome.go" + to: "summercms.go/modules/fetchguard" + via: "the site host and its www twin are resolved and checked with fetchguard.IsPrivateAddr before Chrome starts; the checked addresses go into --host-resolver-rules" + pattern: "fetchguard\\.IsPrivateAddr" + - from: "../sm-seo-plugin/classes/crawl/crawler.go" + to: "../sm-seo-plugin/classes/crawl/compare.go" + via: "after a page's raw fetch, a configured Renderer renders it and compareRendered turns the differences into content_js findings" + pattern: "compareRendered" --- <!-- planner-discipline-allow: seo --> +<!-- planner-discipline-allow: crawl_chrome_path --> ## Phase Goal **As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP. -This plan adds the audit: a built-in crawler that fetches the rendered site like a search engine, stores what it finds per URL with the CMS-data checks, and offers every issue to an MCP client with the tool that fixes it. +This plan adds the audit: a built-in crawler that fetches the rendered site like a search engine, compares the raw HTML with the JavaScript-rendered page when a Chrome binary is configured, stores what it finds per URL with the CMS-data checks, and offers every issue to an MCP client with the tool that fixes it. <objective> Extend ../sm-seo-plugin (main, not pushed): - the crawler as a conga job with storage, politeness limits and the SSRF guard (D-14); -- the checks of UI-SPEC C23 (D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24) and the merged CMS issues with fix routes (SC-3); +- the checks of UI-SPEC C23 (D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24) and the merged CMS issues with fix routes (SC-3, CONTEXT 2026-10-28 success criterion 3 reading); +- the raw-versus-rendered comparison of D-70 through optional chromedp (CONTEXT 2026-10-28 rendered comparison), after its decision note and the legitimacy answer of 33-06 Task 4; - Site audit and Crawls screens (C23, C24), the Site audit settings tab (C20), the nightly schedule; - MCP tools for crawls and issues (D-10). -Purpose: ROADMAP success criterion 3 ("The crawler reports SEO issues per URL, and each issue can be fixed through MCP"). +Purpose: ROADMAP success criterion 3 ("The crawler reports SEO issues per URL, and each issue can be fixed through MCP") and D-70's raw versus rendered check. -Output: commits in ../sm-seo-plugin. +Output: commits in ../sm-seo-plugin; the chromedp decision note and STACK row as a planning-docs commit here before sm-seo-plugin's go.mod changes. **Planner decisions recorded here:** -- **P33-37 (tables):** golem15_seo_crawls (id, status queued|running|finished|failed|cancelled, trigger admin|schedule|mcp, trigger_name, site, max_pages, pages, limit_hit, error, job_id, started_at, finished_at, created_at); golem15_seo_crawl_pages (crawl_id, url, status, location, content_type, title, description, canonical, robots, x_robots, h1 jsonb, headings jsonb, words, text (the page's main text as plain text, capped at 100 000 characters, read by orphan suggestions here and by E10 in 33-11), depth, in_sitemap, sitemap_file, links_out, links_in, fetched_at); golem15_seo_crawl_links (crawl_id, from_url, to_url, text, rel, internal); golem15_seo_issues (id, crawl_id null, source crawl|cms, kind, severity error|warning|notice, url, locale, details jsonb, tool, fix tool|code, fingerprint, found_at); golem15_seo_issue_ignores (fingerprint unique, admin_id, ignored_at). The fingerprint is kind + url + locale + the kind's identifying detail, so an ignore survives later crawls. -- **P33-38 (fix route):** `fix = tool` with the full tool name when a CMS value fixes it: SEO data → seo_set_url_seo, seo_update_robots, seo_create_redirect, seo_update_page; content → the record's ContentDescriptor.Tools (edit, headings, image_alt); otherwise `fix = code` with the template element named in details.what. A kind marked "MCP or Code" in C23 chooses per finding: content headings, links and JSON-LD inside the record's own content (descriptor Headings or body links, stored custom JSON-LD) are tool; elsewhere code. +- **P33-37 (tables):** golem15_seo_crawls (id, status queued|running|finished|failed|cancelled, trigger admin|schedule|mcp, trigger_name, site, max_pages, pages, limit_hit, error, render_status skipped|done|failed (default skipped), render_pages (default 0), render_note, job_id, started_at, finished_at, created_at); golem15_seo_crawl_pages (crawl_id, url, status, location, content_type, title, description, canonical, robots, x_robots, h1 jsonb, headings jsonb, words, text (the page's main text as plain text, capped at 100 000 characters, read by orphan suggestions here and by E10 in 33-11), depth, in_sitemap, sitemap_file, links_out, links_in, fetched_at); golem15_seo_crawl_links (crawl_id, from_url, to_url, text, rel, internal); golem15_seo_issues (id, crawl_id null, source crawl|cms, kind, severity error|warning|notice, url, locale, details jsonb, tool, fix tool|code, fingerprint, found_at); golem15_seo_issue_ignores (fingerprint unique, admin_id, ignored_at). The fingerprint is kind + url + locale + the kind's identifying detail, so an ignore survives later crawls. +- **P33-38 (fix route):** `fix = tool` with the full tool name when a CMS value fixes it: SEO data → seo_set_url_seo, seo_update_robots, seo_create_redirect, seo_update_page; content → the record's ContentDescriptor.Tools (edit, headings, image_alt); otherwise `fix = code` with the template element named in details.what. A kind marked "MCP or Code" in C23 chooses per finding: content headings, links and JSON-LD inside the record's own content (descriptor Headings or body links, stored custom JSON-LD) are tool when the record's type names a fitting tool (H1 findings prefer Tools[headings], other content findings use Tools[edit]); elsewhere, and for a content finding whose type names no fitting tool (Journal categories name only headings, so a skipped level in pillar text stays as before), code. This is the CONTEXT 2026-10-28 success criterion 3 reading: data issues name a tool, template issues carry fix code. - **P33-39 (one crawl at a time):** start refuses when a crawl is queued or running (row check in the start transaction with a row lock), since conga has no unique jobs. - **P33-40 (schedule):** pact.HasSchedule entry `{Command: "seo:crawl", Args: ["--scheduled"], Cadence: pact.DailyAt(2, 30)}`; with --scheduled the command does nothing when crawl_schedule is off and runs weekly crawls only on Mondays in the app's location. - **P33-41 (crawler identity):** User-Agent `SummerCMS-SEO-Crawler/1 (+<site_url>)`; robots.txt rules are not obeyed for the site's own audit (they are checked and reported instead). +- **P33-55 (rendered comparison, CONTEXT 2026-10-28 rendered comparison: optional chromedp):** + - Seam: `classes/crawl/render.go` defines `Renderer` (`Render(ctx, pageURL string) (string, error)`, `Close() error`); the crawl holds nil when the config key `golem15.seo.crawl_chrome_path` is empty and then records render_status skipped. The decision allows a setting or a config key; a config key (environment-overridable like every key) is used because an admin- or MCP-writable executable path would let any holder of golem15.seo.manage make the server run an arbitrary binary (T-33-69). + - Scope and limits: with a path, the crawler renders up to `golem15.seo.crawl_render_max` pages (default 200, the first in crawl order, so the home page and top-level pages come first) one tab at a time right after each page's raw fetch, 20 s per page, DOM read with chromedp's OuterHTML and capped at 5 MiB; the User-Agent is the P33-41 one. + - Comparison: `compareRendered(raw, rendered PageFacts) []Finding` in compare.go (pure, parse.go extracts the facts from both) reports content_js with details.check rendered and details.what naming same-host links present only after JavaScript (up to 5 paths), an H1 present only after JavaScript, or main text that grows by at least 50 words and 20 % after JavaScript. The raw checks against CMS expectations keep details.check cms; the fingerprint includes check, so the two never merge. + - Guard (render_chrome.go): before Chrome starts, the site host and its www twin are resolved and each address checked with fetchguard.IsPrivateAddr (refused unless golem15.seo.crawl_trusted); the checked addresses go into `--host-resolver-rules` as MAP lines after a leading `MAP * ~NOTFOUND`; Fetch-domain interception (`fetch.Enable`, `chromedp.ListenTarget` on `fetch.EventRequestPaused`) continues a request only when `allowRequest(url, hosts, trusted)` passes (https, http only in trusted mode, data: and blob: allowed, host in the list) and fails every other one with BlockedByClient; a fresh profile from os.MkdirTemp passed as the user data dir and removed in Close; downloads denied through `browser.SetDownloadBehavior`; chromedp's default allocator flags without additions that weaken the sandbox. Third-party scripts are therefore never loaded; the README and the note say so. + - Reporting: render_status done with render_pages, or failed with render_note (Chrome missing, a private address, a launch error); P6 shows one extra plain line like the staging one (`crawls.render_skipped`, `crawls.render_done`, `crawls.render_failed`); the crawl API and seo_get_crawl answer `render {status, pages, note}`. + - Copy (new keys, UI-SPEC C24 conventions): `crawls.render_skipped` EN "The comparison with the JavaScript-rendered pages was skipped: no Chrome path is configured (golem15.seo.crawl_chrome_path). The raw-HTML checks ran." PL "Porównanie ze stronami wyrenderowanymi przez JavaScript pominięto: nie skonfigurowano ścieżki do Chrome (golem15.seo.crawl_chrome_path). Sprawdzenia surowego HTML zostały wykonane."; `crawls.render_done` EN one ":count page was compared with its JavaScript-rendered version." other ":count pages were compared with their JavaScript-rendered versions." PL CLDR one/few/many/other "Porównano :count stronę/strony/stron/strony z wersją wyrenderowaną przez JavaScript."; `crawls.render_failed` EN "The comparison with the JavaScript-rendered pages stopped: :error. The raw-HTML checks ran." PL "Porównanie ze stronami wyrenderowanymi przez JavaScript zostało przerwane: :error. Sprawdzenia surowego HTML zostały wykonane." </objective> <execution_context> @@ -167,7 +208,8 @@ Output: commits in ../sm-seo-plugin. @CLAUDE.md <interfaces> -fetchguard: NewClient(Policy{Mode: AllowHostsMode, AllowHosts, MaxBytes, Timeout}, cfg) / TrustedMode; Client.Get(ctx, url, header) → Result{StatusCode, Header, Body}; 3xx returned, never followed; WithTransport(ctx, rt) for tests. +fetchguard: NewClient(Policy{Mode: AllowHostsMode, AllowHosts, MaxBytes, Timeout}, cfg) / TrustedMode; Client.Get(ctx, url, header) → Result{StatusCode, Header, Body}; 3xx returned, never followed; WithTransport(ctx, rt) for tests; IsPrivateAddr(netip.Addr) bool (the dial guard's classification, for the Chrome resolver pinning). +chromedp (Task 4, version from 33-06-SUMMARY.md): NewExecAllocator(ctx, opts...) with DefaultExecAllocatorOptions, ExecPath, UserDataDir, Flag("host-resolver-rules", …), UserAgent; NewContext, Run, Navigate, WaitReady, OuterHTML, ListenTarget, Cancel; cdproto packages fetch (Enable with RequestPattern, EventRequestPaused, ContinueRequest, FailRequest), network (ErrorReasonBlockedByClient), browser (SetDownloadBehavior deny). Chromium 154 is installed on the development machine (RESEARCH Environment Availability); production servers have none unless an operator installs it. golang.org/x/net/html (already in the dependency graph through sm-journal-plugin and the framework) for parsing; grobotstxt for robots checks (Phase 32). conga: Manager.Dispatch(ctx, db, args, DispatchOpts{Label, Count}), StartJob, UpdateJobState, CheckIfCanceled, CancelJob; conga.JobID(ctx). sm-seo-plugin: classes/issues.go (Issue{Kind, URL, Locale, Details, Tool}, CMS kinds), classes/schema (Build, Validate from 33-06), classes/sitemap_filter.go and contract.Listing (33-05), Service.Staging(), SiteURL(); controllers/notfound.go + config (record actions, bulk actions, row states pattern); controllers/history/_history_diff.htm (partial with summer-table); operations.go / operations_test.go; mcp.go. @@ -178,14 +220,15 @@ Copy: UI-SPEC C23 (issue kinds table: severities, fix routes, labels, details ce ## Execution units and resume points -- Commit groups staged by path in ../sm-seo-plugin; vet and `test -short` green at every commit; lang keys in both files per group. -- Resume check: `git -c safe.directory='*' -C ../sm-seo-plugin log --format=%s | grep -Fx '<subject>'`. +- Commit groups staged by path in ../sm-seo-plugin; vet and `test -short` green at every commit; lang keys in both files per group. Task 4's decision note and STACK row are a planning-docs commit here, staged by path, made before sm-seo-plugin's go.mod changes. +- Resume check: `git -c safe.directory='*' -C ../sm-seo-plugin log --format=%s | grep -Fx '<subject>'`; for the note, `git log --format=%s | grep -Fx 'docs(33-10): chromedp decision note and STACK row'` here. | Task | Commit groups, in order | Resume point | |------|-------------------------|--------------| | 1 | `feat: crawler finds a page with two H1 headings and stores the issue (tracer)` | TestCrawlerTracer passes | -| 2 | A `feat: crawler checks for status, sitemaps, canonical, titles and headings`; B `feat: crawler checks for JSON-LD, hreflang, links, images and raw HTML`; C `feat: orphan pages and merged CMS issues with fix routes` | TestCrawlerChecks, TestIssueMerge, TestStagingCrawl pass | -| 3 | A `feat: Site audit and Crawls screens`; B `feat: site audit settings and the nightly crawl`; C `feat: MCP tools for crawls and issues`; D `docs: crawler and site audit in README` | plan complete | +| 2 | A `feat: crawler checks for status, sitemaps, canonical, titles and headings`; B `feat: crawler checks for JSON-LD, hreflang, links, images, raw HTML and the rendered-comparison seam`; C `feat: orphan pages and merged CMS issues with fix routes` | TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestRenderSkipped pass | +| 3 | A `feat: Site audit and Crawls screens`; B `feat: site audit settings and the nightly crawl`; C `feat: MCP tools for crawls and issues`; D `docs: crawler and site audit in README` | admin, schedule and tool tests pass | +| 4 | here: A `docs(33-10): chromedp decision note and STACK row`; seo: B `feat: rendered comparison through headless Chrome` | TestChromeRenderer passes; plan complete | <tasks> @@ -216,8 +259,8 @@ Copy: UI-SPEC C23 (issue kinds table: severities, fix routes, labels, details ce </task> <task type="auto" tdd="true"> - <name>Task 2: The full set of crawl checks, orphan pages, merged CMS issues with fix routes, ignore fingerprints and staging suppression</name> - <files>../sm-seo-plugin/classes/crawl/checks.go, ../sm-seo-plugin/classes/crawl/links.go, ../sm-seo-plugin/classes/crawl/orphans.go, ../sm-seo-plugin/classes/crawl/parse.go, ../sm-seo-plugin/classes/crawl/fix.go, ../sm-seo-plugin/classes/crawl/store.go, ../sm-seo-plugin/classes/crawl/crawler.go, ../sm-seo-plugin/classes/issues.go, ../sm-seo-plugin/menu_item_types.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/crawler_test.go, ../sm-seo-plugin/crawl_site_test.go</files> + <name>Task 2: The full set of crawl checks, the rendered-comparison seam, orphan pages, merged CMS issues with fix routes, ignore fingerprints and staging suppression</name> + <files>../sm-seo-plugin/classes/crawl/checks.go, ../sm-seo-plugin/classes/crawl/links.go, ../sm-seo-plugin/classes/crawl/orphans.go, ../sm-seo-plugin/classes/crawl/parse.go, ../sm-seo-plugin/classes/crawl/fix.go, ../sm-seo-plugin/classes/crawl/store.go, ../sm-seo-plugin/classes/crawl/crawler.go, ../sm-seo-plugin/classes/crawl/render.go, ../sm-seo-plugin/classes/crawl/compare.go, ../sm-seo-plugin/classes/issues.go, ../sm-seo-plugin/menu_item_types.go, ../sm-seo-plugin/lang/en/lang.yaml, ../sm-seo-plugin/lang/pl/lang.yaml, ../sm-seo-plugin/crawler_test.go, ../sm-seo-plugin/crawl_site_test.go</files> <read_first> - ../sm-seo-plugin/classes/issues.go (CMS kinds and their tools), classes/schema/validate.go (33-06), classes/robots.go (grobotstxt matcher) - ../sm-sitemap-plugin/contract/contract.go (Listing for sitemap consistency) @@ -230,18 +273,20 @@ Copy: UI-SPEC C23 (issue kinds table: severities, fix routes, labels, details ce - jsonld_invalid for unparsable JSON, jsonld_multiple for two scripts, jsonld_required / jsonld_recommended from schema.Validate; FAQPage nodes have no rules - hreflang_reciprocal when /en/x lists /pl/x but /pl/x does not list /en/x; hreflang_error when an alternate answers 404 - image_alt for an img without alt (alt="" is fine), image_size without width/height, featured_lazy when the og:image's img is lazy - - content_js when the H1 lacks the descriptor title, when words < half of Words, or when a CMS-known link is absent from the raw HTML + - content_js (check cms) when the H1 lacks the descriptor title, when words < half of Words, or when a CMS-known link is absent from the raw HTML + - With a fake Renderer (test seam) whose rendered HTML adds a same-host link, an H1 or 80 words of text to a planted page, content_js (check rendered) names that link, the H1 or the word growth; an identical rendering adds nothing; the crawl row records render_status done and render_pages + - TestRenderSkipped: without golem15.seo.crawl_chrome_path the crawl finishes with every raw check, render_status skipped and no rendered finding; a Renderer whose Render fails records render_status failed with its note and the crawl still finishes - orphan_page for a sitemap page no crawled page links to, with suggestions (pages mentioning its title or focus keyphrase) - - TestIssueMerge: CMS issues are stored with source cms at the end of the crawl; image_alt (CMS) names the record type's Tools[image_alt]; h1_in_content appears for a record with TopHeading 2 and an H1 in Headings and not for TopHeading 0; every stored issue has tool or fix code + - TestIssueMerge: CMS issues are stored with source cms at the end of the crawl; image_alt (CMS) names the record type's Tools[image_alt]; h1_in_content appears for a record with TopHeading 2 and an H1 in Headings (a post and a category) and not for TopHeading 0, naming Tools[headings]; a heading_skipped inside a category's pillar text (type without an edit tool) carries fix code; every stored issue has tool or fix code - Ignored fingerprints stay ignored after a second crawl; a fixed page's issues are gone after the second crawl - TestStagingCrawl: with the staging lock every page carries noindex, and no noindex-derived kind is reported </behavior> <action> - Per D-14, D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24 and P33-38: implement each check in checks.go/links.go/orphans.go with severities and details per the C23 table; sitemap consistency reads the fetched sitemap files plus contract.Listing; JSON-LD uses schema.Validate on each parsed graph; title/description duplicates are computed over the crawl; heading checks compare levels in document order; content_js uses the descriptor of the page's record (MatchPath) for title, Words and CMS links; orphans use the stored link graph and the descriptor titles plus the SEO entries' focus keyphrases for suggestions (at most 10 each). issues.go gains the CMS kinds image_alt and h1_in_content (descriptor-driven, the latter only when TopHeading is 2) and stores CMS issues at crawl end. The seo-page descriptor gets Tools {edit: seo_update_page}. Fingerprints and the ignore table per P33-37; staging suppression per Pitfall 12. Every kind's label and summary keys of C23 in both lang files. Tests first, against the planted httptest site. Commit groups A, B, C. + Per D-14, D-46, D-51, D-70, D-71, D-81, D-82, D-85, D-24 and P33-38: implement each check in checks.go/links.go/orphans.go with severities and details per the C23 table; sitemap consistency reads the fetched sitemap files plus contract.Listing; JSON-LD uses schema.Validate on each parsed graph; title/description duplicates are computed over the crawl; heading checks compare levels in document order; content_js uses the descriptor of the page's record (MatchPath) for title, Words and CMS links (details.check cms). Per P33-55 and the CONTEXT 2026-10-28 rendered comparison, render.go adds the `Renderer` seam and the crawl's render bookkeeping (nil renderer → render_status skipped; Render errors → failed with the note, the crawl continues), compare.go the pure `compareRendered` over the PageFacts parse.go extracts from raw and rendered HTML, and crawler.go calls it after each raw fetch for the first crawl_render_max pages when a renderer exists; the tests inject a fake renderer through an unexported crawl option (the chromedp renderer arrives in Task 4). Orphans use the stored link graph and the descriptor titles plus the SEO entries' focus keyphrases for suggestions (at most 10 each). issues.go gains the CMS kinds image_alt and h1_in_content (descriptor-driven, the latter only when TopHeading is 2) and stores CMS issues at crawl end. The seo-page descriptor gets Tools {edit: seo_update_page}. fix.go applies P33-38 including the fitting-tool rule (H1 findings → Tools[headings], other content findings → Tools[edit], code when the type names none). Fingerprints and the ignore table per P33-37 (content_js includes details.check); staging suppression per Pitfall 12. Every kind's label and summary keys of C23 in both lang files. Tests first, against the planted httptest site. Commit groups A, B, C. </action> <verify> - <automated>go -C ../sm-seo-plugin test ./... -run 'TestCrawlerChecks|TestIssueMerge|TestStagingCrawl|TestCrawlerTracer|TestIssues' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated> - <fails_when>a "FAIL" line, "no tests to run", or TestCrawlerChecks, TestIssueMerge or TestStagingCrawl missing from the "--- PASS" lines</fails_when> + <automated>go -C ../sm-seo-plugin test ./... -run 'TestCrawlerChecks|TestIssueMerge|TestStagingCrawl|TestRenderSkipped|TestCrawlerTracer|TestIssues' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated> + <fails_when>a "FAIL" line, "no tests to run", or TestCrawlerChecks, TestIssueMerge, TestStagingCrawl or TestRenderSkipped missing from the "--- PASS" lines</fails_when> <automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated> <fails_when>non-zero exit or a "FAIL" line</fails_when> </verify> @@ -249,8 +294,10 @@ Copy: UI-SPEC C23 (issue kinds table: severities, fix routes, labels, details ce - `grep -c "issues.kind_" ../sm-seo-plugin/lang/en/lang.yaml` and the same in lang/pl/lang.yaml are each at least 33 - `grep -n "schema.Validate" ../sm-seo-plugin/classes/crawl/checks.go` prints at least one line - TestIssueMerge asserts that no stored issue has an empty tool together with fix "tool" + - `grep -n "func compareRendered(" ../sm-seo-plugin/classes/crawl/compare.go` and `grep -n "type Renderer interface" ../sm-seo-plugin/classes/crawl/render.go` each print one line + - this task adds no module to ../sm-seo-plugin/go.mod (the headless-browser dependency waits for Task 4's decision note) </acceptance_criteria> - <done>The audit covers every planned check, merges CMS issues and routes each issue to an MCP tool or to the site's code.</done> + <done>The audit covers every planned check, compares raw and rendered pages whenever a renderer exists and reports the comparison as skipped otherwise, merges CMS issues and routes each issue to an MCP tool or to the site's code.</done> </task> <task type="auto" tdd="true"> @@ -263,14 +310,14 @@ Copy: UI-SPEC C23 (issue kinds table: severities, fix routes, labels, details ce - .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C15, C20 Site audit tab, C23, C24, their copy, Destructive actions, MCP parity) </read_first> <behavior> - - TestCrawlAdmin: the Site audit list sorts errors first, hides ignored rows with the default status filter, shows source and fix columns; the preview renders P7 with the C23 sections; recheck re-fetches one URL and toasts issues.rechecked or recheck_fixed; ignore and bulk ignore set the fingerprint; unignore clears it; the Crawls list sorts newest first; the preview renders P6 with one status callout (running, queued, failed danger, cancelled, limit warning, staging extra); cancel keeps fetched pages; deleting a running crawl answers 403 crawls.delete_running + - TestCrawlAdmin: the Site audit list sorts errors first, hides ignored rows with the default status filter, shows source and fix columns; the preview renders P7 with the C23 sections; recheck re-fetches one URL and toasts issues.rechecked or recheck_fixed; ignore and bulk ignore set the fingerprint; unignore clears it; the Crawls list sorts newest first; the preview renders P6 with one status callout (running, queued, failed danger, cancelled, limit warning, staging extra) and the extra plain render line of P33-55 (render_skipped naming the config key, render_done with the count, render_failed with the note); GET /_seo/api/v1/crawls/{id} and seo_get_crawl answer render {status, pages, note}; cancel keeps fetched pages; deleting a running crawl answers 403 crawls.delete_running - Start crawl twice → the second refuses with crawls.already_running naming the start time; without a site URL → crawls.no_site_url - Settings Site audit tab validates the C20 ranges with validation.between; generic_anchors seeded with the en and pl lists - TestCrawlSchedule: seo:crawl --scheduled with crawl_schedule off does nothing, daily dispatches a crawl with trigger schedule, weekly dispatches only on Mondays; crawls beyond crawl_keep are deleted with their pages and issues - TestCrawlTools: the nine MCP tools are listed and bound to their routes; GET tools are read-only; TestEveryAdminOperationHasTool passes with both controllers and the toolbar actions </behavior> <action> - Per D-14, D-10, UI-SPEC C15/C20/C23/C24 and P33-39/P33-40: controllers `golem15.seo.issues` (Site audit: list → preview, toolbar action start_crawl, bulk action ignore, record actions recheck, ignore, unignore with Applies, row state disabled issues.state_ignored, filters severity, kind, source, fix, status default open) and `golem15.seo.crawls` (Crawls: list → preview, toolbar [start_crawl, delete], record action cancel, filters status and started_at daterange); P6 `_crawl_summary.htm` and P7 `_issue_details.htm` built from curated view models in classes/partials.go with the kit classes only; navigation order of C15 (Site audit icon gauge, Crawls icon globe after Pages; Auto-links comes in 33-11). Migration 202610280006 adds crawl_schedule, crawl_max_pages, crawl_delay, crawl_keep, generic_anchors with the four-place settings pattern and the Site audit tab. Management routes: `GET /_seo/api/v1/crawls`, `GET /_seo/api/v1/crawls/{id}`, `POST /_seo/api/v1/crawls/{id}/cancel`, `DELETE /_seo/api/v1/crawls` (ids), `POST /_seo/api/v1/issues/{id}/recheck`, `POST /_seo/api/v1/issues/ignore`, `POST /_seo/api/v1/issues/unignore` and filters on `GET /issues`; MCP tools start_crawl, cancel_crawl, list_crawls, get_crawl, delete_crawls (destructive), list_issues, recheck_url, ignore_issues, unignore_issues with AdminOperations rows. schedule.go per P33-40 and `seo:crawl [--scheduled]` in commands.go (documented). README: crawler, limits, trusted mode, checks, fix routes, screens, tools, schedule. Every C23/C24/C20 key in both lang files. Tests first. Commit groups A to D. + Per D-14, D-10, UI-SPEC C15/C20/C23/C24 and P33-39/P33-40: controllers `golem15.seo.issues` (Site audit: list → preview, toolbar action start_crawl, bulk action ignore, record actions recheck, ignore, unignore with Applies, row state disabled issues.state_ignored, filters severity, kind, source, fix, status default open) and `golem15.seo.crawls` (Crawls: list → preview, toolbar [start_crawl, delete], record action cancel, filters status and started_at daterange); P6 `_crawl_summary.htm` (with the P33-55 render line as an extra plain paragraph, like the staging one) and P7 `_issue_details.htm` built from curated view models in classes/partials.go with the kit classes only; navigation order of C15 (Site audit icon gauge, Crawls icon globe after Pages; Auto-links comes in 33-11). Migration 202610280006 adds crawl_schedule, crawl_max_pages, crawl_delay, crawl_keep, generic_anchors with the four-place settings pattern and the Site audit tab. Management routes: `GET /_seo/api/v1/crawls`, `GET /_seo/api/v1/crawls/{id}`, `POST /_seo/api/v1/crawls/{id}/cancel`, `DELETE /_seo/api/v1/crawls` (ids), `POST /_seo/api/v1/issues/{id}/recheck`, `POST /_seo/api/v1/issues/ignore`, `POST /_seo/api/v1/issues/unignore` and filters on `GET /issues`; MCP tools start_crawl, cancel_crawl, list_crawls, get_crawl, delete_crawls (destructive), list_issues, recheck_url, ignore_issues, unignore_issues with AdminOperations rows. schedule.go per P33-40 and `seo:crawl [--scheduled]` in commands.go (documented). README: crawler, limits, trusted mode, checks, fix routes, screens, tools, schedule (the rendered comparison section follows in Task 4). Every C23/C24/C20 key and the P33-55 render keys in both lang files. Tests first. Commit groups A to D. </action> <verify> <automated>go -C ../sm-seo-plugin test ./... -run 'TestCrawlAdmin|TestCrawlSchedule|TestCrawlTools|TestEveryAdminOperationHasTool|TestMCPReadToolsAreGET|TestCrawlerChecks' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -30</automated> @@ -282,10 +329,48 @@ Copy: UI-SPEC C23 (issue kinds table: severities, fix routes, labels, details ce - `grep -n '"start_crawl"\|"cancel_crawl"\|"list_crawls"\|"get_crawl"\|"delete_crawls"\|"recheck_url"\|"ignore_issues"\|"unignore_issues"' ../sm-seo-plugin/mcp.go` prints eight lines - `grep -n "seo:crawl" ../sm-seo-plugin/schedule.go ../sm-seo-plugin/commands.go` prints a line in each - `grep -n "golem15.seo.issues\|golem15.seo.crawls" ../sm-seo-plugin/admin_navigation.go` prints two lines + - `grep -c "render_skipped\|render_done\|render_failed" ../sm-seo-plugin/lang/en/lang.yaml` and the same in lang/pl/lang.yaml are each at least 3 </acceptance_criteria> <done>Editors and MCP clients can run, follow, read, recheck and ignore the audit; it also runs on a schedule.</done> </task> +<task type="auto" tdd="true"> + <name>Task 4: Rendered comparison through headless Chrome: chromedp decision note, the guarded renderer and its configuration</name> + <files>.planning/notes/2026-10-28-chromedp-rendered-crawl.md, .planning/research/STACK.md, ../sm-seo-plugin/go.mod, ../sm-seo-plugin/go.sum, ../sm-seo-plugin/classes/crawl/render.go, ../sm-seo-plugin/classes/crawl/render_chrome.go, ../sm-seo-plugin/config/config.yaml, ../sm-seo-plugin/README.md, ../sm-seo-plugin/crawl_render_test.go</files> + <read_first> + - .planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md (top: the approved chromedp version from 33-06 Task 4) + - .planning/notes/2026-10-27-robots-txt-grobotstxt.md (decision note format) and .planning/research/STACK.md (row format) + - ../sm-seo-plugin/classes/crawl/render.go, crawler.go and compare.go (Task 2 seam), classes/crawl/fetch.go (host list, trusted mode, User-Agent) + - summercms.go/modules/fetchguard/README.md and ip.go (IsPrivateAddr) + - .planning/phases/33-seo-output-audit-and-content/33-RESEARCH.md (Alternatives considered: chromedp row; Environment Availability: Chromium) + </read_first> + <precondition>33-06 Task 4 answered "chromedp: approved vX.Y.Z" (recorded at the top of 33-06-SUMMARY.md); on "rejected", record that in this plan's SUMMARY, leave the Task 2 seam reporting the comparison as skipped, and stop this task</precondition> + <behavior> + - TestChromeRenderer (skipped under -short; runs when SEO_TEST_CHROME names a binary or chromium, chromium-browser or google-chrome is on PATH): against an httptest site in trusted mode, a page whose script adds a same-host link /js-only and two paragraphs renders with them, and compareRendered reports content_js check rendered naming /js-only; the page's third-party script (https://cdn.example.test/x.js) and a fetch to http://169.254.169.254/ are recorded as blocked and never reach a server; the temporary profile directory no longer exists after Close + - In guarded mode a site host that resolves to a private address makes the renderer refuse to start (render_status failed, note names the private address) without launching Chrome + - An empty or relative golem15.seo.crawl_chrome_path, or a path that is not an executable regular file, gives render_status skipped (empty) or failed (invalid) and the crawl still finishes with its raw checks + </behavior> + <action> + Per D-70, the CONTEXT 2026-10-28 rendered comparison (optional chromedp; a decision note before go.mod changes) and P33-55, in this order: + - (A, here, before any go.mod change) write `.planning/notes/2026-10-28-chromedp-rendered-crawl.md`: the decision (raw-HTML checks always; the raw-versus-rendered comparison only when golem15.seo.crawl_chrome_path is configured, otherwise reported as skipped), the dependency github.com/chromedp/chromedp at the version approved in 33-06 Task 4, why (a maintained pure-Go DevTools client, no cgo and no Node runtime; RESEARCH Alternatives considered), that Chrome or Chromium is installed by the operator and is absent on servers by default, the P33-55 guard and limits (resolver pinning with fetchguard.IsPrivateAddr, request interception, temporary profile, no credentials, timeouts, one tab, the executable path only from config and why), that third-party scripts are not loaded during rendering, and that only sm-seo-plugin requires it (the framework is untouched, D-90); add the STACK.md row; commit as planning docs. + - (B, ../sm-seo-plugin) `go get github.com/chromedp/chromedp@<approved>`. render.go: `NewRenderer(cfg, siteHosts, trusted)` reads crawl_chrome_path (empty → nil, skipped) and crawl_render_max (default 200), validates the path (absolute, an executable regular file, else failed with the note), and holds the pure helpers `allowRequest(rawURL string, hosts []string, trusted bool) bool` and `resolverRules(hosts map[string][]netip.Addr) string` (leading `MAP * ~NOTFOUND`, then one MAP line per allowed host). render_chrome.go: `chromeRenderer` resolving the site host and its www twin with net.DefaultResolver.LookupNetIP and refusing a private address unless trusted (fetchguard.IsPrivateAddr), building the allocator from chromedp.DefaultExecAllocatorOptions plus ExecPath, UserDataDir (os.MkdirTemp, removed in Close), Flag host-resolver-rules and the P33-41 UserAgent, and nothing that weakens Chrome's sandbox; per page a child context with a 20 s timeout, fetch.Enable for every request with chromedp.ListenTarget handling fetch.EventRequestPaused (ContinueRequest when allowRequest passes, FailRequest with network.ErrorReasonBlockedByClient otherwise, each blocked URL kept for the render note's count), browser.SetDownloadBehavior deny, Navigate, WaitReady body, OuterHTML of html capped at 5 MiB; Close cancels the allocator and removes the profile; crawler.go builds it when a crawl starts and closes it on finish, failure and CheckIfCanceled. config/config.yaml documents `crawl_chrome_path` (empty, commented: optional, operator-installed Chrome or Chromium) and `crawl_render_max: 200`. README: a Rendered comparison section (what is compared, the two keys, the guard, third-party scripts not loaded, render status in P6 and MCP) and the dependency line. `crawl_render_test.go` per the behavior list (tests first). `go vet ./...` and `go test -short ./...` green; commit B. + </action> + <verify> + <automated>go -C ../sm-seo-plugin test ./... -run 'TestChromeRenderer|TestRenderSkipped|TestCrawlerChecks' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -15</automated> + <fails_when>a "FAIL" line, "no tests to run", or TestChromeRenderer missing from the "--- PASS" lines (a SKIP fails this check: Chromium is installed on the development machine, RESEARCH Environment Availability)</fails_when> + <automated>go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...</automated> + <fails_when>non-zero exit or a "FAIL" line</fails_when> + </verify> + <acceptance_criteria> + - the note's commit is older than the first sm-seo-plugin commit that adds chromedp to go.mod: `git log -1 --format=%ct -- .planning/notes/2026-10-28-chromedp-rendered-crawl.md` prints a smaller number than the last line of `git -C ../sm-seo-plugin log --format=%ct -S chromedp -- go.mod` (newest first, so the last line is the first such commit) + - `grep -n "github.com/chromedp/chromedp" ../sm-seo-plugin/go.mod .planning/research/STACK.md .planning/notes/2026-10-28-chromedp-rendered-crawl.md` prints a line in each + - `grep -n "host-resolver-rules" ../sm-seo-plugin/classes/crawl/render_chrome.go` and `grep -n "fetchguard.IsPrivateAddr" ../sm-seo-plugin/classes/crawl/render_chrome.go` each print at least one line + - `grep -n "no-sandbox" ../sm-seo-plugin/classes/crawl/render_chrome.go` prints nothing + - `grep -n "crawl_chrome_path\|crawl_render_max" ../sm-seo-plugin/config/config.yaml` prints two lines, and `grep -rn "crawl_chrome_path" ../sm-seo-plugin/models ../sm-seo-plugin/classes/settings_write.go` prints nothing (config only, never a setting) + </acceptance_criteria> + <done>With a Chrome path configured, crawls compare raw and JavaScript-rendered pages inside the same host and address limits as fetchguard; without one they say the comparison was skipped.</done> +</task> + </tasks> <threat_model> @@ -294,6 +379,7 @@ Copy: UI-SPEC C23 (issue kinds table: severities, fix routes, labels, details ce | Boundary | Description | |----------|-------------| | crawler → the site (and anything its pages link to) | outbound HTTP driven by untrusted page content | +| headless Chrome → the site and whatever its scripts request | a browser executing untrusted page JavaScript on the server | | crawled HTML → issue details → admin partials and MCP | untrusted text displayed to administrators | | admin/MCP → crawl actions | authenticated operations | @@ -308,31 +394,40 @@ Copy: UI-SPEC C23 (issue kinds table: severities, fix routes, labels, details ce | T-33-47 | Elevation of privilege | crawl and issue routes | high | mitigate | golem15.seo.manage on every route and action (manage wrapper), TestCrawlTools and the operations parity | | T-33-48 | Information disclosure | crawling staging | low | mitigate | trusted mode needed for http staging; staging-lock issues suppressed, P6 says why | | T-33-49 | Tampering | ignore fingerprints | low | accept | administrators may hide issues; unignore restores them and the ignore records author and time | -| T-33-SC | Tampering | npm/pip/cargo installs | high | accept | no new package (x/net/html and grobotstxt are already required) | +| T-33-66 | Elevation of privilege (SSRF) | headless Chrome in the rendered comparison | high | mitigate | Chrome resolves only the site host and its www twin, pinned through --host-resolver-rules to addresses checked with fetchguard.IsPrivateAddr (private refused unless golem15.seo.crawl_trusted) with every other host mapped to NOTFOUND; every request is intercepted and failed unless https (http only in trusted mode) to an allowed host; TestChromeRenderer plants a third-party script and a link-local fetch and asserts both blocked | +| T-33-67 | Denial of service | Chrome CPU and memory on the server | medium | mitigate | one tab at a time, at most crawl_render_max pages (default 200), 20 s per page, DOM capped at 5 MiB, Chrome closed on finish, failure and cancel; a renderer failure marks render_status failed and never fails the crawl | +| T-33-68 | Information disclosure | browser state during rendering | medium | mitigate | a fresh temporary profile per crawl, removed in Close; no cookies, Authorization header, HTTP credentials or extensions; downloads denied; the crawler never signs in | +| T-33-69 | Elevation of privilege | Chrome executable path | high | mitigate | Go config key only (golem15.seo.crawl_chrome_path, from config files or the environment), never an admin setting or MCP-writable value; absolute path to an executable regular file; Chrome's own sandbox stays on (chromedp default flags, nothing added that disables it) | +| T-33-SC | Tampering | npm/pip/cargo/Go installs | high | mitigate | github.com/chromedp/chromedp is [ASSUMED] in RESEARCH's audit and gated by 33-06's blocking human legitimacy checkpoint (Task 4); the decision note and STACK row are committed before go.mod changes, and the approved version is pinned exactly in go.sum; x/net/html and grobotstxt are already required | </threat_model> <verification> - `go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./...` after every commit. -- Docker suite: TestCrawlerTracer, TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestCrawlAdmin, TestCrawlSchedule, TestCrawlTools and the earlier suites. +- Docker suite: TestCrawlerTracer, TestCrawlerChecks, TestIssueMerge, TestStagingCrawl, TestRenderSkipped, TestCrawlAdmin, TestCrawlSchedule, TestCrawlTools and the earlier suites. +- With Chromium on the machine: TestChromeRenderer passes (not skipped). </verification> <success_criteria> - The crawler audits the rendered site within its limits and stays on the site's host. +- Raw-HTML checks always run; with a Chrome path the raw and JavaScript-rendered pages are compared under the same host and address limits, and without one every crawl says the comparison was skipped (D-70). - Every issue is stored per URL with a severity and either a fixing tool or fix code. - Site audit, Crawls, the settings tab, the schedule and nine MCP tools work. </success_criteria> ## Artifacts this phase produces -- Package `classes/crawl`: crawler, fetch, parse, checks, links, orphans, store, fix. -- Tables: `golem15_seo_crawls`, `golem15_seo_crawl_pages`, `golem15_seo_crawl_links`, `golem15_seo_issues`, `golem15_seo_issue_ignores`; settings `crawl_schedule`, `crawl_max_pages`, `crawl_delay`, `crawl_keep`, `generic_anchors`; migrations 202610280005, 202610280006. +- Package `classes/crawl`: crawler, fetch, parse, checks, links, orphans, store, fix, render (`Renderer`, `NewRenderer`, `allowRequest`, `resolverRules`), render_chrome (`chromeRenderer`), compare (`compareRendered`). +- Dependency: `github.com/chromedp/chromedp` (sm-seo-plugin go.mod only); note `.planning/notes/2026-10-28-chromedp-rendered-crawl.md` and a STACK.md row. +- Config keys: `golem15.seo.crawl_chrome_path` (empty: comparison skipped), `golem15.seo.crawl_render_max` (200). +- Lang keys: `crawls.render_skipped`, `crawls.render_done`, `crawls.render_failed`. +- Tables: `golem15_seo_crawls` (with `render_status`, `render_pages`, `render_note`), `golem15_seo_crawl_pages`, `golem15_seo_crawl_links`, `golem15_seo_issues`, `golem15_seo_issue_ignores`; settings `crawl_schedule`, `crawl_max_pages`, `crawl_delay`, `crawl_keep`, `generic_anchors`; migrations 202610280005, 202610280006. - Job kind `golem15.seo.crawl` (`CrawlArgs`); CLI `seo:crawl [--scheduled]`; schedule entry daily 02:30. - Issue kinds (crawl): `page_status`, `sitemap_error`, `sitemap_noindex`, `sitemap_missing`, `broken_link`, `redirected_link`, `canonical_missing`, `canonical_error`, `h1_missing`, `h1_multiple`, `heading_skipped`, `heading_empty`, `h1_title_mismatch`, `jsonld_invalid`, `jsonld_multiple`, `jsonld_required`, `jsonld_recommended`, `hreflang_reciprocal`, `hreflang_error`, `generic_anchor`, `image_alt`, `image_size`, `featured_lazy`, `content_js`, `orphan_page`; CMS additions `image_alt`, `h1_in_content`. - Admin: controllers `golem15.seo.issues` (Site audit), `golem15.seo.crawls` (Crawls); partials `issue_details` (P7), `crawl_summary` (P6); Site audit settings tab. - Routes: `POST/GET/DELETE /_seo/api/v1/crawls`, `GET /_seo/api/v1/crawls/{id}`, `POST /_seo/api/v1/crawls/{id}/cancel`, `POST /_seo/api/v1/issues/{id}/recheck`, `POST /_seo/api/v1/issues/ignore`, `POST /_seo/api/v1/issues/unignore`; `GET /_seo/api/v1/issues` extended. - MCP tools: `seo_start_crawl`, `seo_cancel_crawl`, `seo_list_crawls`, `seo_get_crawl`, `seo_delete_crawls`, `seo_list_issues` (extended), `seo_recheck_url`, `seo_ignore_issues`, `seo_unignore_issues`. - Config key: `golem15.seo.crawl_trusted`. -- Tests: `TestCrawlerTracer`, `TestCrawlerChecks`, `TestIssueMerge`, `TestStagingCrawl`, `TestCrawlAdmin`, `TestCrawlSchedule`, `TestCrawlTools`; test site helper `crawl_site_test.go`. +- Tests: `TestCrawlerTracer`, `TestCrawlerChecks`, `TestIssueMerge`, `TestStagingCrawl`, `TestRenderSkipped`, `TestCrawlAdmin`, `TestCrawlSchedule`, `TestCrawlTools`, `TestChromeRenderer` (`crawl_render_test.go`); test site helper `crawl_site_test.go`; unit tests for compareRendered, allowRequest and resolverRules follow in 33-14. <output> Create `.planning/phases/33-seo-output-audit-and-content/33-10-SUMMARY.md` when done. diff --git a/.planning/phases/33-seo-output-audit-and-content/33-11-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-11-PLAN.md index 9644d9be..a3c17737 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-11-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-11-PLAN.md @@ -39,10 +39,8 @@ files_modified: - ../sm-seo-plugin/lang/pl/lang.yaml - ../sm-seo-plugin/README.md - ../sm-seo-plugin/linking_test.go - - ../sm-journal-plugin/classes/related.go - ../sm-journal-plugin/classes/image_variants.go - ../sm-journal-plugin/controllers/api/posts.go - - ../sm-journal-plugin/controllers/api/posts_helpers.go - ../sm-journal-plugin/console/image_variants.go - ../sm-journal-plugin/models/image_variant.go - ../sm-journal-plugin/models/registry.go @@ -74,13 +72,13 @@ files_modified: - go.sum - .planning/notes/2026-10-28-webp-gen2brain.md - .planning/research/STACK.md -autonomous: false +autonomous: true requirements: [SC-2, SC-3, D-82, D-85, D-71, D-10] estimate: - tokens: 180000 - raw_tokens: 180000 - tasks: 5 + tokens: 165000 + raw_tokens: 165000 + tasks: 4 confidence: low must_haves: @@ -89,8 +87,7 @@ must_haves: - "Add focus keyphrases (toolbar action on Auto-links, also an MCP tool) creates one row per record whose focus keyphrase in a locale is not yet a phrase there, with the record's URL and origin keyphrase, and toasts the count (D-82, UI-SPEC C22)" - "The SEO tab shows E10 Internal links for content with a body: inbound and outbound link counts from the last finished crawl, the orphan warning when nothing links to the page, up to 10 pages this text could link to and 10 pages that could link here (titles and focus keyphrases found in the crawled text), each with Copy address; before the first crawl it says so (D-82, UI-SPEC C19)" - "MCP has seo_list_autolinks, seo_create_autolink, seo_update_autolink, seo_delete_autolinks, seo_autolinks_from_keyphrases and seo_get_link_suggestions; TestEveryAdminOperationHasTool maps the Auto-links controller and the seo_links widget action (D-10)" - - "Journal related posts stay as today by default (same category, three, newest first); with golem15.journal.related_mode scored they are 3 to 6 (golem15.journal.related_count) ranked by shared tags and categories, and the post answer adds previous_in_category and next_in_category, which the journal layer renders as server-rendered links instead of the site-wide neighbours (D-82, RESEARCH Open Question 6: opt-in, porting contract kept)" - - "WebP is encoded in Go with github.com/gen2brain/webp, recorded first in .planning/notes/2026-10-28-webp-gen2brain.md (with the 2026-10-28 benchmark) and a STACK.md row, then added to go.mod; the encoder lives in the framework sub-package modules/lagoon/attach/webp (generic media, additive, no SEO wording), so only binaries that import it link it (2026-10-28 image formats, D-85, D-90, D-106)" + - "WebP is encoded in Go with github.com/gen2brain/webp at the version approved at 33-06's legitimacy checkpoint, recorded first in .planning/notes/2026-10-28-webp-gen2brain.md (with the 2026-10-28 benchmark) and a STACK.md row, then added to go.mod; the encoder lives in the framework sub-package modules/lagoon/attach/webp (generic media, additive, no SEO wording), so only binaries that import it link it (CONTEXT 2026-10-28 image formats, D-85, D-90, D-106)" - "A Journal conga job (golem15.journal.image_variants), dispatched after a featured image upload commits and runnable for all images with journal:image-variants --all, writes thumbnails at golem15.journal.image_widths (default 480, 800, 1200) and a WebP variant of each next to the thumbnail; the upload request never encodes (2026-10-28 image formats)" - "The Journal API's featured_images entries gain variants [{width, height, url, webp_url}] (additive key), and the journal layer renders featured images as <picture> with a WebP source and the original format as srcset fallback, explicit width and height, alt, lazy loading in cards and eager loading with fetchpriority high for the post's main image (D-85, D-71)" - "AVIF is not produced (deferred); body images inserted through the editor keep their original file (the variants follow the thumbnail pipeline of attachments)" @@ -147,14 +144,14 @@ must_haves: **As a** site editor and as an SEO specialist auditing a SummerCMS site, **I want to** have the rendered sites emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, **so that** sites are indexed correctly and issues can be fixed through MCP. -This plan adds smart internal linking (auto-links, link suggestions with counters, related posts and neighbours in the category) and modern image formats (WebP variants with srcset). +This plan adds smart internal linking (auto-links, link suggestions with counters) and modern image formats (WebP variants with srcset). The opt-in related posts and category neighbours moved to 33-09 (Task 4) in the 2026-10-28 revision, and the WebP package-legitimacy checkpoint to 33-06 (Task 4), so this plan stays within its task budget and no task spans more than two repositories. <objective> -Across four repositories (each main, not pushed; tags in 33-14): +Across four repositories (each main, not pushed; tags in 33-14), at most two per task: - sm-seo-plugin: the Auto-links table and screen with the keyphrase feed and MCP tools, autolinks in the resolve answer, E10 and its tool (D-82, D-10); -- sm-journal-plugin: opt-in scored related posts and category neighbours (D-82), the image variants job and the API's variants (2026-10-28 image formats, D-85); - summercms.go: the WebP encoder in `modules/lagoon/attach/webp` after the decision note (D-85, D-90, D-106); -- vue-journal-layer: `<picture>` with WebP and the original as fallback, category neighbours (D-85, D-71, D-82). +- sm-journal-plugin: the image variants job and the API's variants (CONTEXT 2026-10-28 image formats, D-85); +- vue-journal-layer: `<picture>` with WebP and the original as fallback (D-85, D-71). Purpose: the internal-linking and media parts of D-82 and D-85 that the earlier plans do not cover. @@ -163,7 +160,7 @@ Output: commits in the four repositories; the decision note and STACK row as a p **Planner decisions recorded here:** - **P33-42 (where WebP lives):** in the framework as a generic, additive media helper (`modules/lagoon/attach/webp`, package `webp`: `Encode(w io.Writer, img image.Image) error` at quality 80 and `Variant(ctx, bucket, f *attach.File, w, h int, mode string) (key string, err error)` writing `<thumb key>.webp` next to the thumbnail), with a generic hook in attach (`attach.VariantKey(thumbKey, format string) string`). It is not SEO code (D-90), it is a patch-level addition (D-106), and as a separate sub-package only binaries that import it link the encoder. The job lives in Journal because only plugins register conga jobs. - **P33-43 (variants table):** `golem15_journal_image_variants` (file_id, width, height, key, webp_key, created_at; unique (file_id, width)) so the API lists variants with one query instead of storage lookups. -- **P33-44 (related options):** config keys `golem15.journal.related_mode` (category | scored, default category) and `golem15.journal.related_count` (3 to 6, default 3); the existing previous_post/next_post keys keep their meaning; previous_in_category and next_in_category (primary = first category) are added only in scored mode. +- **P33-44 (related options):** moved to 33-09 with the related-posts task. - **P33-45 (auto-link table):** `golem15_seo_autolinks` (id, phrase, url, locale null = all, enabled, origin manual|keyphrase|mcp, note, timestamps; unique (lower(phrase), coalesce(locale, ''))); settings column autolink_max (default 3) on the Links tab; the resolve answer filters rows by locale, enabled and target ≠ the page's path, ordered by phrase length descending so longer phrases win. </objective> @@ -179,15 +176,16 @@ Output: commits in the four repositories; the decision note and STACK row as a p @.planning/phases/33-seo-output-audit-and-content/33-PATTERNS.md @.planning/phases/33-seo-output-audit-and-content/33-10-SUMMARY.md @.planning/phases/33-seo-output-audit-and-content/33-09-SUMMARY.md +@.planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md @CLAUDE.md <interfaces> framework modules/lagoon/attach/thumb.go: ThumbFilename(id, w, h, offsetX, offsetY, mode, ext), PartitionDirectory, (*File).ThumbKey(ctx, bucket, w, h, mode) (key, error), encodeImage package var (119-130) switched on ext, content-type switch ~222-228, deleteKey on failure, bucket.Exists; thumb_test.go. sm-seo-plugin: Links tab and settings_write.go four-place pattern (33-05), resolve Result (33-05/06 keys), crawl tables golem15_seo_crawl_pages (text), golem15_seo_crawl_links (33-10), entries' focus keyphrase (Phase 32 SEO tab), SEO tab widgets (seo_snippet, seo_graph) and the element bundle (33-06), operations.go/mcp.go. -sm-journal-plugin: controllers/api/posts.go Show (related_posts via relatedPosts, previous_post/next_post via siblingPost), featuredImages (posts.go ~629), UploadFeaturedImage (posts.go ~357-430), jobs (none yet; follow ../sm-seo-plugin/jobs.go), config/config.yaml (33-02). -vue-journal-layer: FeaturedImage.vue, PostCard.vue (33-09), PostPage.vue siblings nav (51-66), utils/types.ts (JournalPostSibling, featured_images). +sm-journal-plugin: controllers/api/posts.go featuredImages (~629), UploadFeaturedImage (~357-430), jobs (none yet; follow ../sm-seo-plugin/jobs.go), config/config.yaml (33-02, related keys from 33-09), related_media_test.go (33-09). +vue-journal-layer: FeaturedImage.vue, PostCard.vue and PostPage.vue (33-09), utils/types.ts (featured_images). Benchmark (33-CONTEXT, 2026-10-28): 1200 px from a 2592×1754 photo, WebP 78 KB against JPEG q80 133 KB; about 90 ms with system libwebp loaded through purego (no cgo), about 510 ms in the WASM fallback; AVIF only about 6% smaller at 1.3× to 20× the CPU. -Package (33-RESEARCH audit): github.com/gen2brain/webp v0.6.4 (2026-07-01, 31 versions, source github.com/gen2brain/webp), [ASSUMED] — legitimacy checked at Task 4. +Package (33-RESEARCH audit): github.com/gen2brain/webp v0.6.4 (2026-07-01, 31 versions, source github.com/gen2brain/webp), [ASSUMED]; legitimacy verified at 33-06 Task 4, the approved version at the top of 33-06-SUMMARY.md. Copy: UI-SPEC C19 (E10), C22 (Auto-links) with their copy tables, C20 Links tab (autolink_max). </interfaces> </context> @@ -201,9 +199,8 @@ Copy: UI-SPEC C19 (E10), C22 (Auto-links) with their copy tables, C20 Links tab |------|-------------------------|--------------| | 1 | seo: `feat: auto-link phrases in the resolve answer (tracer)` | TestAutolinksTracer passes | | 2 | seo: A `feat: auto-links screen, keyphrase feed and MCP tools`; B `feat: internal links widget with counts and suggestions` | linking tests and the operations parity pass | -| 3 | journal: `feat: opt-in scored related posts and category neighbours`; layer: `feat: category neighbours on the post page` | related tests pass | -| 4 | none (checkpoint answer recorded in the SUMMARY) | answer recorded | -| 5 | here: A `docs(33-11): WebP decision note and STACK row`; framework: B `feat(lagoon): WebP variants of attachment thumbnails`; journal: C `feat: image variants job and variants in the post API`; layer: D `feat: WebP and srcset for featured images` | plan complete | +| 3 | here: A `docs(33-11): WebP decision note and STACK row`; framework: B `feat(lagoon): WebP variants of attachment thumbnails` | the webp package tests and the additive gate pass | +| 4 | journal: A `feat: image variants job and variants in the post API`; layer: B `feat: WebP and srcset for featured images` | plan complete | <tasks> @@ -264,79 +261,69 @@ Copy: UI-SPEC C19 (E10), C22 (Auto-links) with their copy tables, C20 Links tab <done>Editors manage auto-links and see linking suggestions with counters in the SEO tab; MCP can do the same.</done> </task> -<task type="auto" tdd="true"> - <name>Task 3: Opt-in scored related posts and category neighbours (Journal API and layer)</name> - <files>../sm-journal-plugin/classes/related.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/controllers/api/posts_helpers.go, ../sm-journal-plugin/config/config.yaml, ../sm-journal-plugin/README.md, ../sm-journal-plugin/related_media_test.go, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files> - <read_first> - - ../sm-journal-plugin/controllers/api/posts_helpers.go (relatedPosts 152-172, siblingPost 122-148) and posts.go Show (147-154) - - ../vue-journal-layer/app/journal-pages/PostPage.vue (siblings nav 51-66) and app/utils/types.ts - </read_first> - <behavior> - - Default mode: Show answers exactly as before (journal_api_matrix_test.go unchanged) - - related_mode scored, related_count 5: up to 5 published posts other than the post, ranked by shared tags (2 points each) plus shared categories (1 point each), ties by newest; count outside 3–6 is clamped; previous_in_category and next_in_category are the neighbours by published_at within the post's first category - - The layer renders previous_in_category/next_in_category links (server-rendered <a href> with the title in the anchor text) when present, else the existing site-wide neighbours - </behavior> - <action> - Per D-82 and P33-44: `classes/related.go` with the scoring query (one SQL with counts, published only for non-editors) and the category neighbours; Show uses it only in scored mode and adds the two keys; config/config.yaml documents both keys; README section. Layer PostPage prefers the in-category neighbours; types.ts adds them; a fixture post with them and an expectation line. Tests first (`TestRelatedScored`, `TestRelatedDefaultUnchanged` in related_media_test.go). Commit as listed. - </action> - <verify> - <automated>go -C ../sm-journal-plugin test ./... -run 'TestRelatedScored|TestRelatedDefaultUnchanged|TestJournalAPIMatrix' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated> - <fails_when>a "FAIL" line, "no tests to run", or TestRelatedScored missing from the "--- PASS" lines</fails_when> - <automated>npm --prefix ../vue-journal-layer run test 2>&1 | tail -6</automated> - <fails_when>non-zero exit, or a "# fail" count above 0 or a "not ok" line</fails_when> - </verify> - <acceptance_criteria> - - `grep -n "golem15.journal.related_mode\|golem15.journal.related_count" ../sm-journal-plugin/classes/related.go ../sm-journal-plugin/config/config.yaml` prints at least two lines - - `grep -n "previous_in_category" ../vue-journal-layer/app/journal-pages/PostPage.vue` prints at least one line - </acceptance_criteria> - <done>Sites can switch on richer related posts and category neighbours without changing the default answer.</done> -</task> - -<task type="checkpoint:human-verify" gate="blocking-human"> - <name>Task 4: Package legitimacy of github.com/gen2brain/webp before it enters go.mod</name> - <what-built>Nothing is installed yet. 33-RESEARCH.md's Package Legitimacy Audit marks github.com/gen2brain/webp [ASSUMED]: the npm/pypi/crates seam cannot check Go modules, so only proxy.golang.org existence was confirmed (v0.6.4, 2026-07-01, 31 versions since v0.1.0 in 2024-03). The 2026-10-28 decision (33-CONTEXT) chose it; this checkpoint verifies the package itself before any download.</what-built> - <how-to-verify> - 1. Open https://pkg.go.dev/github.com/gen2brain/webp and https://github.com/gen2brain/webp: the module path matches, the repository is the author's (gen2brain also maintains gen2brain/avif and go-fitz), the licence is permissive, and the latest tag is v0.6.4 or newer. - 2. Check the README: libwebp through purego when the system library is present, a WASM build through wazero otherwise, no cgo. - 3. Check proxy.golang.org lists the version to pin: https://proxy.golang.org/github.com/gen2brain/webp/@v/list - 4. Answer "approved vX.Y.Z" (the version to pin) or "rejected: <reason>". - </how-to-verify> - <resume-signal>Reply "approved vX.Y.Z" or "rejected: <reason>" (rejected: Task 5 stops and the SUMMARY records WebP as not delivered for the decision to be revisited)</resume-signal> -</task> - <task type="auto"> - <name>Task 5: WebP decision note, the framework encoder sub-package, the Journal variants job and API, and srcset in the layer</name> - <files>.planning/notes/2026-10-28-webp-gen2brain.md, .planning/research/STACK.md, modules/lagoon/attach/variant.go, modules/lagoon/attach/webp/webp.go, modules/lagoon/attach/webp/webp_smoke_test.go, modules/lagoon/attach/README.md, modules/lagoon/README.md, docs/database/attachments.md, go.mod, go.sum, ../sm-journal-plugin/classes/image_variants.go, ../sm-journal-plugin/console/image_variants.go, ../sm-journal-plugin/models/image_variant.go, ../sm-journal-plugin/models/registry.go, ../sm-journal-plugin/updates/202610280002_create_golem15_journal_image_variants.go, ../sm-journal-plugin/updates/registry.go, ../sm-journal-plugin/jobs.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/config/config.yaml, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.sum, ../sm-journal-plugin/README.md, ../sm-journal-plugin/related_media_test.go, ../vue-journal-layer/app/utils/srcset.ts, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/tests/srcset.test.ts, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files> + <name>Task 3: WebP decision note and the framework encoder sub-package (summercms.go only)</name> + <files>.planning/notes/2026-10-28-webp-gen2brain.md, .planning/research/STACK.md, modules/lagoon/attach/variant.go, modules/lagoon/attach/webp/webp.go, modules/lagoon/attach/webp/webp_smoke_test.go, modules/lagoon/attach/README.md, modules/lagoon/README.md, docs/database/attachments.md, go.mod, go.sum</files> <read_first> - - .planning/notes/2026-10-27-robots-txt-grobotstxt.md and .planning/notes/2026-10-07-embedded-admin-release-gate.md (decision note format), .planning/research/STACK.md (row format) + - .planning/phases/33-seo-output-audit-and-content/33-06-SUMMARY.md (top: the approved gen2brain/webp version from 33-06 Task 4) + - .planning/notes/2026-10-27-robots-txt-grobotstxt.md, .planning/notes/2026-10-07-embedded-admin-release-gate.md and .planning/notes/2026-10-28-chromedp-rendered-crawl.md (decision note format; the last one from 33-10), .planning/research/STACK.md (row format) - modules/lagoon/attach/thumb.go (ThumbKey 154-243, encodeImage 119-130, content types, deleteKey, Exists) and thumb_test.go - docs/database/attachments.md and modules/lagoon/attach/README.md - - ../sm-journal-plugin/controllers/api/posts.go (UploadFeaturedImage, featuredImages) and ../sm-seo-plugin/jobs.go (job pattern) - - ../vue-journal-layer/app/components/Journal/FeaturedImage.vue and PostCard.vue </read_first> - <precondition>Task 4 answered "approved vX.Y.Z" for github.com/gen2brain/webp</precondition> + <precondition>33-06 Task 4 answered "webp: approved vX.Y.Z" for github.com/gen2brain/webp (recorded at the top of 33-06-SUMMARY.md); on "rejected", record WebP as not delivered in this plan's SUMMARY and skip Tasks 3 and 4</precondition> <action> - Per the 2026-10-28 image-format decision, D-85, D-71 and P33-42/P33-43, in this order: - - (A, here, before any go.mod change) write `.planning/notes/2026-10-28-webp-gen2brain.md`: decision (WebP variants per thumbnail size in a conga job; AVIF deferred), the dependency github.com/gen2brain/webp at the approved version, how it runs (purego libwebp or WASM, no cgo), the benchmark from 33-CONTEXT, where the code lives (P33-42 and why it is generic media, not SEO), and that editor body images keep their original file; add the STACK.md row; commit as planning docs. + Per the 2026-10-28 image-format decision (CONTEXT: WebP now, in Go; a decision note before go.mod changes), D-85, D-90, D-106 and P33-42, in this order: + - (A, before any go.mod change) write `.planning/notes/2026-10-28-webp-gen2brain.md`: decision (WebP variants per thumbnail size in a conga job; AVIF deferred), the dependency github.com/gen2brain/webp at the version approved in 33-06 Task 4, how it runs (purego libwebp or WASM, no cgo), the benchmark from 33-CONTEXT, where the code lives (P33-42 and why it is generic media, not SEO), and that editor body images keep their original file; add the STACK.md row; commit as planning docs. - (B, framework) `go get github.com/gen2brain/webp@<approved>`; `modules/lagoon/attach/variant.go`: `VariantKey(thumbKey, format string) string` (thumb key with the extension replaced by the format, the same token rule as ThumbFilename); `modules/lagoon/attach/webp/webp.go`: `Encode` (quality 80) and `Variant` (ThumbKey first, return early when the variant exists, encode from the thumbnail image, ContentType image/webp, delete the key on any encode or close error); `webp_smoke_test.go` encodes a generated image and asserts the RIFF/WEBP header and idempotence on a memblob bucket; README rows and docs/database/attachments.md section (no SEO words); `go vet ./... && go test -short ./...` green; commit. - - (C, journal) migration and model of P33-43; `classes/image_variants.go`: `ImageVariantsArgs{FileIDs []uint}` (kind golem15.journal.image_variants, conga.MaxAttempts(3)) generating each width of `golem15.journal.image_widths` (default [480, 800, 1200]; widths larger than the original are skipped) through attach.ThumbKey and webp.Variant and upserting rows; dispatched with lagoon.AfterCommit from UploadFeaturedImage and the admin featured-image upload; `journal:image-variants [--all]` backfills; featuredImages adds `variants` (one query) to each entry; go.mod keeps the framework replace; `TestImageVariants` in related_media_test.go (Docker): an upload commits, the job writes three widths with WebP keys, the API lists variants, a second run writes nothing new, the upload response time does not include encoding (job dispatched, not run inline); commit. - - (D, layer) `app/utils/srcset.ts` (pure: srcset strings for WebP and the original from variants plus the original's width) and tests; FeaturedImage.vue renders `<picture>` with `<source type="image/webp" srcset sizes>` and `<img src srcset sizes width height alt>` (lazy in cards, eager with fetchpriority high on the post page, decoding async), unchanged when variants are absent; fixtures and an expectation line containing image/webp; README; commit. </action> <verify> <automated>go test ./modules/lagoon/attach/... -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10 && go vet ./... && go test -short ./...</automated> <fails_when>non-zero exit, a "FAIL" line, or no "ok" line for the webp package</fails_when> - <automated>go -C ../sm-journal-plugin test ./... -run 'TestImageVariants' -count=1 -v 2>&1 | tail -10</automated> - <fails_when>non-zero exit, "FAIL" or "no tests to run", or no "--- PASS: TestImageVariants" line</fails_when> - <automated>npm --prefix ../vue-journal-layer run test 2>&1 | tail -6 && npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -2 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -8</automated> - <fails_when>non-zero exit from any command, or a "not ok" or "FAIL" line</fails_when> + <automated>bash scripts/check-phase33.sh --additive --marker --docs</automated> + <fails_when>non-zero exit, or a "FAIL", "refuse:" or marker hit line</fails_when> </verify> <acceptance_criteria> - `git log --format=%s -- .planning/notes/2026-10-28-webp-gen2brain.md` shows a commit older than the first commit that changes go.mod with gen2brain (`git log --format=%H -S gen2brain -- go.mod`) - `grep -n "github.com/gen2brain/webp" go.mod .planning/research/STACK.md .planning/notes/2026-10-28-webp-gen2brain.md` prints a line in each - `grep -rln "gen2brain" modules --include=*.go` lists only files under modules/lagoon/attach/webp/ - - `grep -n '"variants"' ../sm-journal-plugin/controllers/api/posts.go` prints one line; `grep -n "image/webp" ../vue-journal-layer/app/components/Journal/FeaturedImage.vue` prints one line - `bash scripts/check-phase33.sh --additive --marker` passes (attach changes are additive and name no SEO concept) </acceptance_criteria> + <done>The framework can write a WebP variant next to any attachment thumbnail, after the decision note, with the encoder linked only into binaries that import it.</done> +</task> + +<task type="auto" tdd="true"> + <name>Task 4: Journal image variants job and API variants, and WebP with srcset in the journal layer</name> + <files>../sm-journal-plugin/classes/image_variants.go, ../sm-journal-plugin/console/image_variants.go, ../sm-journal-plugin/models/image_variant.go, ../sm-journal-plugin/models/registry.go, ../sm-journal-plugin/updates/202610280002_create_golem15_journal_image_variants.go, ../sm-journal-plugin/updates/registry.go, ../sm-journal-plugin/jobs.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/controllers/api/posts.go, ../sm-journal-plugin/config/config.yaml, ../sm-journal-plugin/go.mod, ../sm-journal-plugin/go.sum, ../sm-journal-plugin/README.md, ../sm-journal-plugin/related_media_test.go, ../vue-journal-layer/app/utils/srcset.ts, ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, ../vue-journal-layer/app/components/Journal/PostCard.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/app/utils/types.ts, ../vue-journal-layer/tests/srcset.test.ts, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files> + <read_first> + - modules/lagoon/attach/webp/webp.go and attach/variant.go (Task 3) + - ../sm-journal-plugin/controllers/api/posts.go (UploadFeaturedImage ~357-430, featuredImages ~629) and ../sm-seo-plugin/jobs.go (job pattern) + - ../sm-journal-plugin/related_media_test.go (created by 33-09 for related posts; this task adds TestImageVariants) + - ../vue-journal-layer/app/components/Journal/FeaturedImage.vue, PostCard.vue and app/journal-pages/PostPage.vue (as changed by 33-09) + </read_first> + <precondition>Task 3 is committed (the framework's webp package exists through the replace => ../summercms.go)</precondition> + <behavior> + - TestImageVariants (Docker): an upload commits, the job writes the three default widths with WebP keys, the API lists variants on the featured image, a second run writes nothing new, a width larger than the original is skipped, and the upload request returns before any encoding (the job is dispatched, not run inline) + - srcset.test.ts: WebP and original srcset strings from variants plus the original's width; no variants gives no srcset + - Smoke: a fixture post with variants renders <picture> with a source of type image/webp and an img with width, height and alt; a post without variants renders as before + </behavior> + <action> + Per the 2026-10-28 image-format decision, D-85, D-71 and P33-43, in this order: + - (A, journal) migration and model of P33-43; `classes/image_variants.go`: `ImageVariantsArgs{FileIDs []uint}` (kind golem15.journal.image_variants, conga.MaxAttempts(3)) generating each width of `golem15.journal.image_widths` (default [480, 800, 1200]; widths larger than the original are skipped) through attach.ThumbKey and webp.Variant and upserting rows; dispatched with lagoon.AfterCommit from UploadFeaturedImage and the admin featured-image upload; `journal:image-variants [--all]` backfills (documented, so commands_documented_test.go passes); featuredImages adds `variants` (one query) to each entry (additive key); go.mod keeps the framework replace; config/config.yaml documents image_widths; README section; tests first; commit. + - (B, layer) `app/utils/srcset.ts` (pure: srcset strings for WebP and the original from variants plus the original's width) and tests first; FeaturedImage.vue renders `<picture>` with `<source type="image/webp" srcset sizes>` and `<img src srcset sizes width height alt>` (lazy in cards, eager with fetchpriority high on the post page through a prop PostPage passes, decoding async), unchanged when variants are absent; types.ts adds variants; fixtures and an expectation line containing image/webp; README; commit. + </action> + <verify> + <automated>go -C ../sm-journal-plugin test ./... -run 'TestImageVariants|TestJournalAPIMatrix' -count=1 -v 2>&1 | grep -E '^(--- |FAIL|ok)' | tail -10</automated> + <fails_when>a "FAIL" line, "no tests to run", or TestImageVariants missing from the "--- PASS" lines</fails_when> + <automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./...</automated> + <fails_when>non-zero exit or a "FAIL" line</fails_when> + <automated>npm --prefix ../vue-journal-layer run test 2>&1 | tail -6 && npm --prefix ../vue-journal-layer run build:playground 2>&1 | tail -2 && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | tail -8</automated> + <fails_when>non-zero exit from any command, or a "not ok" or "FAIL" line</fails_when> + </verify> + <acceptance_criteria> + - `grep -n '"variants"' ../sm-journal-plugin/controllers/api/posts.go` prints one line; `grep -n "image/webp" ../vue-journal-layer/app/components/Journal/FeaturedImage.vue` prints one line + - `grep -n "golem15.journal.image_variants" ../sm-journal-plugin/classes/image_variants.go` prints at least one line + - `grep -n "image_widths" ../sm-journal-plugin/config/config.yaml` prints at least one line + </acceptance_criteria> <done>Featured images get WebP variants at three widths from a background job, and the sites serve them with srcset and the original as fallback.</done> </task> @@ -357,22 +344,23 @@ Copy: UI-SPEC C19 (E10), C22 (Auto-links) with their copy tables, C20 Links tab |-----------|----------|-----------|----------|-------------|-----------------| | T-33-50 | Tampering (XSS) | auto-link phrases and URLs | high | mitigate | URL must be a / path or an http(s) URL (javascript: and data: refused), phrase is text; the layer inserts them into text nodes with attribute escaping (33-09); TestAutolinks covers the refusals | | T-33-51 | Denial of service | image decoding in the job | medium | mitigate | attach's existing original-size cap (errOriginalTooLarge) applies before resizing; the job has attempts and runs outside the request; widths larger than the original skipped | -| T-33-52 | Tampering | supply chain of the WebP encoder | high | mitigate | human legitimacy checkpoint (Task 4), decision note and STACK row before go.mod, exact version pinned in go.sum | +| T-33-52 | Tampering | supply chain of the WebP encoder | high | mitigate | human legitimacy checkpoint (33-06 Task 4), decision note and STACK row before go.mod, exact approved version pinned in go.sum | | T-33-53 | Elevation of privilege | auto-links, links and keyphrase tools | high | mitigate | golem15.seo.manage on every route and action, operations parity test | | T-33-54 | Information disclosure | E10 suggestions | low | accept | suggestions come from the public pages the crawler fetched | -| T-33-SC | Tampering | npm/pip/cargo/Go installs | high | mitigate | package-legitimacy gate: github.com/gen2brain/webp is [ASSUMED] and gated by the blocking human checkpoint Task 4; no npm package added | +| T-33-SC | Tampering | npm/pip/cargo/Go installs | high | mitigate | package-legitimacy gate: github.com/gen2brain/webp is [ASSUMED] and gated by the blocking human checkpoint 33-06 Task 4 (Task 3 here has it as a precondition); no npm package added | </threat_model> <verification> - sm-seo-plugin: vet, `test -short`, Docker suites TestAutolinksTracer, TestAutolinks, TestLinkSuggestions and the parity tests; element tests and the dist check. -- sm-journal-plugin: TestRelatedScored, TestRelatedDefaultUnchanged, TestImageVariants and the API matrix. +- sm-journal-plugin: TestImageVariants and the API matrix. - framework: `go vet ./... && go test -short ./...`, the attach tests, `check-phase33.sh --additive --marker`. - vue-journal-layer: tests, playground build and smoke. </verification> <success_criteria> -- Auto-links, link suggestions with counters, scored related posts and category neighbours work. -- WebP variants with srcset are served, after the note and the legitimacy checkpoint. +- Auto-links and link suggestions with counters work. +- WebP variants with srcset are served, after the note and 33-06's legitimacy checkpoint. +- No task spans more than two repositories. </success_criteria> ## Artifacts this phase produces @@ -380,10 +368,10 @@ Copy: UI-SPEC C19 (E10), C22 (Auto-links) with their copy tables, C20 Links tab - Notes: `.planning/notes/2026-10-28-webp-gen2brain.md`; STACK.md row for `github.com/gen2brain/webp`. - Framework: `attach.VariantKey`; package `modules/lagoon/attach/webp` with `webp.Encode` and `webp.Variant`; go.mod require `github.com/gen2brain/webp`. - sm-seo-plugin: table `golem15_seo_autolinks`, settings `autolink_max`, migration 202610280007, controller `golem15.seo.autolinks`, routes `GET/POST /_seo/api/v1/autolinks`, `PUT /_seo/api/v1/autolinks/{id}`, `DELETE /_seo/api/v1/autolinks`, `POST /_seo/api/v1/autolinks/from-keyphrases`, `GET /_seo/api/v1/links`, widget action `seo_links`, element `golem15-seo-links` (E10), resolve keys `autolinks`, `autolink_max`, MCP tools `seo_list_autolinks`, `seo_create_autolink`, `seo_update_autolink`, `seo_delete_autolinks`, `seo_autolinks_from_keyphrases`, `seo_get_link_suggestions`. -- sm-journal-plugin: config keys `golem15.journal.related_mode`, `golem15.journal.related_count`, `golem15.journal.image_widths`; table `golem15_journal_image_variants` (migration 202610280002); job kind `golem15.journal.image_variants` (`ImageVariantsArgs`); CLI `journal:image-variants [--all]`; API keys `previous_in_category`, `next_in_category`, `featured_images[].variants`. -- vue-journal-layer: `app/utils/srcset.ts`, `<picture>` in FeaturedImage, category neighbours on the post page. -- Tests: `TestAutolinksTracer`, `TestAutolinks`, `TestLinkSuggestions`, `TestRelatedScored`, `TestRelatedDefaultUnchanged`, `TestImageVariants`, `webp_smoke_test.go`, `tests/srcset.test.ts`. +- sm-journal-plugin: config key `golem15.journal.image_widths`; table `golem15_journal_image_variants` (migration 202610280002); job kind `golem15.journal.image_variants` (`ImageVariantsArgs`); CLI `journal:image-variants [--all]`; API key `featured_images[].variants`. (The related-posts keys are 33-09's.) +- vue-journal-layer: `app/utils/srcset.ts`, `<picture>` in FeaturedImage. +- Tests: `TestAutolinksTracer`, `TestAutolinks`, `TestLinkSuggestions`, `TestImageVariants`, `webp_smoke_test.go`, `tests/srcset.test.ts`. <output> -Create `.planning/phases/33-seo-output-audit-and-content/33-11-SUMMARY.md` when done; record the Task 4 answer and the pinned version. +Create `.planning/phases/33-seo-output-audit-and-content/33-11-SUMMARY.md` when done; record the pinned gen2brain/webp version (from 33-06's checkpoint answer). </output> diff --git a/.planning/phases/33-seo-output-audit-and-content/33-12-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-12-PLAN.md index 4199b220..3f90f6a8 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-12-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-12-PLAN.md @@ -30,7 +30,7 @@ must_haves: - "The demo's client-only routes /p/** and /presentations/** are declared in seo.noindexRoutes, so its build passes the SSR guard and those routes send X-Robots-Tag noindex, nofollow; removing the declaration makes the build fail (D-70)" - "The demo's hand-written robots meta on login, register, connect and the signed-in pages is replaced by page meta (seo.robots for auth pages, requiresAuth for signed-in pages), so the layer marks them automatically (D-23)" - "The demo reports its static routes to the page registry at server start and links the HTML sitemap from its footer (D-33, D-34)" - - "summercms.io's changelog site (Journal on the journal layer, no SEO plugin mounted) runs with seo.cms false: it keeps its titles through the journal layer's fallback meta, passes the SSR guard, and its changelog smoke passes with the updated card and heading markup; the main site keeps its own @nuxtjs/seo static sitemap (RESEARCH Open Question 7: pin bumps and build guard only)" + - "summercms.io's changelog site (Journal on the journal layer, no SEO plugin mounted) runs with seo.cms false: it keeps its titles through the journal layer's fallback meta, passes the SSR guard, and its changelog smoke passes with the updated card and heading markup; the main site keeps its own @nuxtjs/seo static sitemap (CONTEXT 2026-10-28 accepted defaults: summercms.io gets pin bumps and the build guard only)" - "sm-bm-app has no indexable pages: its frontend serves robots.txt with User-agent: * and Disallow: /, index.html carries <meta name=\"robots\" content=\"noindex, nofollow\">, and nginx adds X-Robots-Tag: noindex, nofollow to every response; there is no SSR migration (D-72)" - "The app changes that depend on new tags are captured as patches in the phase directory (one per repository) that apply cleanly to each repository's current HEAD; 33-14 applies them after the tags with real pins; sm-bm-app's change needs no tag and is committed locally (2026-10-28 release cadence)" - "33-12-local-wiring.md records the exact local-link wiring commands so 33-13 and 33-14 can repeat them" @@ -84,7 +84,7 @@ Purpose: SC-1/SC-2 on a real app before the release; D-72. Output: four patch files and a wiring note in the phase directory (planning docs commit here), and local commits in sm-bm-app. Nothing is pushed, tagged or deployed (2026-10-28 release cadence: tags and app re-pins in 33-14). **Planner decisions recorded here:** -- **P33-46 (local wiring, never committed):** a scratch directory outside every repository holds detached worktrees of sm-demo-app, vue-demo-app (from its submodule repository), sm-summercmsio-app and vue-summercmsio-app at HEAD. Go: a scratch go.work using the worktree plus the sibling plugin checkouts (absolute paths) and replacing the framework with ../summercms.go. Nuxt: pnpm overrides in the scratch worktree pointing vue-seo-layer, vue-journal-layer, vue-menu-layer and vue-faq-layer to the sibling checkouts (`link:`). The commands go into 33-12-local-wiring.md. +- **P33-46 (local wiring, never committed):** the fixed scratch directory `../../scratch-33` (that is /media/nvme/dev/golem15/summercms.io/scratch-33, outside every repository: summercms.io/ is not a git repository) holds detached worktrees of sm-demo-app, vue-demo-app (from its submodule repository), sm-summercmsio-app and vue-summercmsio-app at HEAD, as `../../scratch-33/<repository name>`; 33-13 adds its own two worktrees there. A fixed path keeps every verify command resolvable; Task 1 creates the directory; each plan removes only its own worktrees with `git worktree remove` after its patches are committed (33-13 after its deploy step) and records that in its SUMMARY. Go: a scratch go.work using the worktree plus the sibling plugin checkouts (absolute paths) and replacing the framework with ../summercms.go. Nuxt: pnpm overrides in the scratch worktree pointing vue-seo-layer, vue-journal-layer, vue-menu-layer and vue-faq-layer to the sibling checkouts (`link:`). The commands go into 33-12-local-wiring.md. - **P33-47 (patch contents):** each patch holds only the changes that stay after the release (config, pages, tests, scripts, docs), generated with `git diff --binary` in the worktree (new files added with `git add -N`), never the wiring; it must `git apply --check` against the repository's current HEAD. - **P33-48 (demo noindex set):** noindexRoutes `/p/**` and `/presentations/**` (client-only decks); page meta `seo: {robots: 'noindex, nofollow'}` on login, register, connect and link-admin; `requiresAuth: true` on account/security and subscriptions; quizzes and roadmap keep the CMS answer (public) unless their current noindex is intentional, in which case they move to page meta with that value. </objective> @@ -132,14 +132,14 @@ Layer runtime config (33-07): NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE, NUXT_SEO - ../vue-seo-layer/README.md (runtime config) and ../sm-seo-plugin/README.md (config keys) </read_first> <action> - Per P33-46 and the 2026-10-28 release cadence: create the scratch directory and worktrees, wire Go (scratch go.work with the sibling sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin and the framework replace) and Nuxt (pnpm overrides to the four sibling layers), build the demo binary with `summer build` against the wiring, migrate a scratch database (the demo's compose.yaml Postgres or testcontainers), seed one Journal post with a category and a translation, run the Go binary and the Nuxt server with NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE and the tokens set, and run the sitemap rebuild. Then check through the Nuxt server (curl): the post page has one `<script type="application/ld+json">`, one `rel="canonical"`, hreflang links, a nav.seo-breadcrumbs and the CMS title; /sitemap.xml answers an index whose files answer through the layer; a redirect created through the SEO API answers 301 from Nuxt; /p/x answers X-Robots-Tag noindex, nofollow; the registry lists the reported static routes. Write the commands and checks as a runnable script `scripts/smoke-seo.sh BASE_URL` inside the demo worktree (it becomes part of the patch in Task 2) and record every wiring command in 33-12-local-wiring.md. + Per P33-46 and the 2026-10-28 release cadence: create the scratch directory `../../scratch-33` and the worktrees in it, wire Go (scratch go.work with the sibling sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin and the framework replace) and Nuxt (pnpm overrides to the four sibling layers), build the demo binary with `summer build` against the wiring, migrate a scratch database (the demo's compose.yaml Postgres or testcontainers), seed one Journal post with a category and a translation, run the Go binary and the Nuxt server with NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE and the tokens set, and run the sitemap rebuild. Then check through the Nuxt server (curl): the post page has one `<script type="application/ld+json">`, one `rel="canonical"`, hreflang links, a nav.seo-breadcrumbs and the CMS title; /sitemap.xml answers an index whose files answer through the layer; a redirect created through the SEO API answers 301 from Nuxt; /p/x answers X-Robots-Tag noindex, nofollow; the registry lists the reported static routes. Write the commands and checks as a runnable script `scripts/smoke-seo.sh BASE_URL` inside the demo worktree (it becomes part of the patch in Task 2) and record every wiring command in 33-12-local-wiring.md. </action> <verify> - <automated>bash "$SCRATCH/sm-demo-app/scripts/smoke-seo.sh" http://127.0.0.1:3020 2>&1 | tail -20</automated> - <fails_when>non-zero exit, a "FAIL" line, or no final "smoke-seo: ok" line ($SCRATCH is the scratch directory recorded in 33-12-local-wiring.md)</fails_when> + <automated>bash ../../scratch-33/sm-demo-app/scripts/smoke-seo.sh http://127.0.0.1:3020 2>&1 | tail -20</automated> + <fails_when>non-zero exit, a "FAIL" line, a missing script (the P33-46 worktree was not created), or no final "smoke-seo: ok" line</fails_when> </verify> <acceptance_criteria> - - 33-12-local-wiring.md lists the worktree, go.work and pnpm override commands with absolute sibling paths + - 33-12-local-wiring.md lists the worktree, go.work and pnpm override commands with absolute sibling paths, and the worktrees live under ../../scratch-33 (P33-46) - smoke-seo.sh checks at least: one ld+json, one canonical, breadcrumbs, the sitemap index and one file, a 301 from the SEO plugin, the noindex header on /p/x - the SUMMARY records the smoke output </acceptance_criteria> @@ -156,10 +156,10 @@ Layer runtime config (33-07): NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE, NUXT_SEO - .planning/phases/32-seo-foundation/32-05-demo-mount.patch (patch precedent) </read_first> <action> - Per D-70, D-23, D-33, D-34, D-97 and P33-47/P33-48, in the scratch worktrees: vue-demo-app nuxt.config.ts adds `seo: {noindexRoutes: ['/p/**', '/presentations/**']}` and removes the @nuxtjs/seo-only keys (robots, the sitemap URL setting) the journal layer no longer understands; pages switch their hand-written robots meta to page meta per P33-48; the footer renders `<SeoSitemapLink>`; the build passes the guard and fails when the noindexRoutes line is removed (check once, then restore). sm-demo-app: config/golem15.seo.yaml (route_report_token from the environment), config/golem15.sitemap.yaml (site_url), DEPLOY.md (new NUXT_SEO_* variables, `/_sitemap/` not exposed, root files served by the layer), deploy/nginx/demo.summercms.io.conf (drop any sitemap.xsl location), `seo_output_test.go` (Docker: sitemap index served, /_seo/api/v1/site answers, resolve of a seeded post carries jsonld and breadcrumbs), and scripts/smoke-seo.sh from Task 1. vue-summercmsio-app: `seo: {cms: false}` for the changelog app, changelog/testing/expect.txt updated for the journal layer's card and heading markup; `npm run generate` and `test:changelog` pass on the wiring. sm-summercmsio-app: when nothing beyond the later pin bumps is needed, 33-12-sm-summercmsio-app.patch holds the single line `# no change beyond the pin bumps in 33-14` (git apply treats it as an empty patch and 33-14 skips it). Export one patch per repository (P33-47), check each with `git -C <repo> apply --check <patch>` against the live repository's HEAD, and commit the patches and the updated wiring note here. + Per D-70, D-23, D-33, D-34, D-97, P33-47/P33-48 and the CONTEXT 2026-10-28 accepted default (summercms.io: pin bumps and the build guard only), in the scratch worktrees under ../../scratch-33: vue-demo-app nuxt.config.ts adds `seo: {noindexRoutes: ['/p/**', '/presentations/**']}` and removes the @nuxtjs/seo-only keys (robots, the sitemap URL setting) the journal layer no longer understands; pages switch their hand-written robots meta to page meta per P33-48; the footer renders `<SeoSitemapLink>`; the build passes the guard and fails when the noindexRoutes line is removed (check once, then restore). sm-demo-app: config/golem15.seo.yaml (route_report_token from the environment), config/golem15.sitemap.yaml (site_url), DEPLOY.md (new NUXT_SEO_* variables, `/_sitemap/` not exposed, root files served by the layer), deploy/nginx/demo.summercms.io.conf (drop any sitemap.xsl location), `seo_output_test.go` (Docker: sitemap index served, /_seo/api/v1/site answers, resolve of a seeded post carries jsonld and breadcrumbs), and scripts/smoke-seo.sh from Task 1. vue-summercmsio-app: `seo: {cms: false}` for the changelog app, changelog/testing/expect.txt updated for the journal layer's card and heading markup; `npm run generate` and `test:changelog` pass on the wiring. sm-summercmsio-app: when nothing beyond the later pin bumps is needed, 33-12-sm-summercmsio-app.patch holds the single line `# no change beyond the pin bumps in 33-14` (git apply treats it as an empty patch and 33-14 skips it). Export one patch per repository (P33-47), check each with `git -C <repo> apply --check <patch>` against the live repository's HEAD, and commit the patches and the updated wiring note here; then remove this plan's four worktrees from ../../scratch-33 with `git worktree remove` (P33-46) and record it in the SUMMARY. </action> <verify> - <automated>for p in sm-demo-app vue-demo-app; do r=../$p; [ "$p" = vue-demo-app ] && r=../sm-demo-app/vue-demo-app; git -C "$r" apply --check "$PWD/.planning/phases/33-seo-output-audit-and-content/33-12-$p.patch" || exit 1; done; echo patches-ok</automated> + <automated>git -C ../sm-demo-app apply --check ../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-12-sm-demo-app.patch && git -C ../sm-demo-app/vue-demo-app apply --check ../../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch && echo patches-ok</automated> <fails_when>non-zero exit, an "error:" line from git apply, or no "patches-ok" line</fails_when> <automated>grep -c "noindexRoutes" .planning/phases/33-seo-output-audit-and-content/33-12-vue-demo-app.patch</automated> <fails_when>output 0 or a missing file</fails_when> @@ -229,6 +229,7 @@ Layer runtime config (33-07): NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE, NUXT_SEO - Patches: `33-12-sm-demo-app.patch` (config/golem15.seo.yaml, config/golem15.sitemap.yaml, DEPLOY.md, nginx, `seo_output_test.go`, `scripts/smoke-seo.sh`), `33-12-vue-demo-app.patch` (nuxt.config `seo.noindexRoutes`, page meta, `<SeoSitemapLink>` in the footer), `33-12-sm-summercmsio-app.patch`, `33-12-vue-summercmsio-app.patch` (`seo.cms: false`, changelog expectations). - Note: `33-12-local-wiring.md`. +- Scratch worktrees (not committed): `../../scratch-33/sm-demo-app`, `../../scratch-33/vue-demo-app`, `../../scratch-33/sm-summercmsio-app`, `../../scratch-33/vue-summercmsio-app` (P33-46), removed after Task 2. - sm-bm-app: `frontend/public/robots.txt`, robots meta in `frontend/index.html`, X-Robots-Tag in the nginx conf, `scripts/check-noindex.sh`. - Test (in the demo patch): `TestSEOOutput` (seo_output_test.go). diff --git a/.planning/phases/33-seo-output-audit-and-content/33-13-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-13-PLAN.md index 84a79a3b..d2ff4cbc 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-13-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-13-PLAN.md @@ -28,7 +28,7 @@ must_haves: - "Every page renders exactly one H1, one canonical and one JSON-LD script; post pages carry breadcrumbs through their category; the build passes the SSR guard (D-70, D-52, D-86, D-43)" - "The static pages /, /kontakt and /trwaja-prace become registry pages through the route report, and their current titles and descriptions move into the CMS through deploy/seo-pages.csv imported with the SEO plugin's CSV import, so the pages lose no metadata (D-33)" - "404s are reported to the SEO plugin's 404 log through the layer with the new token (D-20)" - - "The uat deployment happens only after Jakub's decision at Task 3 and his go-ahead; production grzybyfunkcjonalne.pl (WinterCMS) is never touched; the deploy is logged in 33-13-deploy-log.md (2026-10-28 release cadence: no push, no tag)" + - "The uat deployment happens only after Jakub's decision at Task 3 and his go-ahead; production grzybyfunkcjonalne.pl (WinterCMS) is never touched; the deploy is logged in 33-13-deploy-log.md (2026-10-28 release cadence: no push, no tag); Jakub's own check of uat happens at 33-14's release checkpoint (Task 3 there), before anything is tagged" - "On uat (a non-production environment) the staging lock keeps robots Disallow and noindex, sitemaps are still built, and no IndexNow request is sent (D-22, D-31)" prohibitions: - requirement_id: "D-32" @@ -72,9 +72,9 @@ must_haves: This plan moves grzybyfunkcjonalne.pl's SummerCMS deployment onto the new sitemap format and the SEO layer, proves its public URLs unchanged on local links, and deploys it to uat only with Jakub's go-ahead. <objective> -- In scratch worktrees wired to the sibling repositories (as 33-12, recorded again in 33-13-local-wiring.md): bump the stack (framework, Journal with tag_pages false and author pages, sitemap new format, SEO plugin mounted, layers on vue-seo-layer), run the site's own smoke over every old URL plus the SEO checks. +- In scratch worktrees under the fixed directory ../../scratch-33 (P33-46 of 33-12), wired to the sibling repositories and recorded again in 33-13-local-wiring.md: bump the stack (framework, Journal with tag_pages false and author pages, sitemap new format, SEO plugin mounted, layers on vue-seo-layer), run the site's own smoke over every old URL plus the SEO checks. - Capture the committable changes as two patches (app repository and its Nuxt submodule repository) for 33-14. -- Decide (Task 3) and, if chosen, deploy to uat.grzybyfunkcjonalne.pl from the local build, then let Jakub verify. +- Decide (Task 3) and, if chosen, deploy to uat.grzybyfunkcjonalne.pl from the local build and smoke it; Jakub verifies uat at 33-14's release checkpoint, which already stops for his review before any tag (moved there in the 2026-10-28 revision so this plan stays within its task budget). Purpose: SC-1 ("grzybyfunkcjonalne.pl runs on the new format") and RESEARCH Pitfall 13 (several versions at once on a live site). @@ -116,8 +116,7 @@ Deferred from Phase 32 (deferred-items.md item 1): the Journal bump needs golem1 | 1 | none (scratch run; results in the SUMMARY) | smoke.sh and the SEO checks pass locally | | 2 | `docs(33-13): grzyby patches for the release` | both patches apply with git apply --check | | 3 | none (decision recorded in the deploy log) | decision recorded | -| 4 | `docs(33-13): uat deploy log` | uat smoke passes, or the log records the deferral | -| 5 | none (Jakub's verification recorded in the SUMMARY) | approved | +| 4 | `docs(33-13): uat deploy log` | uat smoke passes, or the log records the deferral; plan complete | <tasks> @@ -130,13 +129,13 @@ Deferred from Phase 32 (deferred-items.md item 1): the Journal bump needs golem1 - .planning/phases/33-seo-output-audit-and-content/33-12-local-wiring.md if present, else .planning/phases/32-seo-foundation/32-05-SUMMARY.md ("Scratch run") </read_first> <action> - Per Pitfall 13 and P33-46's wiring (repeated for this app): detached worktrees of sm-grzybyfunkcjonalne-app and vue-grzybyfunkcjonalne-app in a scratch directory; scratch go.work using the worktree and the sibling plugin checkouts (sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin, sm-menu-plugin, sm-faq-plugin, sm-translate-plugin, sm-user-plugin as they are) plus the framework replace; summer.yaml mounts golem15.seo; config adds `golem15.journal.tag_pages: false`, the journal-author pattern `/autor/:author`, golem15.seo (site_url https://grzybyfunkcjonalne.pl, notfound_token from the environment) and golem15.sitemap (site_url); pnpm overrides link the four layers. Build with scripts/build.sh on the wiring, run scripts/smoke.sh (full rehearsal: migrate, companions import with the sitemap definitions, Go and Nuxt, every urls.txt line), then write scripts/smoke-seo.sh for this site in the worktree (the same checks as the demo's script of 33-12 when it already exists: one H1, one canonical and one ld+json on a post, a category and /kontakt; breadcrumbs on a post; /sitemap.xml index with sitemap-posts-1.xml; the companion url items present as Additional addresses; it ends with "smoke-seo: ok") and run it. Record every wiring command in 33-13-local-wiring.md. + Per Pitfall 13 and P33-46's wiring (repeated for this app): detached worktrees of sm-grzybyfunkcjonalne-app and vue-grzybyfunkcjonalne-app at ../../scratch-33/sm-grzybyfunkcjonalne-app and ../../scratch-33/vue-grzybyfunkcjonalne-app (the fixed P33-46 directory, created if 33-12 has not run yet); scratch go.work using the worktree and the sibling plugin checkouts (sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin, sm-menu-plugin, sm-faq-plugin, sm-translate-plugin, sm-user-plugin as they are) plus the framework replace; summer.yaml mounts golem15.seo; config adds `golem15.journal.tag_pages: false`, the journal-author pattern `/autor/:author`, golem15.seo (site_url https://grzybyfunkcjonalne.pl, notfound_token from the environment) and golem15.sitemap (site_url); pnpm overrides link the four layers. Build with scripts/build.sh on the wiring, run scripts/smoke.sh (full rehearsal: migrate, companions import with the sitemap definitions, Go and Nuxt, every urls.txt line), then write scripts/smoke-seo.sh for this site in the worktree (the same checks as the demo's script of 33-12 when it already exists: one H1, one canonical and one ld+json on a post, a category and /kontakt; breadcrumbs on a post; /sitemap.xml index with sitemap-posts-1.xml; the companion url items present as Additional addresses; it ends with "smoke-seo: ok") and run it. Record every wiring command in 33-13-local-wiring.md. </action> <verify> - <automated>cd "$SCRATCH/sm-grzybyfunkcjonalne-app" && bash scripts/smoke.sh 2>&1 | tail -15</automated> - <fails_when>non-zero exit, a "FAIL" line, or a urls.txt line reported with a different status ($SCRATCH as recorded in 33-13-local-wiring.md)</fails_when> - <automated>bash "$SCRATCH/sm-grzybyfunkcjonalne-app/scripts/smoke-seo.sh" http://127.0.0.1:3016 2>&1 | tail -15</automated> - <fails_when>non-zero exit, a "FAIL" line, or no "smoke-seo: ok" line</fails_when> + <automated>cd ../../scratch-33/sm-grzybyfunkcjonalne-app && bash scripts/smoke.sh 2>&1 | tail -15</automated> + <fails_when>non-zero exit (including a missing worktree directory), a "FAIL" line, or a urls.txt line reported with a different status</fails_when> + <automated>bash ../../scratch-33/sm-grzybyfunkcjonalne-app/scripts/smoke-seo.sh http://127.0.0.1:3016 2>&1 | tail -15</automated> + <fails_when>non-zero exit, a "FAIL" line, a missing script, or no "smoke-seo: ok" line</fails_when> </verify> <acceptance_criteria> - every urls.txt line not marked remote passes in the full rehearsal @@ -158,7 +157,7 @@ Deferred from Phase 32 (deferred-items.md item 1): the Journal bump needs golem1 Per D-32, D-22, D-33, D-97 and P33-47/P33-49/P33-50, in the worktrees: app repository — summer.yaml golem15.seo entry; config/golem15.journal.yaml (tag_pages false), config/menu_items.yaml (journal-author), config/golem15.seo.yaml, config/golem15.sitemap.yaml; nginx per P33-49 (and its header comment); scripts/check-deploy.sh (the required locations), scripts/smoke.sh and scripts/urls.txt (sitemap lines are Nuxt lines now; add /sitemap-posts-1.xml), scripts/migrate.sh comments (step 9 sitemap rows go to Additional addresses); deploy/env.example (SEO tokens and site URL keys), deploy/nuxt.env.example (NUXT_SEO_API_BASE, NUXT_SEO_BACKEND_BASE, NUXT_SEO_NOTFOUND_TOKEN), DEPLOY.md (the new variables, root files through Nuxt, the CSV import step, the route report), deploy/seo-pages.csv (P33-50), smoke-seo.sh, boot_test.go (golem15.seo booted) and companions_test.go (sitemap definitions land in extra_urls). Nuxt submodule repository — nuxt.config.ts drops robots, sitemap, linkChecker and site, adds `seo` options (pagination query, htmlSitemap pl /mapa-strony) and keeps the journal routes; SiteFooter renders `<SeoSitemapLink>`; the three pages drop their useSeoMeta; tests/fixtures/api/seo/{resolve,site}.json and tests/fixtures/expect.txt with one-H1, one-canonical and one-ld+json lines; package.json pins stay for 33-14. `go test` (with Docker) and `npm test` / `smoke:ssr` pass on the wiring. Export both patches (P33-47), `git apply --check` them against the live repositories' HEADs, commit them and the wiring note here. </action> <verify> - <automated>git -C ../sm-grzybyfunkcjonalne-app apply --check "$PWD/.planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch" && git -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app apply --check "$PWD/.planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch" && echo patches-ok</automated> + <automated>git -C ../sm-grzybyfunkcjonalne-app apply --check ../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch && git -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app apply --check ../../summercms.go/.planning/phases/33-seo-output-audit-and-content/33-13-vue-grzybyfunkcjonalne-app.patch && echo patches-ok</automated> <fails_when>non-zero exit, an "error:" line from git apply, or no "patches-ok" line</fails_when> <automated>grep -c "seo-pages.csv\|golem15.seo" .planning/phases/33-seo-output-audit-and-content/33-13-sm-grzybyfunkcjonalne-app.patch</automated> <fails_when>output 0 or a missing file</fails_when> @@ -205,7 +204,7 @@ Deferred from Phase 32 (deferred-items.md item 1): the Journal bump needs golem1 </read_first> <precondition>Task 3 answered "deploy-now", and Jakub has generated the user_setup secrets on the server (DEPLOY.md: never printed)</precondition> <action> - Per P33-51: write the decision to 33-13-deploy-log.md first. If the answer is after-tags or hold, record it and stop (33-14 carries the deploy). Otherwise, from the scratch worktree with the patches applied: scripts/build.sh, scripts/check-deploy.sh, back up the uat database and current binaries as DEPLOY.md describes, rsync the binary, config, scripts, Nuxt .output and deploy files, run the migrations, restart both supervisor programs, reload nginx after `nginx -t`, run the sitemap rebuild command, import deploy/seo-pages.csv after the Nuxt server has reported its routes, then run `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` (with the UAT basic auth from the server's own loopback, as DEPLOY.md says) and smoke-seo.sh against it. Log each step with its result as it completes; never print a secret. On any failure, follow DEPLOY.md's rollback and log it. + Per P33-51: write the decision to 33-13-deploy-log.md first. If the answer is after-tags or hold, record it, remove this plan's two worktrees from ../../scratch-33 with `git worktree remove` (P33-46) and stop (33-14 carries the deploy). Otherwise, from the scratch worktree with the patches applied: scripts/build.sh, scripts/check-deploy.sh, back up the uat database and current binaries as DEPLOY.md describes, rsync the binary, config, scripts, Nuxt .output and deploy files, run the migrations, restart both supervisor programs, reload nginx after `nginx -t`, run the sitemap rebuild command, import deploy/seo-pages.csv after the Nuxt server has reported its routes, then run `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` (with the UAT basic auth from the server's own loopback, as DEPLOY.md says) and smoke-seo.sh against it. Log each step with its result as it completes; never print a secret. On any failure, follow DEPLOY.md's rollback and log it. Finally write at the end of the log the uat check list Jakub runs at 33-14's release checkpoint (sitemap index and one file, a post's head and breadcrumbs, SEO › Pages and Settings › Sitemaps in /gfadmin, a crawl without staging-lock noindex), and remove this plan's two worktrees from ../../scratch-33 with `git worktree remove` (P33-46). </action> <verify> <automated>grep -c "^| " .planning/phases/33-seo-output-audit-and-content/33-13-deploy-log.md</automated> @@ -215,20 +214,10 @@ Deferred from Phase 32 (deferred-items.md item 1): the Journal bump needs golem1 - the deploy log starts with the Task 3 decision - for deploy-now: the log shows build, check-deploy, backup, upload, migrate, restart, nginx reload, rebuild, CSV import, smoke.sh --url and smoke-seo.sh, each with its result - no secret value appears in the log or the SUMMARY + - the log ends with the uat check list for 33-14's release checkpoint (deploy-now only) + - `git -C ../sm-grzybyfunkcjonalne-app worktree list` no longer lists ../../scratch-33/sm-grzybyfunkcjonalne-app </acceptance_criteria> - <done>uat runs the new stack and passes both smokes, or the deferral is on record.</done> -</task> - -<task type="checkpoint:human-verify" gate="blocking"> - <name>Task 5: Jakub checks uat.grzybyfunkcjonalne.pl (skipped when Task 3 deferred the deploy)</name> - <what-built>The SummerCMS stack of grzybyfunkcjonalne.pl on uat with the new sitemap format, the SEO plugin and the SEO layer (Task 4).</what-built> - <how-to-verify> - 1. Open https://uat.grzybyfunkcjonalne.pl/sitemap.xml: an index of sitemap-posts, sitemap-categories, sitemap-authors and sitemap-pages files; open one file. - 2. Open a post: view source shows one title, one canonical, one application/ld+json script with BlogPosting and BreadcrumbList, and breadcrumbs above the content. - 3. In /gfadmin: SEO › Pages lists /, /kontakt and /trwaja-prace with their titles; Settings › Sitemaps shows the files table and the migrated Additional addresses. - 4. Start a crawl in SEO › Crawls; after it finishes, SEO › Site audit lists issues without staging-lock noindex. - </how-to-verify> - <resume-signal>Reply "approved" or describe what is wrong</resume-signal> + <done>uat runs the new stack and passes both smokes, or the deferral is on record; Jakub's check of uat is queued for 33-14's release checkpoint.</done> </task> </tasks> @@ -250,13 +239,13 @@ Deferred from Phase 32 (deferred-items.md item 1): the Journal bump needs golem1 | T-33-59 | Tampering | untagged build on uat | medium | mitigate | deploy only after the Task 3 decision; sha256 of the uploaded binary compared with the local build (DEPLOY.md); rollback path logged | | T-33-60 | Elevation of privilege | exposing /_seo and /_sitemap | medium | mitigate | P33-49 keeps both internal (no nginx location), the management API needs backend auth anyway | | T-33-61 | Denial of service | public URLs changing on the live domain | high | mitigate | only uat is deployed; every urls.txt line must pass first; the WinterCMS site is untouched | -| T-33-SC | Tampering | npm/Go installs | high | accept | no new package beyond those already gated in 33-11 | +| T-33-SC | Tampering | npm/Go installs | high | accept | no new package beyond those already gated at 33-06's legitimacy checkpoint (chromedp, gen2brain/webp) | </threat_model> <verification> - Local rehearsal: scripts/smoke.sh and smoke-seo.sh pass on the wiring. - Both patches pass `git apply --check`. -- For deploy-now: the uat smokes pass and Jakub approves. +- For deploy-now: the uat smokes pass; Jakub checks uat at 33-14's release checkpoint before any tag. </verification> <success_criteria> @@ -267,7 +256,8 @@ Deferred from Phase 32 (deferred-items.md item 1): the Journal bump needs golem1 ## Artifacts this phase produces - Patches: `33-13-sm-grzybyfunkcjonalne-app.patch` (summer.yaml golem15.seo, `config/golem15.journal.yaml`, `config/golem15.seo.yaml`, `config/golem15.sitemap.yaml`, `config/menu_items.yaml` journal-author, nginx, `scripts/check-deploy.sh`, `scripts/smoke.sh`, `scripts/urls.txt`, `scripts/smoke-seo.sh`, `deploy/env.example`, `deploy/nuxt.env.example`, `deploy/seo-pages.csv`, DEPLOY.md, boot and companions tests), `33-13-vue-grzybyfunkcjonalne-app.patch` (nuxt.config `seo` options, `<SeoSitemapLink>` in SiteFooter, page meta removal, `tests/fixtures/api/seo/*`, `tests/fixtures/expect.txt`). -- Notes and logs: `33-13-local-wiring.md`, `33-13-deploy-log.md`. +- Notes and logs: `33-13-local-wiring.md`, `33-13-deploy-log.md` (ending with the uat check list for 33-14's release checkpoint). +- Scratch worktrees (not committed): `../../scratch-33/sm-grzybyfunkcjonalne-app`, `../../scratch-33/vue-grzybyfunkcjonalne-app` (P33-46), removed after Task 4. - Environment variables (uat): `NUXT_SEO_NOTFOUND_TOKEN`, `NUXT_SEO_ROUTE_REPORT_TOKEN`, `NUXT_SEO_API_BASE`, `NUXT_SEO_BACKEND_BASE`; config keys used: `golem15.journal.tag_pages`, `golem15.seo.site_url`, `golem15.seo.notfound_token`, `golem15.sitemap.site_url`. <output> diff --git a/.planning/phases/33-seo-output-audit-and-content/33-14-PLAN.md b/.planning/phases/33-seo-output-audit-and-content/33-14-PLAN.md index 53bcb84c..1cb0c43a 100644 --- a/.planning/phases/33-seo-output-audit-and-content/33-14-PLAN.md +++ b/.planning/phases/33-seo-output-audit-and-content/33-14-PLAN.md @@ -31,6 +31,8 @@ files_modified: - ../sm-seo-plugin/classes/crawl/parse_test.go - ../sm-seo-plugin/classes/crawl/checks_test.go - ../sm-seo-plugin/classes/crawl/fix_test.go + - ../sm-seo-plugin/classes/crawl/compare_test.go + - ../sm-seo-plugin/classes/crawl/render_test.go - ../sm-seo-plugin/tests/elements/schema.test.ts - ../sm-seo-plugin/tests/elements/graph.test.ts - ../sm-seo-plugin/tests/elements/links.test.ts @@ -49,17 +51,18 @@ autonomous: false requirements: [SC-4, SC-1, SC-2, SC-3, D-106, D-90, D-97, D-30, D-43, D-50, D-70, D-14] estimate: - tokens: 200000 - raw_tokens: 200000 - tasks: 5 + tokens: 205000 + raw_tokens: 205000 + tasks: 4 confidence: low must_haves: truths: - - "The phase's new code has unit tests, delivered in this last plan (ROADMAP success criterion 4, project rule): framework (descriptor fields, RequestOptions, attach variants and the WebP encoder), sm-sitemap-plugin (writer, slots, lastmod, News, IndexNow, HTML groups, slugs), sm-seo-plugin (schema builder and validators, custom JSON-LD, pagination, alternates, breadcrumbs, sitemap filter, registry, auto-links, crawler parse/checks/fix routes, elements E8–E10 and the parent select), sm-journal-plugin (heading conversion, image alt, content facts, related scoring, editor outline and forms), and the layers (routes, pagination, post title, FAQ block) (SC-4)" + - "The phase's new code has unit tests, delivered in this last plan (ROADMAP success criterion 4, project rule): framework (descriptor fields, RequestOptions, attach variants and the WebP encoder), sm-sitemap-plugin (writer, slots, lastmod, News, IndexNow, HTML groups, slugs), sm-seo-plugin (schema builder and validators, custom JSON-LD, pagination, alternates, breadcrumbs, sitemap filter, registry, auto-links, crawler parse/checks/fix routes, the rendered comparison's compareRendered, allowRequest and resolverRules, elements E8–E10 and the parent select), sm-journal-plugin (heading conversion for post content and category pillar text, image alt, content facts, related scoring, editor outline and forms), and the layers (routes, pagination, post title, FAQ block) (SC-4)" - "bash scripts/check-phase33.sh --all passes with Docker, every repository present and no skips: self-test, go, plugins, layers (tests, playground builds, smokes, build-guard fixtures), consumers, docker, admin, docs, marker, additive and compat (Wave 0 gate)" - "The framework release is a patch tag (the next v0.5.x read from git tag, expected v0.5.2), strictly additive against v0.5.1 and naming no SEO concept or consuming application (D-106, D-90)" - - "Nothing is pushed, tagged, created on git.golem15.com, deployed or posted before Jakub's go-ahead at Task 4, which names the versions, the pushes, the changelog posts and any deploys (2026-10-28 release cadence: all tags in 33-14)" + - "Nothing is pushed, tagged, created on git.golem15.com, deployed or posted before Jakub's go-ahead at Task 3, which names the versions, the pushes, the changelog posts and any deploys (2026-10-28 release cadence: all tags in 33-14)" + - "When 33-13 deployed uat.grzybyfunkcjonalne.pl, Jakub checks it at the same checkpoint (the list at the end of 33-13-deploy-log.md) before any tag; a problem there is answered with hold" - "After the go-ahead, repositories are released in order — framework → sm-journal-plugin → sm-sitemap-plugin (breaking: next minor) → sm-seo-plugin → vue-seo-layer (created with tea) → vue-journal-layer, vue-menu-layer, vue-faq-layer → apps — each plugin requiring the new tags, the layers pinning the new vue-seo-layer tag instead of link:, and each app pinning tags with plugin pointer bumps as separate commits after the plugin is pushed (2026-10-28 release cadence, CLAUDE.md)" - "The 33-12 and 33-13 patches are applied to the apps after the tags, with grzybyfunkcjonalne-app's sm-seo-plugin submodule added at its tag; each app builds and passes its tests on the tagged versions; sm-demo-app's check-pins passes on a clean worktree" - "SummerCMS's release tag gets its changelog post on summercms.io the same day through scripts/changelog-post.py (source, then publish); the g15_ token is read by the script and never printed (CLAUDE.md)" @@ -121,6 +124,7 @@ Output: test commits in each repository, the gate script, the filled validation **Planner decisions recorded here:** - **P33-52 (versions proposed at the checkpoint, read from git tag):** framework next patch (v0.5.2); sm-journal-plugin next patch or minor (new settings, type and API keys are additive: patch v0.2.6 unless Jakub prefers v0.3.0); sm-sitemap-plugin next minor (breaking, v0.2.0); sm-seo-plugin next minor (new requires and features, v0.2.0); vue-seo-layer v0.1.0; vue-journal-layer next minor (drops @nuxtjs/seo: v0.7.0); vue-menu-layer and vue-faq-layer next minor (v0.5.0). The answer fixes them. - **P33-53 (release steps are resumable):** each step is: check the log; do it; verify (`git ls-remote --tags origin <tag>`, build of the dependent); append the log line with SHAs. A failure stops the run and is logged; nothing is force-pushed. +- **P33-57 (four tasks, 2026-10-28 revision):** the gate completion that used to be its own task is split by language into the two unit-test tasks (Go stages with the Go tests, the other stages with the TypeScript tests), and 33-13's uat check joins the release checkpoint, so the plan keeps four tasks without a task spanning more repositories than before. The unit tests and the release cannot leave this plan (project rule: unit tests are the last plan; CONTEXT 2026-10-28 release cadence: all tags in 33-14). </objective> <execution_context> @@ -150,67 +154,51 @@ Layer git-install check: ../vue-journal-layer/scripts/check-git-install.sh <tag> ## Execution units and resume points - Test commits per repository staged by path; planning docs (VALIDATION, release log) in their own commits here. Other sessions' uncommitted files stay untouched in every repository. -- The executor stops at Task 4 (checkpoint) and the continuation runs Task 5; Task 5 is resumable from 33-release-log.md (P33-53). +- The executor stops at Task 3 (checkpoint) and the continuation runs Task 4; Task 4 is resumable from 33-release-log.md (P33-53). | Task | Commit groups, in order | Resume point | |------|-------------------------|--------------| -| 1 | `test: complete the phase 33 gate` | check-phase33.sh --all passes with the existing tests | -| 2 | one `test: unit tests for …` commit per Go repository (framework, sitemap, seo, journal) | Go unit tests pass | -| 3 | one `test: …` commit per TS repository (sm-seo-plugin elements, sm-journal-plugin editor, vue-seo-layer, vue-journal-layer, vue-faq-layer); `docs(33-14): validation map` | gate --all green again; VALIDATION filled | -| 4 | none (go-ahead recorded at the top of the release log) | answer recorded | -| 5 | release steps logged one by one in 33-release-log.md | every named step logged done | +| 1 | `test: phase 33 gate, Go stages`; one `test: unit tests for …` commit per Go repository (framework, sitemap, seo, journal) | the Go stages of the gate pass with the new Go unit tests | +| 2 | `test: phase 33 gate, layer, consumer and admin stages`; one `test: …` commit per TS repository (sm-seo-plugin elements, sm-journal-plugin editor, vue-seo-layer, vue-journal-layer, vue-faq-layer); `docs(33-14): validation map` | gate --all green; VALIDATION filled | +| 3 | none (go-ahead recorded at the top of the release log) | answer recorded | +| 4 | release steps logged one by one in 33-release-log.md | every named step logged done | <tasks> <task type="tracer"> - <name>Task 1: End-to-end "the phase gate runs every repository's tests, builds, smokes and guards and passes" — the gate across all 13 repositories</name> - <files>scripts/check-phase33.sh</files> + <name>Task 1: End-to-end "the Go side of the phase gate runs the Go repositories' new unit tests, Docker suites and guards and passes" — framework, sm-sitemap-plugin, sm-seo-plugin and sm-journal-plugin</name> + <files>scripts/check-phase33.sh, modules/pact/content_test.go, modules/cabana/field_request_options_test.go, modules/lagoon/attach/variant_test.go, modules/lagoon/attach/webp/webp_test.go, ../sm-sitemap-plugin/classes/writer_test.go, ../sm-sitemap-plugin/classes/slots_test.go, ../sm-sitemap-plugin/classes/lastmod_test.go, ../sm-sitemap-plugin/classes/news_test.go, ../sm-sitemap-plugin/classes/indexnow_test.go, ../sm-sitemap-plugin/classes/htmlgroups_test.go, ../sm-sitemap-plugin/classes/types_test.go, ../sm-seo-plugin/classes/schema/graph_test.go, ../sm-seo-plugin/classes/schema/validate_test.go, ../sm-seo-plugin/classes/schema/custom_test.go, ../sm-seo-plugin/classes/pagination_test.go, ../sm-seo-plugin/classes/alternates_test.go, ../sm-seo-plugin/classes/breadcrumbs_test.go, ../sm-seo-plugin/classes/sitemap_filter_test.go, ../sm-seo-plugin/classes/registry_test.go, ../sm-seo-plugin/classes/autolinks_test.go, ../sm-seo-plugin/classes/crawl/parse_test.go, ../sm-seo-plugin/classes/crawl/checks_test.go, ../sm-seo-plugin/classes/crawl/fix_test.go, ../sm-seo-plugin/classes/crawl/compare_test.go, ../sm-seo-plugin/classes/crawl/render_test.go, ../sm-journal-plugin/classes/headings_test.go, ../sm-journal-plugin/classes/image_alt_test.go, ../sm-journal-plugin/classes/content_facts_test.go, ../sm-journal-plugin/classes/related_test.go</files> <read_first> - scripts/check-phase33.sh (33-01) and scripts/check-phase32.sh (stage engine) - - every 33-0x-SUMMARY.md and 33-1x-SUMMARY.md "Artifacts" and verification commands - - .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md - </read_first> - <action> - Complete the stages of check-phase33.sh with what the plans delivered: plugins (the three Go plugins' vet and `test -short`, the element and editor vitest suites, both dist checks), layers (per layer: tests, playground build, its smoke command, journal's second expectation file with the postTitleTag env, vue-seo-layer guard:check and purge checks), consumers (sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and sm-bm-app build as committed; the 33-12/33-13 patches checked with `git apply --check`), docker (the Docker suites named in the SUMMARYs of the three plugins and the framework attach tests), admin, docs, marker (framework diff since v0.5.1), additive and compat. Run `bash scripts/check-phase33.sh --self-test` and `--all` with Docker (if the Docker socket is denied, run through `sudo -u golem` per CLAUDE.md); fix gate bugs, never weaken a stage. Commit the script. - </action> - <verify> - <automated>bash scripts/check-phase33.sh --self-test && bash scripts/check-phase33.sh --all 2>&1 | tail -25</automated> - <fails_when>non-zero exit, a "FAIL", "refuse:", "missing " or "SKIP" line, or no "Phase 33 gate passed" line</fails_when> - </verify> - <acceptance_criteria> - - the --all output lists PASS for all eleven stages - - `grep -n "guard:check\|expect-acme-title-h1" scripts/check-phase33.sh` prints at least two lines - </acceptance_criteria> - <done>One command proves the whole phase across every repository.</done> -</task> - -<task type="auto" tdd="true"> - <name>Task 2: Go unit tests for the framework, sm-sitemap-plugin, sm-seo-plugin and sm-journal-plugin</name> - <files>modules/pact/content_test.go, modules/cabana/field_request_options_test.go, modules/lagoon/attach/variant_test.go, modules/lagoon/attach/webp/webp_test.go, ../sm-sitemap-plugin/classes/writer_test.go, ../sm-sitemap-plugin/classes/slots_test.go, ../sm-sitemap-plugin/classes/lastmod_test.go, ../sm-sitemap-plugin/classes/news_test.go, ../sm-sitemap-plugin/classes/indexnow_test.go, ../sm-sitemap-plugin/classes/htmlgroups_test.go, ../sm-sitemap-plugin/classes/types_test.go, ../sm-seo-plugin/classes/schema/graph_test.go, ../sm-seo-plugin/classes/schema/validate_test.go, ../sm-seo-plugin/classes/schema/custom_test.go, ../sm-seo-plugin/classes/pagination_test.go, ../sm-seo-plugin/classes/alternates_test.go, ../sm-seo-plugin/classes/breadcrumbs_test.go, ../sm-seo-plugin/classes/sitemap_filter_test.go, ../sm-seo-plugin/classes/registry_test.go, ../sm-seo-plugin/classes/autolinks_test.go, ../sm-seo-plugin/classes/crawl/parse_test.go, ../sm-seo-plugin/classes/crawl/checks_test.go, ../sm-seo-plugin/classes/crawl/fix_test.go, ../sm-journal-plugin/classes/headings_test.go, ../sm-journal-plugin/classes/image_alt_test.go, ../sm-journal-plugin/classes/content_facts_test.go, ../sm-journal-plugin/classes/related_test.go</files> - <read_first> - - each package's code from the SUMMARYs; the Phase 32 unit test style: ../sm-seo-plugin/classes/{resolver_test,issues_test,helpers_test}.go, modules/cabana/form_extensions_test.go, modules/lagoon/attach/thumb_test.go + - every 33-0x-SUMMARY.md and 33-1x-SUMMARY.md "Artifacts" and verification commands (the Go symbols to test and the Docker suites to run) + - the Phase 32 unit test style: ../sm-seo-plugin/classes/{resolver_test,issues_test,helpers_test}.go, modules/cabana/form_extensions_test.go, modules/lagoon/attach/thumb_test.go </read_first> <behavior> - - Table-driven, -short-compatible where no database is needed: XML escaping and namespaces, no priority element; slot assignment with deletes, appends and size change; lastmod ignore list and locale rows; News window boundaries (47 h/49 h) and the 1000 cap; IndexNow chunking at 10 000, key format, host filter, answer mapping; HTML groups order, tree and paging; default slug pluralisation; @id links per kind and the publisher/local node; every validator rule; custom JSON-LD caps, script refusal and merge; pagination parse, 301 and invalid cases per form; alternates and x-default; breadcrumb chain with primary parent, fallback and depth cap; filter reasons; route-report merge never deleting; auto-link locale/self filtering; crawler parse of headings, links, images, JSON-LD and hreflang on HTML fixtures; each check's positive and negative case; fix routes; ConvertH1 byte preservation; alt rewrite escaping; content facts (decorative, videos, headings); related scoring ties; RequestOptions merge precedence and fail-open; VariantKey; WebP encode header and idempotent variant + - Table-driven, -short-compatible where no database is needed: XML escaping and namespaces, no priority element; slot assignment with deletes, appends and size change; lastmod ignore list and locale rows; News window boundaries (47 h/49 h) and the 1000 cap; IndexNow chunking at 10 000, key format, host filter, answer mapping; HTML groups order, tree and paging; default slug pluralisation; @id links per kind and the publisher/local node; every validator rule; custom JSON-LD caps, script refusal and merge; pagination parse, 301 and invalid cases per form; alternates and x-default; breadcrumb chain with primary parent, fallback and depth cap; filter reasons; route-report merge never deleting; auto-link locale/self filtering; crawler parse of headings, links, images, JSON-LD and hreflang on HTML fixtures; each check's positive and negative case; fix routes (including the fitting-tool rule: H1 → headings tool, a category's skipped level → code); ConvertH1 byte preservation; alt rewrite escaping; content facts (decorative, videos, headings, category pillar headings); related scoring ties; RequestOptions merge precedence and fail-open; VariantKey; WebP encode header and idempotent variant + - Rendered comparison (33-10 Task 2 and 4): compareRendered reports a same-host link, an H1 and a 50-word and 20 % text growth present only after JavaScript, ignores other-host links and smaller growth, and returns nothing for identical facts; allowRequest accepts https on the site host and its www twin, data: and blob:, refuses other hosts, http (except in trusted mode) and every other scheme; resolverRules starts with MAP * ~NOTFOUND and maps exactly the allowed hosts; NewRenderer gives skipped for an empty path and failed for a relative or non-executable one </behavior> <action> - Write the unit tests per behavior list in the files named (package-internal tests where the code is unexported), each repository's `go vet ./...` and `go test -short ./...` green, Docker suites still green. One commit per repository. + Complete the Go side of check-phase33.sh with what the plans delivered: go (framework vet and `test -short`, including the attach and webp packages), plugins' Go part (the three Go plugins' vet and `test -short`; the element and editor vitest suites and both dist checks stay in the stage, unchanged), docker (the Docker suites named in the SUMMARYs of the three plugins, TestChromeRenderer when a Chrome binary is present, and the framework attach tests), docs, marker (framework diff since v0.5.1), additive and compat. Then write the Go unit tests per the behavior list in the files named (package-internal tests where the code is unexported); each repository's `go vet ./...` and `go test -short ./...` green, Docker suites still green. Run `bash scripts/check-phase33.sh --self-test` and the Go stages with Docker (if the Docker socket is denied, run through `sudo -u golem` per CLAUDE.md); fix gate bugs, never weaken a stage. Commit the script, then one test commit per repository. </action> <verify> - <automated>go test -short ./modules/pact ./modules/cabana ./modules/lagoon/attach/... -count=1 2>&1 | tail -6 && for r in sm-sitemap-plugin sm-seo-plugin sm-journal-plugin; do go -C ../$r vet ./... && go -C ../$r test -short ./... -count=1 2>&1 | tail -4 || exit 1; done</automated> + <automated>bash scripts/check-phase33.sh --self-test && bash scripts/check-phase33.sh --go --plugins --docker --docs --marker --additive --compat 2>&1 | tail -20</automated> + <fails_when>non-zero exit, or a "FAIL", "refuse:", "missing " or "SKIP" line in the output</fails_when> + <automated>go test -short ./modules/pact ./modules/cabana ./modules/lagoon/attach/... -count=1 2>&1 | tail -6 && go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -short ./... -count=1 2>&1 | tail -4 && go -C ../sm-seo-plugin vet ./... && go -C ../sm-seo-plugin test -short ./... -count=1 2>&1 | tail -4 && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./... -count=1 2>&1 | tail -4</automated> <fails_when>non-zero exit or a "FAIL" line in any output</fails_when> </verify> <acceptance_criteria> - every test file in this task's files exists and contains at least one `func Test` - `go -C ../sm-seo-plugin test -short ./classes/schema/... ./classes/crawl/... -count=1 -v 2>&1 | grep -c -- "--- PASS"` is at least 30 + - `grep -n "TestChromeRenderer" scripts/check-phase33.sh` prints at least one line </acceptance_criteria> - <done>The Go code of the phase is covered by unit tests.</done> + <done>The Go code of the phase is covered by unit tests, and the Go side of the gate proves it with the Docker suites and guards.</done> </task> <task type="auto" tdd="true"> - <name>Task 3: TypeScript unit tests for the elements, the editor and the layers; the validation map</name> - <files>../sm-seo-plugin/tests/elements/schema.test.ts, ../sm-seo-plugin/tests/elements/graph.test.ts, ../sm-seo-plugin/tests/elements/links.test.ts, ../sm-seo-plugin/tests/elements/parent.test.ts, ../sm-journal-plugin/tests/blogeditor/outline.test.ts, ../sm-journal-plugin/tests/blogeditor/forms.test.ts, ../vue-seo-layer/tests/routes.test.ts, ../vue-seo-layer/tests/usePagination.test.ts, ../vue-journal-layer/tests/postTitle.test.ts, ../vue-faq-layer/tests/block.test.ts, .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md</files> + <name>Task 2: TypeScript unit tests for the elements, the editor and the layers; the remaining gate stages, --all and the validation map</name> + <files>scripts/check-phase33.sh, ../sm-seo-plugin/tests/elements/schema.test.ts, ../sm-seo-plugin/tests/elements/graph.test.ts, ../sm-seo-plugin/tests/elements/links.test.ts, ../sm-seo-plugin/tests/elements/parent.test.ts, ../sm-journal-plugin/tests/blogeditor/outline.test.ts, ../sm-journal-plugin/tests/blogeditor/forms.test.ts, ../vue-seo-layer/tests/routes.test.ts, ../vue-seo-layer/tests/usePagination.test.ts, ../vue-journal-layer/tests/postTitle.test.ts, ../vue-faq-layer/tests/block.test.ts, .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md</files> <read_first> + - scripts/check-phase33.sh (after Task 1) and .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md - ../sm-seo-plugin/tests/elements/{host.ts, serp.test.ts} and ../sm-journal-plugin/tests/blogeditor/{helpers.ts, element.test.ts} (harness) - ../vue-seo-layer/tests/*.test.ts and ../vue-journal-layer/tests/*.test.ts (node --test style) - .planning/phases/33-seo-output-audit-and-content/33-UI-SPEC.md (C17, C18, C19, C28, L2, L5, L6 backstops) @@ -228,36 +216,39 @@ Layer git-install check: ../vue-journal-layer/scripts/check-git-install.sh <tag> - FAQ block: headingLevel 0 renders p with aria-labelledby; FAQPage node text stripped of markup </behavior> <action> - Write the tests per behavior list; `npm --prefix <repo> run test` green in each repository; rebuild nothing (tests only). Fill 33-VALIDATION.md's per-task verification map with the real commands of every plan and their status, tick the sign-off list and set `nyquist_compliant: true` and `wave_0_complete: true` when all rows are green. Re-run `bash scripts/check-phase33.sh --all`. Commit per repository, then the validation map here. + Write the tests per behavior list; `npm --prefix <repo> run test` green in each repository; rebuild nothing (tests only). Complete the remaining stages of check-phase33.sh with what the plans delivered: layers (per layer: tests, playground build, its smoke command, journal's second expectation file with the postTitleTag env, vue-seo-layer guard:check and purge checks), consumers (sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and sm-bm-app build as committed; the 33-12/33-13 patches checked with `git apply --check`) and admin; fix gate bugs, never weaken a stage. Run `bash scripts/check-phase33.sh --all` with Docker. Fill 33-VALIDATION.md's per-task verification map with the real commands of every plan (including the D-70 rendered comparison: TestRenderSkipped, TestChromeRenderer, compare_test.go) and their status, tick the sign-off list and set `nyquist_compliant: true` and `wave_0_complete: true` when all rows are green. Commit the script, then per repository, then the validation map here. </action> <verify> - <automated>for r in sm-seo-plugin sm-journal-plugin vue-seo-layer vue-journal-layer vue-faq-layer vue-menu-layer; do npm --prefix ../$r run test 2>&1 | tail -4 || exit 1; done</automated> + <automated>npm --prefix ../sm-seo-plugin run test 2>&1 | tail -4 && npm --prefix ../sm-journal-plugin run test 2>&1 | tail -4 && npm --prefix ../vue-seo-layer run test 2>&1 | tail -4 && npm --prefix ../vue-journal-layer run test 2>&1 | tail -4 && npm --prefix ../vue-faq-layer run test 2>&1 | tail -4 && npm --prefix ../vue-menu-layer run test 2>&1 | tail -4</automated> <fails_when>non-zero exit, a "not ok" line, a "# fail" count above 0, or a failed count above 0 in a vitest summary</fails_when> - <automated>bash scripts/check-phase33.sh --all 2>&1 | tail -5 && grep -n "nyquist_compliant: true" .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md</automated> - <fails_when>non-zero exit, no "Phase 33 gate passed" line, or the grep printing nothing</fails_when> + <automated>bash scripts/check-phase33.sh --all 2>&1 | tail -15 && grep -n "nyquist_compliant: true" .planning/phases/33-seo-output-audit-and-content/33-VALIDATION.md</automated> + <fails_when>non-zero exit, a "FAIL", "refuse:", "missing " or "SKIP" line, no "Phase 33 gate passed" line, or the grep printing nothing</fails_when> </verify> <acceptance_criteria> - each test file of this task exists with at least three test cases + - the --all output lists PASS for all eleven stages + - `grep -n "guard:check\|expect-acme-title-h1" scripts/check-phase33.sh` prints at least two lines - 33-VALIDATION.md has no "⬜ pending" status left </acceptance_criteria> - <done>The admin elements, the editor and the layers are covered by unit tests, and the validation map is complete.</done> + <done>The admin elements, the editor and the layers are covered by unit tests, one command proves the whole phase across every repository, and the validation map is complete.</done> </task> <task type="checkpoint:human-verify" gate="blocking-human"> - <name>Task 4: Release go-ahead (versions, pushes, the new repository, changelog posts, deploys)</name> - <what-built>All Phase 33 code is committed locally in 13 repositories, the gate passes (Task 1–3), and the app patches apply. Nothing has been pushed, tagged, created remotely, deployed or posted.</what-built> + <name>Task 3: Release go-ahead (versions, pushes, the new repository, changelog posts, deploys), with the uat check of 33-13</name> + <what-built>All Phase 33 code is committed locally in 13 repositories, the gate passes (Tasks 1 and 2), and the app patches apply. Nothing has been pushed, tagged, created remotely, deployed or posted. If 33-13 answered deploy-now, uat.grzybyfunkcjonalne.pl runs the untagged stack and both smokes passed there (33-13-deploy-log.md).</what-built> <how-to-verify> + 0. Only if 33-13 deployed uat (moved here from 33-13 in the 2026-10-28 revision; the list is at the end of 33-13-deploy-log.md): open https://uat.grzybyfunkcjonalne.pl/sitemap.xml (an index of sitemap-posts, sitemap-categories, sitemap-authors and sitemap-pages files; open one file); open a post (view source: one title, one canonical, one application/ld+json script with BlogPosting and BreadcrumbList, breadcrumbs above the content); in /gfadmin, SEO › Pages lists /, /kontakt and /trwaja-prace with their titles and Settings › Sitemaps shows the files table and the migrated Additional addresses; start a crawl in SEO › Crawls and, when it finishes, SEO › Site audit lists issues without staging-lock noindex. Anything wrong: answer "hold" with what is wrong. 1. Review the proposed versions (P33-52, read now from `git tag` in each repository) and the release order of the release log. 2. Review the list of pushes: framework master; sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin main; vue-seo-layer (new repository with tea, name from 33-07) main; vue-journal-layer, vue-menu-layer, vue-faq-layer main; sm-demo-app and vue-demo-app; sm-summercmsio-app and vue-summercmsio-app; sm-grzybyfunkcjonalne-app and vue-grzybyfunkcjonalne-app; sm-bm-app — with the counts of commits each push carries (including other sessions' commits, as in 32-06). 3. Choose the changelog posts (the framework post is required for a framework tag; plugin posts optional). 4. Name any deploys (demo per ../sm-demo-app/DEPLOY.md; uat.grzybyfunkcjonalne.pl per its DEPLOY.md, on the tagged build; bm per its DEPLOY.md) or none. 5. Answer with the versions, the approved pushes, the posts and the deploys. </how-to-verify> - <resume-signal>Reply "release: <versions>; push: <list>; posts: <list>; deploy: <list or none>" or "hold"</resume-signal> + <resume-signal>Reply "release: <versions>; push: <list>; posts: <list>; deploy: <list or none>" or "hold" (with what is wrong on uat, when that is the reason)</resume-signal> </task> <task type="auto"> - <name>Task 5: Release in order, re-pin the apps, post the changelog and deploy what was named — every step logged</name> + <name>Task 4: Release in order, re-pin the apps, post the changelog and deploy what was named — every step logged</name> <files>.planning/phases/33-seo-output-audit-and-content/33-release-log.md</files> <read_first> - .planning/phases/33-seo-output-audit-and-content/33-release-log.md (the go-ahead at its top and any steps already done) @@ -265,7 +256,7 @@ Layer git-install check: ../vue-journal-layer/scripts/check-git-install.sh <tag> - ../sm-summercmsio-app/DEPLOY.md ("Posting to the changelog"), ../sm-demo-app/DEPLOY.md, ../sm-grzybyfunkcjonalne-app/DEPLOY.md - 33-12-local-wiring.md, 33-13-local-wiring.md, 33-13-deploy-log.md </read_first> - <precondition>Task 4 answered "release: …" (not "hold"), and the answer is recorded at the top of 33-release-log.md</precondition> + <precondition>Task 3 answered "release: …" (not "hold"), and the answer is recorded at the top of 33-release-log.md</precondition> <action> Per the 2026-10-28 release cadence, P33-52 and P33-53, only for what the go-ahead names, appending a log line (step, repository, result, SHAs, tag object) after each step: 1. Pre-checks: `bash scripts/check-phase33.sh --all` green; each repository's branch is ahead of origin only by the expected commits; patches pass `git apply --check`. @@ -308,7 +299,7 @@ Layer git-install check: ../vue-journal-layer/scripts/check-git-install.sh <tag> | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| -| T-33-62 | Repudiation/Integrity | unapproved release steps | high | mitigate | blocking go-ahead (Task 4) naming every push, tag, post and deploy; each executed step logged with SHAs | +| T-33-62 | Repudiation/Integrity | unapproved release steps | high | mitigate | blocking go-ahead (Task 3) naming every push, tag, post and deploy; each executed step logged with SHAs | | T-33-63 | Information disclosure | changelog token | high | mitigate | read by changelog-post.py from ~/.config/summercms/journal-token; never printed, copied or minted; refused token → ask Jakub | | T-33-64 | Tampering | breaking published API | high | mitigate | additive and compat stages of the gate before tagging; the framework tag is a patch | | T-33-65 | Tampering | pins to moving targets | medium | mitigate | apps pin tags (go.mod versions, submodule pointers at tags, pnpm git URLs with #tag); check-pins on the demo | @@ -329,7 +320,7 @@ Layer git-install check: ../vue-journal-layer/scripts/check-git-install.sh <tag> - Unit test files listed in files_modified (framework, three Go plugins, element and editor suites, three layers). - Completed `scripts/check-phase33.sh` stages; filled `33-VALIDATION.md` (`nyquist_compliant: true`). -- Release log entries (TAG lines) in `33-release-log.md`; tags (versions fixed at Task 4): framework v0.5.x, sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin, vue-seo-layer, vue-journal-layer, vue-menu-layer, vue-faq-layer; remote repository vue-seo-layer on git.golem15.com; app commits re-pinning the tags and applying the 33-12/33-13 patches; the summercms.io changelog post for the framework tag. +- Release log entries (TAG lines) in `33-release-log.md`; tags (versions fixed at Task 3): framework v0.5.x, sm-journal-plugin, sm-sitemap-plugin, sm-seo-plugin, vue-seo-layer, vue-journal-layer, vue-menu-layer, vue-faq-layer; remote repository vue-seo-layer on git.golem15.com; app commits re-pinning the tags and applying the 33-12/33-13 patches; the summercms.io changelog post for the framework tag. <output> Create `.planning/phases/33-seo-output-audit-and-content/33-14-SUMMARY.md` when done; list for the orchestrator the g15office comment to post and whether g15office task w8kupqi3 still needs closing.