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`.
+
+ 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.
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= 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=
npm --prefix ../vue-seo-layer run test 2>&1 | tail -12
@@ -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): \" 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)"
- " renders the resolve answer's breadcrumbs as nav > ol with server-rendered 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)"
- " 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 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 /; 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)
- 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.
npm --prefix ../vue-seo-layer run test 2>&1 | tail -10
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
- 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.
## 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.