diff --git a/.planning/phases/17-blog-companions/17-RESEARCH.md b/.planning/phases/17-blog-companions/17-RESEARCH.md
new file mode 100644
index 0000000..d8a308d
--- /dev/null
+++ b/.planning/phases/17-blog-companions/17-RESEARCH.md
@@ -0,0 +1,789 @@
+# Phase 17: Blog companions - Research
+
+**Researched:** 2026-10-11
+**Domain:** Go plugin ports (sitemap, menu, FAQ) on SummerCMS, a framework menu-item type registry, cabana field/list features (repeater, dependent dropdown, richeditor, tree reorder), two Nuxt layers, one site migration
+**Confidence:** HIGH for the port sources, the framework seams and the sitemap byte format (verified against live PHP output and a local PHP 8.5 probe); MEDIUM for the new framework API shapes (design proposals); LOW only where flagged in the Assumptions Log
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+#### Menu-item type registry (framework)
+- **D-01:** The successor of `pages.menuitem.*` is an **optional capability interface**. It follows the `pact.Has*` pattern and could be named something like `HasMenuItemTypes`. A plugin returns typed `MenuItemType` values that carry their own info (label, reference options, nesting support) and a `Resolve` method. A framework registry collects them at boot. There are no stringly festival events for list, info or resolve. — **Reversibility:** costly — Journal, menu, sitemap and every later content plugin (pages, blog) implement this contract.
+- **D-02:** **Headless URLs come from patterns** such as `/blog/:post` and `/:category`, resolved in three layers. Each type declares a default pattern. The host config can override the pattern per type, and that override is how grzyby keeps its P16 D-15 URLs. A menu item or sitemap item can override it once more. — **Reversibility:** costly — the config keys and item fields become a published plugin contract.
+- **D-03:** **Resolved items carry per-locale alternates.** `Resolve` returns title, URL, mtime and a locale→URL alternates map, built from translate-plugin slugs. Nested `items` are returned for types that expand, such as `all-journal-categories`. Sitemap hreflang and localized menus both read these alternates. A single-locale site gets a single entry. — **Reversibility:** costly — the shape of the resolve result.
+- **D-04:** **Journal registers its 5 PHP types under the PHP keys:** `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts` and `category-journal-posts`. The source is `Plugin.php` `extendWinterPagesPlugin` in the frozen Journal tree. This is an additive change to `sm-journal-plugin` and leaves its existing API untouched.
+- **D-05:** **Menu types are menu built-ins plus registry types.**
+ - `url` (link) and `divider` stay menu built-ins.
+ - Every content-backed type comes from the registry.
+ - PHP `blog_categories` maps to Journal's `all-journal-categories`.
+ - PHP `page` (Winter.Pages) is dropped.
+ - The import or DB copy maps the stored type values to the new ones.
+ - **Reversibility:** costly — stored type values in the menu tables.
+
+#### Admin editing (framework first)
+- **D-06:** Close the cabana gaps **in `summercms.go` this phase**, following the P15 markdown-field pattern: generic features with fail-loud schema validation, the README and `docs/` updated, the admin OpenAPI and generated TS types refreshed, and `dist/` committed in the same change. — **Reversibility:** costly — new field types in the typed admin schema that later plugins will use.
+- **D-07:** **`repeater` field type.** It edits a JSON list stored in a jsonable column, with a nested `form.fields`. FAQ `content` (question/answer) uses it, and so do the sitemap Definition items (`data`).
+- **D-08:** **A dropdown with options that depend on a sibling field** (Winter's `dependsOn`). Its options come from a provider keyed on the value of the sibling `type` field and are served through the registry's type info. The sitemap repeater rows and the menu entry form use this same type/reference binding, so the registry has exactly one admin binding.
+- **D-09:** **Drag-and-drop tree reorder page.** It succeeds Winter's ReorderController and works on any `parent_id` + `sort_order` model, writing both columns. Menu entries use it, scoped to one menu. It replaces the PHP reorder screen and the "Children" partial.
+- **D-10:** **`richeditor` field type (HTML WYSIWYG)** in cabana and the SPA, which folds in the todo `cabana-richeditor-field-type.md`. The server sanitizes on save, the way Journal's `classes.CheckHTML` does, and the field must not bypass that gate. FAQ `description` and the repeater `answer` use it, matching PHP. Journal's category `content` switches from `textarea` to `richeditor`, as that todo asks. This decision replaces an earlier choice of the markdown field: the markdown preview drops raw HTML, so it can't show the imported PHP FAQ HTML. — **Reversibility:** costly — a new field type plus a JS editor dependency in the admin SPA.
+
+#### Sitemap headless adaptation
+- **D-11:** **One definition per app.** The `theme` column stays so the schema is 1:1, filled with a fixed key. `/sitemap.xml` serves that single definition. The admin opens it directly, like a settings page, the same way PHP auto-created one definition per theme.
+- **D-12:** **Item types are URL plus registry types only.** Static Page, All Static Pages and the CMS Page field are dropped. In the user's words: "no static pages or cms pages, just URL items, we configure it like that in wintercms sitemap plugin on horoskopia etc". `updates_manifest_path` is ported 1:1 as an optional setting.
+- **D-13:** **Priority tiers stay, with configurable patterns.** `enable_priority_tiers` and the tier values port unchanged. The tier path patterns move from code to settings or host config. The PHP golem15.com patterns (`/uslugi`, `/realizacje`, legal pages and so on) become documented defaults or an example, not hardcoded behavior. A site configured the same way must produce the same output as PHP.
+- **D-14:** `sitemap.xml` and `sitemap.xsl` keep their PHP behavior:
+ - the `frontend_url` rewrite of ``, of `xhtml:link href` and of the XSL href
+ - `frontend_default_locale`
+ - the 50,000 URL limit
+ - XML escaping
+
+ The PHP `tests/security` cases (`XmlEscapingTest`, `MassAssignmentTest`) are a behavioral spec. The `golem15.sitemap.beforeAddItem` and `addItem` hooks become typed festival events (planner's call on shape).
+- **D-15:** **grzybyfunkcjonalne.pl switches to the plugin's sitemap.** nginx routes `/sitemap.xml` and `/sitemap.xsl` to Go, the Nuxt `@nuxtjs/seo` sitemap module is disabled in the app (or layer config), and editors manage the sitemap in `/gfadmin`. This reverses **P16 D-13** for the sitemap only; per-post meta, OG, canonical and robots stay on `@nuxtjs/seo`. — **Reversibility:** reversible — the nginx route and the Nuxt module toggle.
+
+#### Public APIs + Nuxt consumption
+- **D-16:** **Menu API: `GET /_menu/api/v1/menus/{code}`**, mirroring `/_journal/api/v1`, anonymous. It returns the published, nested tree with registry types already resolved: URLs built, expanding types expanded into child items, plus title, target, css_class, icon and code. The Nuxt side only renders, and computes the active state from the current route. — **Reversibility:** one-way — a published API that the layers and later sites consume.
+- **D-17:** **FAQ API: `GET /_faq/api/v1/faqs/{slug}?locale=pl`**, active rows only. It returns name, slug, description and content `[{question, answer}]`, with HTML sanitized server-side. The `locale_id` → translate `Locale` relation stays 1:1 (one FAQ row per locale), and the locale defaults to the app default. — **Reversibility:** one-way — published API.
+- **D-18:** **Sibling Nuxt layers `vue-menu-layer` and `vue-faq-layer`**, new repos under `git.golem15.com/golem15/`. They use the same distribution as `vue-journal-layer` (P16 D-02 to D-04: pnpm git dependency pinned to a tag, a neutral design driven by CSS tokens, components the app can override). Each one mirrors one plugin, so a site without a blog can use them without Journal. Each ships a typed client and render components. — **Reversibility:** costly — repo and distribution layout.
+- **D-19:** **grzyby data is copied from the PHP DB.** Tables keep their PHP names and columns, so rows for the menu, the FAQ (slug `what-mushrooms-can-give-you`) and the sitemap definition are copied from the live Winter DB as a scripted, ordered migration step, the same way P16 handles its import. Menu and sitemap type values are mapped (D-05). After the copy, the P16 typed menu config and the static FAQ content are removed from `vue-grzybyfunkcjonalne-app`, which then uses the new layers.
+
+### Claude's Discretion
+- Go package, module and identifier names for the registry, and whether it lives in `pact` (interface) plus an existing or new module (registry), provided D-01 to D-03 hold and the module README and docs are updated.
+- Config key names for the per-type URL patterns and for the priority tier patterns.
+- The exact JSON field names in the menu and FAQ responses, provided they cover the fields listed in D-16 and D-17.
+- Which JS WYSIWYG editor the SPA uses for `richeditor`. It must be stdlib-first-compatible and add no Go dependency; if an npm dependency is needed, name it in research.
+- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". The framework work (registry plus cabana features) likely lands first and gets a framework tag.
+- Whether the PHP sitemap `ImportCommand` JSON format is kept byte-compatible. Default: yes, port 1:1.
+- Admin permissions and navigation for the three plugins follow the PHP `Plugin.php` registrations.
+- Run the security-review agent: the phase adds public HTTP endpoints and an HTML-accepting field type.
+
+### Folded Todos
+- `sitemap-plugin-port.md`: the Golem15.Sitemap 1:1 port, adapted per D-11 to D-15.
+- `menu-plugin-port.md`: the Golem15.Menu port with a public read API, per D-05, D-09 and D-16.
+- `faq-plugin-port.md`: the Golem15.Faq port with a read API by slug, per D-07, D-10 and D-17.
+- `cabana-richeditor-field-type.md`: the cabana `richeditor` field, plus switching the Journal category content to it (D-10).
+
+### Deferred Ideas (OUT OF SCOPE)
+- Pages plugin (Winter.Pages successor) and its Static Page menu and sitemap types: dropped here per D-12. A future pages port would register through the D-01 registry.
+- Sitemap definitions keyed per host, for multi-domain apps.
+- FAQ index listing endpoint (`GET /faqs`).
+
+#### Reviewed Todos (not folded)
+- `journal-author-profile-fields.md`: belongs to the Journal author API work and quick task 261009-mys, not this phase.
+- `nest-framework-packages-under-modules.md`: keyword match only; Phase 10.2 covers it.
+- The remaining matches (READMEs, scaffold, wristband, lagoon, benchmark, translate JSON editor): keyword matches only.
+
+
+
+## Phase Requirements
+
+No requirement IDs are assigned (ROADMAP: "Requirements: TBD"). The success criteria and CONTEXT decisions are the source items.
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| SC-1 | Three plugins port PHP schema, models, admin screens and import commands 1:1, each in its own repo with a README | §PHP port inventory, §Recommended Project Structure, §YAML adaptations (Pitfall 9), §Data copy |
+| SC-2 | Typed successor of `pages.menuitem.listTypes/getTypeInfo/resolveItem`; Journal registers through it; menu and sitemap consume it | §Pattern 1 (registry), §Pattern 2 (pattern layers), §Journal type semantics, Pitfalls 3-5 |
+| SC-3 | `sitemap.xml`/`sitemap.xsl` match PHP output incl. `frontend_url` rewrite and hreflang; menu and FAQ public read APIs | §Pattern 4 (byte-exact serializer), §Url::to port, Pitfalls 1-2, 6-8; §Pattern 8/9 (APIs) |
+| SC-4 | grzybyfunkcjonalne.pl reads menu and FAQ from the plugins | §Data copy (D-19), §Runtime State Inventory, §Nuxt layers |
+| SC-5 | Unit tests in the last plan | §Validation Architecture |
+| D-01..D-05 | Registry, patterns, alternates, Journal types, menu types | §Pattern 1-3 |
+| D-06..D-10 | cabana repeater, dependent dropdown, tree reorder, richeditor | §Pattern 5-7, §Conflict with Phase 16.2 |
+| D-11..D-15 | Sitemap headless adaptation | §Pattern 4, §Singleton definition |
+| D-16..D-19 | APIs, layers, data copy | §Pattern 8-10 |
+
+
+## Project Constraints (from CLAUDE.md)
+
+From `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global instructions:
+
+- `go vet ./...` and `go test ./...` stay green at every commit; Docker tests skip under `-short`, and a `TestMain` that starts containers must call `flag.Parse()` and check `testing.Short()`.
+- One Go module `git.golem15.com/golem15/summercms`; a new framework module gets a beach name, a README in the standard structure (H1, summary sentence, import line, `**Name:**` mnemonic, Overview, Features, Usage, API reference, optional Configuration/CLI, Dependencies, Testing) and a row in the root `README.md` table.
+- Compiled plugins only: plugins implement `pact` capability interfaces; no runtime plugin loading.
+- YAML is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`.
+- **Standard library first. A new dependency needs a research doc or a phase/decision note naming it** (this file names bluemonday for the framework and TipTap for the admin SPA; a decision note is still recommended, see Pitfall 12).
+- A change to a module's exported API, config keys, CLI commands or dependencies updates that module's README and the affected `docs/` pages **in the same commit**; every identifier in a README/docs page must exist (`summer docs:build --check`, `go test ./cmd/summer -run TestDocsTree`).
+- Admin changes: swag annotations → `scripts/check-admin-openapi.sh` → `admin/openapi/admin.json` → `admin/src/api/schema.d.ts`; rebuild and commit `modules/boardwalk/dist`, pass `scripts/check-admin-dist.sh` before tagging.
+- Framework code, READMEs and docs never name a consuming application (no "grzyby", no "golem15.com" in framework or plugin READMEs; use `acme`, `blog`).
+- Shared plugins live in `sm--plugin` repos, module path = repo path, mounted as submodules at `plugins//`; commit and push the plugin first, then the pointer bump in the app as a separate commit. Never stage files inside a submodule from the app repo.
+- Core plugin contracts (routes, payloads, tables, migration IDs, config keys) stay non-breaking (Journal's existing API untouched, D-04).
+- Lean planning: few large plans; present plan count with one-line scopes and wait for confirmation. **Unit tests are the last plan.** State via `msd_run query state.*` handlers. Post a short g15office comment when the phase advances.
+- No co-author tags in commits (user global rule overrides the attribution reminder). Planning docs and code in separate commits. Stage files by explicit path; several sessions work concurrently (another session may be in `modules/cabana`).
+- Don't push, tag or deploy without the user's go-ahead.
+- User memory: for protocol and security code, recommend the maintained Go library plus a decision note, not hand-rolled code.
+
+## Summary
+
+The phase has three layers of work, and the order matters. First, the framework (`summercms.go`) needs a menu-item type registry (a `pact` capability plus a small new module that collects types at boot) and five cabana capabilities the PHP admin screens rely on but cabana lacks today: `repeater`, a dependent dropdown (`dependsOn`), `richeditor`, a tree reorder page, and a way to open the single sitemap definition directly. None of these exist in cabana: `formFieldTypes` is a closed set without them, and there is no `tabs`, `trigger`, `dependsOn`, `cssClass`, `hint` or `section` support [VERIFIED: modules/cabana/form_schema.go:24-30]. Second, the three plugins are ported into new repos (`sm-sitemap-plugin`, `sm-menu-plugin`, `sm-faq-plugin`), and Journal registers its five types. Third, grzybyfunkcjonalne.pl mounts the plugins, copies its PHP rows, switches nginx and Nuxt, and adopts two new layers.
+
+The highest-risk item is SC-3, "sitemap.xml matches the PHP output". It is achievable byte for byte, but not with `encoding/xml`. PHP builds the document with `DOMDocument::saveXML()` (libxml2), and Laravel/Winter `Url::to` normalizes URLs in specific ways: `/` becomes `https://host/`, `/blog/` becomes `https://host/blog`, and path segments are `rawurlencode`d, so `:` becomes `%3A`. Both behaviors were reproduced this session against the local PHP 8.5.10 and the vendored Winter router, and against live output from two production sites. The Go port should use a small hand-written serializer and a `Url::to` port, both pinned by golden fixtures generated from the local PHP. This is the one place where "don't hand-roll" does not apply: the format is fixed by the PHP output, not by XML correctness.
+
+The second risk is a decision conflict. **Phase 16.2 already shipped** the Journal Blog Editor (`blogeditor`) for Journal category content, and it closed the `cabana-richeditor-field-type.md` todo (now in `todos/done/`). D-10's clause "Journal's category content switches from textarea to richeditor" is therefore obsolete, and executing it would regress the editor. The remaining D-10 scope is a core `richeditor` for FAQ `description` and the repeater `answer`. It needs a real HTML sanitizer in the framework: bluemonday v1.0.27, the same library and version Journal uses, plus a decision note, because cabana's markdown gate is the regex blocklist that Journal had to replace.
+
+**Primary recommendation:** Build the framework first, in two plans (Go APIs, then the SPA plus release tag). Then do Journal types and sitemap together, because sitemap parity is only testable with Journal types. Then menu and FAQ, then the site integration (layers, data copy, nginx), and finally unit tests. Generate every PHP-parity golden (XML escaping, `Url::to`, tier priorities, alternates order) with the local `php` binary, and commit the goldens.
+
+### Conflict with shipped work (Phase 16.2), needs user confirmation
+
+| D-10 clause | Status in the repo | Recommendation |
+|---|---|---|
+| "Journal's category `content` switches from `textarea` to `richeditor`" | Already `type: blogeditor` [VERIFIED: ../sm-journal-plugin/models/category/fields.yaml:28 `type: blogeditor`]; todo closed: "Done in Phase 16.2: the Journal `blogeditor`/`mlblogeditor` plugin field type edits category and post HTML content." [VERIFIED: .planning/todos/done/cabana-richeditor-field-type.md] | **Drop this clause.** Leave Journal category content on `blogeditor`. |
+| "folds in the todo `cabana-richeditor-field-type.md`" | Todo is done | Nothing to fold. A core `richeditor` is still needed for FAQ (16.2 deferred "Core cabana `richeditor`/`mlricheditor` type for plugins without their own editor"). |
+| "a JS editor dependency in the admin SPA" | 16.2 D-16 said "The admin SPA itself gains no editor dependency" | D-10 (later and locked) overrides this for the core type. Use the same TipTap 3.31.4 that Journal pins, so the workspace carries one editor version. |
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Menu-item type contract (`HasMenuItemTypes`) | Framework `pact` | — | Every content plugin implements it; pact is the capability home |
+| Type registry (collect, dedupe, look up, pattern layers 1-2) | Framework new module | `party.activate` hook | Built once at boot like phrasebook; fail-loud on duplicate keys |
+| Type info / references / resolve | Owning plugin (Journal) | translate plugin (slugs, locales) | Only the content owner knows its records and translations |
+| Pattern layer 3 (item override), per-locale sitemap expansion, tiers | Consumer plugin (sitemap, menu) | — | Consumer-specific semantics |
+| Repeater, dependent dropdown, richeditor, tree reorder | Framework cabana (API) + admin SPA | — | D-06: generic admin features |
+| HTML sanitizing (richeditor save, FAQ API output) | Framework (new sanitizer package) | FAQ plugin calls it | One policy, maintained library |
+| `/sitemap.xml`, `/sitemap.xsl` | sm-sitemap-plugin (API/backend) | nginx route | PHP parity; Nuxt sitemap disabled |
+| `/_menu/api/v1`, `/_faq/api/v1` | Plugins (API/backend) | surf throttle + host CORS | Mirrors `/_journal/api/v1` |
+| Menu/FAQ rendering, active state | Nuxt layers (SSR + client) | Site app overrides | D-16: Nuxt only renders |
+| Data copy from PHP | App scripts (SQL on PHP server) + plugin import commands | `migrate.sh` ordering | Same pattern as P16 |
+
+## Standard Stack
+
+### Core (Go)
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `github.com/microcosm-cc/bluemonday` | v1.0.27 (latest; 2024-07-04) | HTML sanitizer for `richeditor` saves and FAQ API output | Already the project's sanitizer (`journal-html-sanitizing-bluemonday.md`); latest tag confirmed with `go list -m -json ...@latest` [VERIFIED: proxy.golang.org via go list]. **New to the framework's go.mod**, so it needs a decision note |
+| `golang.org/x/net/html` | v0.58.0 (already in framework go.mod) | Token comparison for byte-stable sanitize, as in Journal | Already a cabana dependency [VERIFIED: go.mod] |
+| `gorm.io/gorm` / `gormigrate/v2` | v1.31.2 / v2.1.7 | Models, raw-SQL migrations in plugins | Framework standard [VERIFIED: go.mod] |
+| `github.com/goccy/go-yaml` | v1.19.2 | fields.yaml parsing (repeater nested form, reorder config) | CLAUDE.md mandate [VERIFIED: go.mod] |
+| stdlib `strings.Builder`, `net/url`, `regexp` | Go 1.27 | Sitemap serializer, `Url::to` port, tier patterns (RE2) | The PHP output format must be matched exactly; see Pattern 4 |
+
+### Core (JS)
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `@tiptap/core` | 3.31.4 | `richeditor` in the admin SPA | Same version Journal pins after the 16.2 human package check [VERIFIED: ../sm-journal-plugin/package.json:14-18; npm registry `npm view` 3.31.4, MIT] |
+| `@tiptap/pm` | 3.31.4 | ProseMirror peer | Required by core [VERIFIED: npm registry] |
+| `@tiptap/starter-kit` | 3.31.4 | Paragraph, headings, bold/italic/strike, lists, blockquote, hard break, **Link and Underline (new in v3)** | Covers the FAQ HTML (`p`, `ul`, `li`, `strong`, `br`, counted from the current 16 answers) [CITED: tiptap.dev/docs/editor/extensions/functionality/starterkit] |
+| Nuxt / Vue | 4.4.8 / 3.5.35 | `vue-menu-layer`, `vue-faq-layer` | Same pins as `vue-journal-layer` [VERIFIED: ../vue-journal-layer/package.json] |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `@tiptap/vue-3` | 3.31.4 | Vue binding | Optional. Mounting `Editor` on a `ref` element with core only avoids it; recommend **not** adding it |
+| `sanitize-html` | 2.18.0 | Client-side mirror in `vue-faq-layer` | Only if the user wants defense in depth in the layer (the Journal layer mirrors its policy this way). Recommended default: server-side only (D-17), with no layer dependency |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Core `richeditor` in cabana + SPA (D-10) | A framework-shipped *plugin field type* on the 16.2 `pact.HasFormFieldTypes` API | Reuses Decode/notices/untouched-skip, but settings and relation forms refuse plugin types and D-10 says "in cabana and the SPA". Stay with the core type and reuse the notices plumbing internally |
+| Native HTML5 drag-and-drop for the tree page | `sortablejs`/`vue-draggable-plus`, `@atlaskit/pragmatic-drag-and-drop` | The SPA already does native DnD in `FileuploadField.vue` (`draggable="true"`, `@dragstart`) [VERIFIED: admin/src/components/form/fields/FileuploadField.vue:904-910]; no new dependency. Add keyboard move/indent buttons for accessibility |
+| Hand serializer for sitemap XML | `encoding/xml` | `encoding/xml` escapes `'`/`"` as `'`/`"` in text and `\n` as `
`, and never self-closes ``, so the output cannot match libxml |
+| bluemonday in the framework | Reuse cabana's partial allowlist walker (`partial_render.go`) | That walker is built for template view models, not stored user HTML; the decision note rejected hand-built gates for this purpose |
+
+**Installation:**
+```bash
+# framework
+go get github.com/microcosm-cc/bluemonday@v1.0.27
+npm --prefix admin install --save-exact @tiptap/core@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4
+# layers (each repo)
+pnpm add -D nuxt@4.4.8 vue@3.5.35 typescript@5.9.3
+```
+
+**Version verification:** `npm view @tiptap/core version` → `3.31.4` (modified 2026-09-30), `@tiptap/pm` 3.31.4, `@tiptap/starter-kit` 3.31.4, `sanitize-html` 2.18.0; `go list -m -json github.com/microcosm-cc/bluemonday@latest` → `v1.0.27`, `2024-07-04T13:09:59Z`. [VERIFIED: npm registry, Go module proxy]
+
+## Package Legitimacy Audit
+
+| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| @tiptap/core | npm | version 11 days (pkg years) | 22.4M/wk | github.com/ueberdosis/tiptap | [SUS] (reason: `too-new` version) | Flagged: planner adds `checkpoint:human-verify` (16.2 already approved this exact version for Journal) |
+| @tiptap/pm | npm | 11 days | 22.4M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged, same checkpoint |
+| @tiptap/starter-kit | npm | 11 days | 19.1M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged, same checkpoint |
+| @tiptap/vue-3 | npm | 11 days | 1.9M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Not recommended (not needed) |
+| sanitize-html | npm | 11 days | — | github.com/apostrophecms/sanitize-html | [SUS] `too-new` | Optional; only if the layer mirror is chosen |
+| github.com/microcosm-cc/bluemonday | Go proxy | v1.0.27, 2024-07 | — | github.com/microcosm-cc/bluemonday | not run (seam is npm/pypi/crates) | Approved by precedent (Journal decision note); framework decision note required |
+
+No `postinstall` scripts on any of the npm packages (`npm view scripts.postinstall` empty). **Packages removed due to [SLOP]:** none. **Packages flagged [SUS]:** the TipTap trio (+ `sanitize-html` if used); every flag is "too-new" on a long-established package. The planner inserts one `checkpoint:human-verify` before `npm install` in the admin SPA, as 16.2 did.
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+ boot (party.activate)
+ plugins ──HasMenuItemTypes──▶ [type registry] ──published on App──┐
+ (Journal: 5 types, dedupe keys, host pattern layer │
+ later pages/blog) │
+ ▼
+ Admin SPA ──GET schema/options──▶ cabana ──DependentOptions──▶ menu/sitemap controller
+ repeater rows, dependent dropdown, │ └─▶ registry.TypeInfo(type).References(ctx)
+ richeditor, tree reorder page ├─save──▶ repeater decode → nested field checks
+ │ richeditor → sanitizer (bluemonday) → meta.notices
+ └─reorder POST──▶ tx: lock scoped set, validate tree, write parent_id+sort_order
+
+ Browser/Nuxt SSR ─GET /_menu/api/v1/menus/{code}──▶ sm-menu-plugin ─▶ entries tree (published)
+ └─registry types ─▶ Resolve(ctx, req) ─▶ children
+ ─GET /_faq/api/v1/faqs/{slug}?locale──▶ sm-faq-plugin ─▶ Locale(code)→row(active) ─▶ sanitize HTML
+ Search engine ──GET /sitemap.xml──nginx──▶ sm-sitemap-plugin
+ Definition(theme=fixed).data items ─┬─ url item → Url::to
+ └─ registry item → Resolve → (list types: one per alternate)
+ → festival BeforeAddItem (skip?) → tier priority → serialize (libxml format) → AddItem event
+ → frontend_url rewrite (loc, xhtml:link href, xsl href) → application/xml
+ ─GET /sitemap.xsl──▶ embedded assets/sitemap.xsl (text/xsl; charset=UTF-8)
+```
+
+### Recommended Project Structure
+
+The layout follows `plugin-layout-winter-directories.md`: each PHP directory maps to a Go package, and `models/` is a leaf package.
+
+```
+summercms.go/
+├── modules/pact/capabilities.go # + HasMenuItemTypes, MenuItemType, ResolveRequest/ResolvedItem
+├── modules// # new beach-named module (e.g. "signpost"): Activate, Registry, Patterns, ReferenceOptions
+├── modules// # new beach-named module (e.g. "seaglass"): bluemonday policy + byte-stable Report
+├── modules/cabana/field_repeater.go # repeater compile/decode/project
+├── modules/cabana/field_dependent.go # dependsOn dropdown, options route, save recheck
+├── modules/cabana/field_richeditor.go # richeditor save gate + notices
+├── modules/cabana/reorder.go # config_reorder.yaml, GET/POST reorder routes
+├── modules/party/registry.go # call .Activate after Register, before Boot
+└── admin/src/components/form/fields/{RepeaterField,RichEditorField}.vue, views/ReorderView.vue
+
+../sm-sitemap-plugin/ plugin.go routes.go assets/sitemap.xsl classes/{generator,urlto,serialize,tiers}.go
+ models/{definition,settings}.go models/{definition,settings}/fields.yaml controllers/definitions/
+ console/import.go updates/ lang/{en,pl}/ config/config.yaml README.md go.mod go.work
+../sm-menu-plugin/ models/{menu,menu_entry}.go controllers/{menus,menuentries}/ controllers/api/ updates/ lang/{en,pl}
+../sm-faq-plugin/ models/faq.go controllers/faqs/ controllers/api/ updates/ lang/{en,pl}
+../sm-journal-plugin/ menu_item_types.go + classes/menu_item_types.go (additive)
+../vue-menu-layer/, ../vue-faq-layer/ nuxt.config.ts lib/*-module.ts app/{components,composables,utils} tests/ testing/
+../sm-grzybyfunkcjonalne-app/ .gitmodules, go.work, go.mod, summer.yaml, config/{http,…}.yaml, deploy/nginx, scripts/php-export/*.sql, scripts/migrate.sh
+```
+
+### PHP port inventory (what "1:1" means)
+
+| Plugin | Tables (keep names/columns) | Permissions | Admin | Commands |
+|---|---|---|---|---|
+| sitemap | `golem15_sitemap_definitions` (`id`, `theme` nullable indexed, `data` mediumtext jsonable, timestamps) [VERIFIED: fonoteka sitemap updates/v1.0.2 + v2.0.0/rename_tables.php:11]; settings (PHP stores them in `system_settings` with item `golem15_sitemap_settings` [VERIFIED: models/Settings.php:13]) → Go settings table | `golem15.sitemap.access_definitions` (developer) [VERIFIED: Plugin.php:42] | Settings entries `definitions` (link) + `settings` (form), category CMS | `sitemap:import {file} {--theme=} {--append}` |
+| menu | `golem15_menu_menus` (`id`, `name`, `code` unique, `is_active` default true, `css_class`), `golem15_menu_menu_entries` (`id`, `menu_id`, `parent_id` null, `nest_left`, `nest_right`, `nest_depth` NOT NULL, `sort_order` null, `type`, `title`, `url`, `reference`, `code`, `icon`, `css_class`, `target`, `is_published` default true) [VERIFIED: menu updates/v1.0.1, v1.0.2] | `golem15.menu.manage_menu` (developer, publisher) [VERIFIED: Plugin.php:77] | Menus, MenuEntries (list top-level of current menu, form, relation `children`, reorder) | none |
+| faq | `golem15_faq_faqs` (`id`, `name`, `slug`, `locale_id` NOT NULL, `is_active` default false, `description` text, `content` longText jsonable) [VERIFIED: faq updates/v1.0.1] | `golem15.faq.faqs` (developer, publisher) [VERIFIED: Plugin.php:60] | Top nav "FAQ" order 500 with side menu `faqs` | none |
+
+The PHP menu type options are, verbatim [VERIFIED: grzyby menu/models/menuentry/fields.yaml:23-27]:
+```yaml
+ options:
+ link: Link
+ page: Page
+ divider: Divider
+ blog_categories: Active Blog Categories
+```
+The Journal PHP type keys are, verbatim [VERIFIED: fonoteka journal/Plugin.php:247-255]:
+```php
+ 'journal-category' => 'golem15.journal::lang.menuitem.journal_category',
+ 'all-journal-categories' => 'golem15.journal::lang.menuitem.all_journal_categories',
+ 'journal-post' => 'golem15.journal::lang.menuitem.journal_post',
+ 'all-journal-posts' => 'golem15.journal::lang.menuitem.all_journal_posts',
+ 'category-journal-posts' => 'golem15.journal::lang.menuitem.category_journal_posts',
+```
+
+### Pattern 1: Menu-item type registry (D-01)
+
+**What:** a `pact` interface with typed values. The registry is built in `party.activate` after `Register` and before `Boot`, the same way as `phrasebook.Activate[P interface{ ID() string }](app, plugins)` [VERIFIED: modules/phrasebook/translator.go:86], and it is published as `app.Publish`. Consumers read it with `app.Lookup[*registry.Registry]()`. Types close over a lazy `func() *backpack.App`, as Journal's field types do [VERIFIED: ../sm-journal-plugin/field_types.go].
+
+```go
+// modules/pact (proposal, names at planner's discretion)
+type MenuItemType struct {
+ Key string // "journal-post"; unique across plugins, boot fails on a duplicate
+ Label string // phrase key
+ DefaultPattern string // "/blog/:slug" (layer 1 of D-02)
+ Placeholders []string // ":slug", ":category", ":id", ":year", ":month", ":day"
+ Nesting bool // PHP 'nesting'
+ DynamicItems bool // PHP 'dynamicItems'
+ References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"` // nil: the type takes no reference
+ Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
+}
+type MenuItemReference struct { Value, Label string; Items []MenuItemReference } // PHP ["title","items"] tree
+type MenuItemResolve struct {
+ Type, Reference string
+ Pattern string // already layered: item override > host config > DefaultPattern
+ Nesting bool
+ UnprefixedLocale string // PHP frontend_default_locale; "" = the type's own default
+}
+type ResolvedMenuItem struct {
+ Title string
+ URL string // "" for pure list types (all-*), as in PHP
+ MTime *time.Time
+ Alternates []MenuItemAlternate // ORDERED (Locale sort_order); never a map (Pitfall 4)
+ Items []ResolvedMenuItem
+}
+type MenuItemAlternate struct { Locale, URL, Title string }
+type HasMenuItemTypes interface { MenuItemTypes() []MenuItemType }
+```
+
+**When to use:** Journal now; pages/blog later. **Don't** put `isActive` in the result: D-16 makes the active state a Nuxt concern.
+
+### Pattern 2: Three pattern layers (D-02)
+
+`pattern = item.urlPattern (if non-empty) ?? hostConfig[type] ?? type.DefaultPattern`. Host config is read once at boot from a registry-owned key, for example `menu_items.patterns` as a YAML map from type key to pattern [ASSUMED key name]. Keep the PHP placeholder names: category `:slug`, `:id`; post `:slug`, `:category`, `:id`, `:year`, `:month`, `:day` [VERIFIED: Category.php:126, Post.php:808]. Stored PHP `urlPattern` values use them, and Journal's PHP `buildUrlFromPattern` is an ordered `str_replace` of `:key`. Suggested Journal defaults match the layer's defaults (post `/blog/:slug`, category `/blog/category/:slug`) [VERIFIED: ../vue-journal-layer/app/utils/routes.ts:10-11]. The grzyby host config sets `journal-post: /blog/:slug` and `journal-category: /:slug`, which keeps P16 D-15 [VERIFIED: vue-grzybyfunkcjonalne-app/nuxt.config.ts:20-21 `post: '/blog/:slug'`, `category: '/:category'`].
+
+### Pattern 3: Journal type semantics to reproduce (headless branch)
+
+All of this comes from the frozen PHP tree [VERIFIED: fonoteka journal/models/Category.php:330-425, Post.php:1005-1160]:
+
+- `journal-category`: needs a reference; `url` = the first alternate (or the pattern built from the default slug); `mtime` = `updated_at`. With `nesting`, `items` = the category subtree, **one entry per category with its default URL only** (not per locale).
+- `all-journal-categories`: no `url`; `items` = every category `orderBy('name')`, **one entry per enabled locale** (same alternates on each).
+- `journal-post`: needs a reference; single URL, with no published filter.
+- `all-journal-posts`: `isPublished()` posts `orderBy('title')`, one entry per locale.
+- `category-journal-posts`: published posts in the category and its descendants (nested set), `orderBy('title')`, one entry per locale.
+- Alternates: when translate has no enabled locales, `{'x-default': url}`. Otherwise, for each enabled locale in `sort_order`, the prefix is `''` for the unprefixed locale and `'/'.code` for the others; the slug is translated per locale (for posts, the category slug is translated too). The year, month and day placeholders are filled only when the post is published and has `published_at`.
+
+**Go mapping:** return per-record items (each with ordered alternates) from `Resolve`. The **sitemap** reproduces PHP's per-locale duplication with one rule that matches all five types: *when the resolved top level has no URL (a pure list type), emit one `` per alternate for each child; otherwise emit each child once with its default URL.* The **menu** then never sees per-locale duplicates.
+
+### Pattern 4: Byte-exact sitemap serializer
+
+Live PHP output, verbatim prefix [VERIFIED: curl https://grzybyfunkcjonalne.pl/sitemap.xml, 2026-10-11]:
+```
+
+
+https://grzybyfunkcjonalne.pl/2026-10-11T15:45:03+00:00always1.0...
+```
+An hreflang entry, verbatim [VERIFIED: curl https://www.golem15.com/sitemap.xml]:
+```
+https://www.golem15.com/en/blog/ai-engineering/ai-agents-workflow-automation2026-02-11T15:30:10+00:00always0.6
+```
+Format facts:
+
+- `\n` after the XML declaration and after the PI.
+- Everything else is on one line, ending in `\n`.
+- An empty urlset self-closes: ``.
+- `` uses `+00:00`, not `Z`. Use the Go layout `2006-01-02T15:04:05-07:00` in UTC.
+- `xhtml:link` self-closes with no space before `/>`, and is emitted only when there are more than 1 alternate.
+- Headers: `Content-Type: application/xml` for the XML and `text/xsl; charset=UTF-8` for the XSL, `Cache-Control: no-cache, private` [VERIFIED: live response headers].
+- The live XSL is byte-identical to `fonoteka/plugins/golem15/sitemap/assets/sitemap.xsl` [VERIFIED: cmp], so embed it unchanged.
+
+libxml escaping, measured with local PHP 8.5.10 `DOMDocument::saveXML()` [VERIFIED: local php probe]:
+- **text nodes:** `&`→`&`, `<`→`<`, `>`→`>`, `\r`→`
`; `"`, `'`, `\t`, `\n` and UTF-8 stay literal.
+- **attributes:** `&`→`&`, `<`→`<`, `>`→`>`, `"`→`"`, `\r`→`
`, `\t`→` `, `\n`→`
`; `'` stays literal.
+
+The generation order follows `Definition::addItemToSet` [VERIFIED: fonoteka sitemap/models/Definition.php]:
+
+1. Stop at `MAX_URLS = 50000`.
+2. Fill a missing `url` with `Url::to($item->url)`.
+3. lastmod = mtime, else the manifest lookup, else **omitted**.
+4. `BeforeAddItem` (skip if handled).
+5. Drop an empty URL; `Url::to` again.
+6. `urlCount++`.
+7. Tier priority, else the item priority.
+8. Emit loc, lastmod, changefreq, priority, then the alternates.
+9. `AddItem` event.
+
+The route then applies the frontend rewrite with `str_replace(backendUrl, frontendUrl)` inside every `…` and every `xhtml:link href`, and on `href="{backendUrl}/sitemap.xsl"`, where `backendUrl = rtrim(url('/'), '/')`.
+
+### Pattern 4b: `Url::to` port (Winter = Laravel + `buildUrl`)
+
+These outputs were measured with the vendored Winter router [VERIFIED: local php + grzybyfunkcjonalne.pl/vendor, `Winter\Storm\Router\UrlGenerator`]:
+
+| Input | Output (`https://api.ex.pl` request) |
+|---|---|
+| `/`, `""` | `https://api.ex.pl/` |
+| `/blog/` | `https://api.ex.pl/blog` |
+| `/blog/zażółć gęślą` | `https://api.ex.pl/blog/za%C5%BC%C3%B3%C5%82%C4%87%20g%C4%99%C5%9Bl%C4%85` |
+| `/en/blog/:slug` | `https://api.ex.pl/en/blog/%3Aslug` |
+| `https://x.pl/q/` (valid URL) | `https://x.pl/q/` (unchanged except buildUrl encoding) |
+| `/a?b=1&c=2` | `https://api.ex.pl/a?b=1&c=2` |
+
+The rules:
+
+- Laravel's `isValidUrl` is `^(#|//|https?://|(mailto|tel|sms):)` or `FILTER_VALIDATE_URL`.
+- Otherwise the result is `root + '/' + trim(path, '/')`.
+- Winter then re-encodes each path segment with `rawurlencode(rawurldecode(seg))`, which escapes everything except `A-Za-z0-9-_.~`. That is **not** Go's `url.PathEscape`, which leaves `:@!$&'()*+,;=` alone.
+- An empty path gets `/`.
+
+Generate a golden table with local PHP and test the Go port against it.
+
+### Pattern 5: Repeater (D-07)
+
+- **YAML:** keep Winter's keys `prompt`, `form.fields`, `maxItems`, `minItems`, `titleFrom`. Refuse `groups` and `style` loudly.
+- **Nested field types:** allow `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (static, method or dependent) and `richeditor`. Refuse `relation`, `relation-manager`, `fileupload`, `widget`, `partial`, `password`, `ml*` and plugin types, so v1 needs no nested file or translation plumbing.
+- **Column:** a `lagoon.Jsonable[[]map[string]any]` or a typed slice [VERIFIED: modules/lagoon/jsonable.go:21 `type Jsonable[T any] struct`].
+- **Save:**
+ - Decode an array of objects. Each row keeps only the nested field names and coerces scalars per the nested type.
+ - `required` applies per row. Errors use path keys such as `content.3.answer`.
+ - Each nested `richeditor` is sanitized, and its notices go to `meta.notices`.
+- **Bypass rule:** today `ProjectWritableFields` drops nested values (`nestedValue` returns true for `[]any`) [VERIFIED: modules/cabana/crud.go `func nestedValue`]. The repeater needs an explicit, schema-driven exemption, not a loosening of that rule.
+- **Settings forms:** they refuse nested values too (`requireSettingInputs`), so either support the repeater there as well, or keep the sitemap definition on a controller form (Pattern 7, recommended).
+
+### Pattern 6: Dependent dropdown (D-08)
+
+- **YAML:** `type: dropdown` with `dependsOn: type` (a sibling field in the same form, or the same repeater row; boot fails if the sibling is missing).
+- **Options:** a new controller capability, for example `pact.DependentOptionsProvider{ DependentOptions(ctx, field string, values map[string]any) ([]Option, error) }`. The route is `GET .../fields/{field}/dependent-options?values[type]=journal-post`; for a nested field, the field path is `data.reference` [ASSUMED shape]. The existing `pact.DropdownOptionsProvider.DropdownOptions(field string) []Option` takes no `ctx` or database handle [VERIFIED: modules/pact/capabilities.go:574-576], so it cannot serve database-backed references.
+- **One admin binding:** the registry module exports a single helper, `ReferenceOptions(ctx, typeKey) ([]Option-with-depth, error)`, which flattens the reference tree. Both the menu-entries controller and the definitions controller call it. Nested references need a `depth` (or indent) in the option payload; `pact.Option` has only `Value`/`Label` [VERIFIED: capabilities.go:567-571].
+- **Save:** recheck that the submitted reference is among the options for the submitted type (the same idea as relation option revalidation, D-18 of P10). An unknown value is a 422 on the field.
+
+### Pattern 7: Tree reorder page (D-09) and singleton definition (D-11)
+
+- **Reorder:**
+ - Config: a `config_reorder.yaml` (Winter's `reorder_config.yaml`: `title`, `nameFrom`, `modelClass`) plus Go-side keys for the parent column (default `parent_id`), the sort column (default `sort_order`) and an optional partition column (`menu_id`) [ASSUMED keys].
+ - `GET .../reorder?partition=` returns the scoped tree.
+ - `POST .../reorder` with `{nodes:[{id,parent_id,sort_order}]}` runs in one transaction. It locks the scoped set through `ListExtendQuery`/`FormExtendQuery` plus the partition and requires exactly that set (the file reorder rule "exactly the field's visible files" [VERIFIED: cabana README]). It refuses a cycle, a foreign parent or a parent outside the partition, then writes both columns.
+ - CSRF and `X-Requested-With`, like every write route. The route needs an OpenAPI annotation and a conformance case (`TestPhase10ConformanceInventory` gate).
+- **Menu entry rules:** PHP's `MenuEntry` used `Sortable` (a global `orderBy sort_order` when no other order is set [VERIFIED: vendor/winter/storm/src/Database/SortableScope.php:16-22]). It set `nest_left`, `nest_right` and `nest_depth` to `0` and `sort_order = max(menu)+1` in `beforeSave`, and inherited `menu_id` from the parent. Keep those rules in a GORM hook, because the `nest_*` columns are NOT NULL.
+- **Singleton:** PHP's `index()` ran `firstOrCreate(['theme' => …])` and redirected to `update/:id`.
+ - Recommended: a definitions controller with a form only, a `SettingsItem{Controller: ...}` link (already supported [VERIFIED: cabana README "A settings entry whose Controller field is set links to that admin controller"]) and a small generic "singleton form" capability (`firstOrCreate` through a controller hook, SPA opens the record).
+ - Alternative: a `HasSettings` singleton row id 1 with the Definition model. It needs repeater and dependent-dropdown support inside settings forms, and it breaks on copied PHP rows whose id ≠ 1 [VERIFIED: cabana/settings.go Get/Put use `primaryColumn = 1`].
+
+### Pattern 8: Menu API (D-16)
+
+`GET /_menu/api/v1/menus/{code}` (optional `?locale=`):
+
+- **Scope:** the menu must be `is_active`. Entries are filtered by `is_published` and ordered by `sort_order` per parent.
+- **Built-in entries:** for `url`, return the stored `url`; for `divider`, return no URL.
+- **Registry entries:**
+ - A single type (`journal-post`, `journal-category`) gets `url` = the alternate for the requested locale, else the default.
+ - A list type (`all-journal-categories`) keeps the entry's own stored `url` and `title` (the PHP partial links the dropdown parent to `entry.url`), and its `items` are the resolved children.
+- **Mount and limits:** mount like Journal: `r.Group("/_menu/api/v1", surf.Use("throttle:menu-public-api"), …)` plus a `surf.BucketProvider` bucket [VERIFIED: ../sm-journal-plugin/routes.go, plugin.go `Buckets`]. The host adds `_menu/api/*` and `_faq/api/*` to `config/http.yaml` `cors.paths` [VERIFIED: sm-grzybyfunkcjonalne-app/config/http.yaml:4 `- _journal/api/*`].
+- **Errors:** JSON 404 for an unknown or inactive code.
+
+### Pattern 9: FAQ API (D-17)
+
+`GET /_faq/api/v1/faqs/{slug}?locale=pl`:
+
+1. Resolve the translate `Locale` by `code` (default: `is_default`).
+2. Select the row by `slug`, `locale_id` and `is_active`, ordered by `id`, first. PHP has no unique index on slug, and its component ignored `is_active`.
+3. Return `{name, slug, description, content:[{question, answer}]}`.
+4. Sanitize `description` and every `answer` **on output** with the framework sanitizer, because the D-19 DB copy bypasses the admin save gate (Pitfall 10).
+
+`Requires()` = `golem15.translate`.
+
+### Pattern 10: Nuxt layers (D-18)
+
+Copy the `vue-journal-layer` skeleton:
+
+- An absolute `currentDir` for every path [VERIFIED: ../vue-journal-layer/nuxt.config.ts].
+- `runtimeConfig..apiBase` (server-only, SSR) plus `public..apiBase` (same-origin).
+- Tokens CSS first, then base CSS.
+- `node --test` tests and a `testing/` SSR smoke.
+- A tag release, pinned as a `git+https://…#vX.Y.Z` dependency.
+
+Keep both new layers **module-free**: do not declare `@nuxtjs/i18n`, `@nuxtjs/seo` or fonts. The journal layer warns that two copies of `@nuxtjs/i18n` split locale state. Labels come from the API. The menu layer computes the active state from `useRoute()`; the FAQ layer renders `` accordions with `v-html` of server-sanitized HTML.
+
+For the site app:
+
+- `extends: ['vue-journal-layer', 'vue-menu-layer', 'vue-faq-layer']`.
+- `sitemap: { enabled: false }` ("Whether to generate the sitemap", default `true` [CITED: nuxtseo.com/docs/sitemap/api/config]).
+- `robots: { sitemap: '/sitemap.xml' }` [CITED: nuxtseo.com/docs/robots/api/config].
+
+### Anti-Patterns to Avoid
+- **A `map[string]string` for alternates:** Go map order is random and `encoding/json` sorts keys, but PHP emits hreflang and per-locale entries in `sort_order` (Pitfall 4).
+- **`encoding/xml` or `xml.EscapeText` for the sitemap:** see Pattern 4.
+- **Switching Journal category content to `richeditor`:** it is already `blogeditor` (see the conflict table).
+- **A string-keyed festival event for list, info or resolve:** forbidden by D-01. Festival is only for the two sitemap hooks.
+- **Importing a plugin from the framework:** the registry, the sanitizer and cabana never import Journal, translate or menu.
+- **Copying PHP rows without remapping `locale_id`:** Go's translate locale ids differ (Pitfall 11).
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| HTML sanitizing (richeditor, FAQ output) | Regex gates (`cabana.RenderMarkdown`'s `markdownUnsafeTag` style) | bluemonday v1.0.27 UGC-based policy + the token-equality "byte-stable" comparison from Journal's `classes.SanitizeHTMLReport` | The decision note lists real bypasses of regex gates (``, `javascript:`, `