docs(17): research phase domain
This commit is contained in:
789
.planning/phases/17-blog-companions/17-RESEARCH.md
Normal file
789
.planning/phases/17-blog-companions/17-RESEARCH.md
Normal file
@@ -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>
|
||||
## 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 `<loc>`, 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.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## 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 |
|
||||
</phase_requirements>
|
||||
|
||||
## 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-<name>-plugin` repos, module path = repo path, mounted as submodules at `plugins/<vendor>/<name>`; 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 `<xhtml:link/>`, 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 <pkg> 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 <url> 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/<registry>/ # new beach-named module (e.g. "signpost"): Activate, Registry, Patterns, ReferenceOptions
|
||||
├── modules/<sanitizer>/ # 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 <registry>.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 `<url>` 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]:
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet type="text/xsl" href="https://grzybyfunkcjonalne.pl/sitemap.xsl"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"><url><loc>https://grzybyfunkcjonalne.pl/</loc><lastmod>2026-10-11T15:45:03+00:00</lastmod><changefreq>always</changefreq><priority>1.0</priority></url>...
|
||||
```
|
||||
An hreflang entry, verbatim [VERIFIED: curl https://www.golem15.com/sitemap.xml]:
|
||||
```
|
||||
<url><loc>https://www.golem15.com/en/blog/ai-engineering/ai-agents-workflow-automation</loc><lastmod>2026-02-11T15:30:10+00:00</lastmod><changefreq>always</changefreq><priority>0.6</priority><xhtml:link rel="alternate" hreflang="en" href="https://www.golem15.com/en/blog/ai-engineering/ai-agents-workflow-automation"/><xhtml:link rel="alternate" hreflang="pl" href="https://www.golem15.com/blog/inzynieria-ai/agenci-ai-w-automatyzacji-workflow"/></url>
|
||||
```
|
||||
Format facts:
|
||||
|
||||
- `\n` after the XML declaration and after the PI.
|
||||
- Everything else is on one line, ending in `</urlset>\n`.
|
||||
- An empty urlset self-closes: `<urlset xmlns="x"/>`.
|
||||
- `<lastmod>` 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 `<loc>…</loc>` 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=<id>` 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.<x>.apiBase` (server-only, SSR) plus `public.<x>.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 `<details>` 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 (`<img src="x"onerror=…>`, `javascript:`, `<svg/onload>`) |
|
||||
| WYSIWYG editing | contenteditable glue | TipTap 3.31.4 StarterKit | Schema, paste handling, undo, link policy |
|
||||
| Tree drag-and-drop | A new DnD library | Native HTML5 DnD as in `FileuploadField.vue`, plus move/indent buttons | Already used; no dependency |
|
||||
| Locale list and translated slugs | Reading `golem15_translate_*` tables in Journal by hand | translate `classes.TranslatedExact` / `models.Locale` (`is_enabled`, `sort_order`) [VERIFIED: ../sm-translate-plugin/classes/translatable.go:73, models/locale.go] | Same storage the admin writes |
|
||||
| Plugin command DB access | Assuming a published `*gorm.DB` | The lookup-or-open helper (`lagoon.OpenFromApp` + `lagoon.Publish`), as `grzyby/console/helpers.go` `lookupDB` and the 14.2 "commandDatabase" pattern do | The generated `main.go` publishes no DB for plugin commands (STATE, Phase 16) |
|
||||
| Rate limiting, CORS | Per-handler limiters | `surf.Use("throttle:<bucket>")` + `surf.BucketProvider`, host `cors.paths` | Existing Journal pattern |
|
||||
|
||||
**Exception (deliberate hand-roll):** the sitemap serializer, the `rawurlencode` and `Url::to` port, and the Journal `str_replace` pattern fill. They exist to match PHP byte for byte, and each needs a golden test generated by the local PHP.
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
This phase moves menu, FAQ and sitemap from app code and the PHP DB into plugins.
|
||||
|
||||
| Category | Items Found | Action Required |
|
||||
|----------|-------------|------------------|
|
||||
| Stored data | PHP (MySQL, live server): `golem15_menu_menus`, `golem15_menu_menu_entries` (types `link`/`page`/`divider`/`blog_categories`), `golem15_faq_faqs` (slug `what-mushrooms-can-give-you`, `locale_id` → PHP locale ids), `golem15_sitemap_definitions` (`data` JSON with PHP-object keys, possibly `cmsPage`, `exists`, `fillable`), `system_settings` item `golem15_sitemap_settings` | **Data migration:** read-only `scripts/php-export/*.sql` → JSONL → Go import commands; map types (D-05), remap `locale_id` by locale code, drop `page`/static-page/`cmsPage` items with a report, bump Postgres sequences after explicit-id inserts |
|
||||
| Live service config | nginx vhost (`deploy/nginx/grzybyfunkcjonalne-staging.conf`): no `/sitemap.xml`, `/sitemap.xsl`, `/_menu/`, `/_faq/` locations yet (only `/gfadmin`, `/_journal/`, storage) [VERIFIED: grep `location`]; Nuxt `@nuxtjs/seo` sitemap enabled via the layer (`sitemap: { sources: ['/api/__sitemap__/journal'] }` [VERIFIED: vue-journal-layer/nuxt.config.ts:62]) | **Config edit:** add four `location` blocks before `location /` with `Host $host` + `X-Forwarded-Proto`; set `sitemap.enabled false` in the app |
|
||||
| OS-registered state | supervisor programs for Go and Nuxt (`deploy/supervisor`): names unchanged | None. Verified by listing `deploy/`; no renamed process |
|
||||
| Secrets/env vars | None new. Journal's site-cache purge token exists; menu and FAQ edits are not purged (see Open Questions) | None, unless a purge is added |
|
||||
| Build artifacts | `modules/boardwalk/dist` (must be rebuilt for the new SPA fields); `vue-grzybyfunkcjonalne-app` pnpm-lock pins `vue-journal-layer#v0.3.0` [VERIFIED: package.json]; app `plugins.gen.go`/`main.go` regenerated by `summer build` after the `summer.yaml` additions; app `go.work` needs three `use` lines | Rebuild dist and commit; tag both layers and pin them; regenerate the build files; commit the `go.work`, `go.mod` and `go.sum` changes |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: `Url::to` trailing-slash and encoding rules
|
||||
**What goes wrong:** the homepage loc becomes `https://host` (missing `/`), `/blog/` keeps its slash, or `:` is left unescaped.
|
||||
**Why it happens:** Laravel trims slashes, and then Winter's `buildUrl` restores the root slash and re-encodes segments with `rawurlencode`.
|
||||
**How to avoid:** port it as in Pattern 4b and test against the PHP-generated golden table.
|
||||
**Warning signs:** a diff against the live sitemap on `/` or on non-ASCII URL items.
|
||||
|
||||
### Pitfall 2: `lastmod` format and fallback
|
||||
**What goes wrong:** Go's `time.RFC3339` prints `Z`, but PHP `date('c')` prints `+00:00` (live output shows `+00:00`). The current grzyby live sitemap still fakes "now" for URL items because it runs an older plugin. The port spec (the fonoteka version) **omits** lastmod when neither an mtime nor a manifest entry exists.
|
||||
**How to avoid:** use layout `2006-01-02T15:04:05-07:00` in UTC. Document that the URL items' lastmod changes against the live grzyby output (expected).
|
||||
|
||||
### Pitfall 3: Per-locale duplication only for list types
|
||||
**What goes wrong:** the sitemap emits one entry per locale for `journal-category` children (PHP does not), or the menu shows each category N times.
|
||||
**How to avoid:** apply the Pattern 3 rule in the sitemap consumer; `Resolve` stays per record.
|
||||
|
||||
### Pitfall 4: Ordered alternates
|
||||
**What goes wrong:** hreflang order differs run to run. golem15.com emits `en` before `pl`, following `sort_order` [VERIFIED: live output; grzyby translate `scopeOrder` orders by `sort_order`].
|
||||
**How to avoid:** use an ordered slice and query locales `ORDER BY sort_order`.
|
||||
|
||||
### Pitfall 5: `orderBy('name'/'title')` collation drift
|
||||
**What goes wrong:** the PHP MySQL `utf8mb4_*_ci` order differs from Postgres ICU `pl-PL` for case and diacritics, so the URL order in the sitemap or menu changes.
|
||||
**How to avoid:** accept a set-equality parity test plus an ordered test on ASCII fixtures, or use the per-query collation from `lagoon-per-query-collation.md`. Flag set-equal vs byte-equal in the acceptance criteria.
|
||||
|
||||
### Pitfall 6: Backend URL behind nginx
|
||||
**What goes wrong:** Go sees `r.TLS == nil` behind TLS termination and builds `http://` locs. The `frontend_url` rewrite then misses, because `backendUrl` differs.
|
||||
**How to avoid:** build the base from the trusted `X-Forwarded-Proto` (surf's trusted proxies) and `Host`, or from `app.url` when set. nginx already sends `Host $host` and `X-Forwarded-Proto $scheme` [VERIFIED: staging vhost].
|
||||
|
||||
### Pitfall 7: PHP-shaped JSON in `data` and `content`
|
||||
**What goes wrong:** Go decoding fails on `"nesting":"1"`, `"priority":0.5` vs `"0.5"`, `"reference":12`, or unexpected keys (`fillable`, `exists`, `code`, `cmsPage`). PHP `json_encode` of `DefinitionItem` objects writes all public properties, and form posts write strings.
|
||||
**How to avoid:** use a tolerant `UnmarshalJSON` (string/number/bool coercion, unknown keys ignored). On the next admin save, write the PHP `toArray()` key set (`changefreq, priority, nesting, type, url, reference, cmsPage, urlPattern`).
|
||||
|
||||
### Pitfall 8: `str_replace` rewrite semantics
|
||||
**What goes wrong:** an "improved" rewrite (prefix-only, URL-parsed) changes output.
|
||||
**How to avoid:** port the three PHP replacements literally: inside every `<loc>` and `xhtml:link href`, plus the exact `href="{backend}/sitemap.xsl"` string. They run after serialization.
|
||||
|
||||
### Pitfall 9: Winter YAML keys cabana does not have
|
||||
**What goes wrong:** the PHP YAML is copied verbatim and boot fails. The affected keys are `tabs:`/`stretch`/`cssClass`/`span: none` (FAQ, definition), `type: hint`/`type: section`/`trigger` (sitemap settings), `select: name` on a relation (FAQ locale), `preset: name` (supported), and a custom widget class (`Golem15\Sitemap\FormWidgets\SitemapItems`).
|
||||
**How to avoid:** flatten tabs into `tab:` keys and turn `select` into `nameFrom`. Map `span: none` to `left`/`right`/`auto`, drop `hint`/`section` into `comment` text, and show tier fields unconditionally. Replace the widget with `type: repeater` on `data`. Journal set the precedent: its fields.yaml is flat [VERIFIED: models/category/fields.yaml]. List every adaptation in each README.
|
||||
|
||||
### Pitfall 10: The DB copy bypasses the save gate
|
||||
**What goes wrong:** FAQ HTML copied from PHP never passes the richeditor sanitizer, so the API serves raw PHP HTML.
|
||||
**How to avoid:** sanitize on API output (and optionally in the import command, reporting what was removed).
|
||||
|
||||
### Pitfall 11: Foreign keys and sequences on the copy
|
||||
**What goes wrong:** FAQ `locale_id` points at PHP locale ids; menu `parent_id`/`menu_id` rely on PHP ids; inserting explicit ids leaves the Postgres `SERIAL` sequences behind, so the next admin create collides.
|
||||
**How to avoid:** remap the locale by `code`. Keep menu ids inside one transaction, then run `SELECT setval(pg_get_serial_sequence('<table>','id'), max(id))`. Refuse to import into non-empty tables unless `--force` is given.
|
||||
|
||||
### Pitfall 12: New framework dependency without a note
|
||||
**What goes wrong:** bluemonday enters the framework `go.mod` and violates CLAUDE.md "A new dependency needs a research doc or a phase/decision note naming it".
|
||||
**How to avoid:** this research names it. Also add `.planning/notes/<date>-framework-html-sanitizer-bluemonday.md` and update the module README Dependencies line in the same commit.
|
||||
|
||||
### Pitfall 13: Env overrides for dashed config keys
|
||||
**What goes wrong:** type keys contain `-` (`journal-post`), so `SUMMER_MENU_ITEMS__PATTERNS__JOURNAL-POST` is not a usable env var name.
|
||||
**How to avoid:** document YAML-file-only overrides for pattern maps, or use a list shape `[{type, pattern}]`.
|
||||
|
||||
### Pitfall 14: Plugin commands without a DB
|
||||
**What goes wrong:** `sitemap:import` or a menu/FAQ import fails with "database is not configured" in a `summer build` host.
|
||||
**How to avoid:** use the lookup-or-open helper in every plugin command (STATE, Phase 16 and 14.2 decisions).
|
||||
|
||||
### Pitfall 15: URL-scheme XSS through menu entries
|
||||
**What goes wrong:** an admin-entered `javascript:` URL is rendered by Nuxt in `<a :href>`, and Vue does not sanitize hrefs.
|
||||
**How to avoid:** validate on save (allow `http`, `https`, `mailto`, `tel`, a relative path and `#`) and filter again in the API. Treat `target` as an enum (`_self`, `_blank`) and add `rel="noopener"` in the layer.
|
||||
|
||||
### Pitfall 16: Admin route conformance gates
|
||||
**What goes wrong:** new cabana routes (dependent options, reorder, singleton) lack swag annotations or conformance cases, and `TestPhase10ConformanceInventory` or `check-admin-openapi.sh` fails.
|
||||
**How to avoid:** annotate in `admin_openapi.go`, add conformance cases, regenerate `schema.d.ts`, and commit dist in the same change.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Sitemap escaping (libxml-compatible)
|
||||
```go
|
||||
// Source: behavior measured with local PHP 8.5.10 DOMDocument::saveXML (this session).
|
||||
var textEsc = strings.NewReplacer("&", "&", "<", "<", ">", ">", "\r", " ")
|
||||
var attrEsc = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """,
|
||||
"\r", " ", "\t", "	", "\n", " ")
|
||||
|
||||
func writeURL(b *strings.Builder, loc, lastmod, changefreq, priority string, alts []pact.MenuItemAlternate) {
|
||||
b.WriteString("<url><loc>"); b.WriteString(textEsc.Replace(loc)); b.WriteString("</loc>")
|
||||
if lastmod != "" { b.WriteString("<lastmod>" + textEsc.Replace(lastmod) + "</lastmod>") }
|
||||
b.WriteString("<changefreq>" + textEsc.Replace(changefreq) + "</changefreq>")
|
||||
b.WriteString("<priority>" + textEsc.Replace(priority) + "</priority>")
|
||||
if len(alts) > 1 {
|
||||
for _, a := range alts {
|
||||
b.WriteString(`<xhtml:link rel="alternate" hreflang="` + attrEsc.Replace(a.Locale) +
|
||||
`" href="` + attrEsc.Replace(urlTo(a.URL)) + `"/>`)
|
||||
}
|
||||
}
|
||||
b.WriteString("</url>")
|
||||
}
|
||||
```
|
||||
|
||||
### rawurlencode (PHP)
|
||||
```go
|
||||
// Escapes everything except A-Z a-z 0-9 - _ . ~ (RFC 3986 unreserved), like PHP rawurlencode.
|
||||
func rawurlencode(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
```
|
||||
|
||||
### Typed sitemap hooks (D-14)
|
||||
```go
|
||||
// BeforeAddItem: UntilHandled; a listener sets Skip to drop the URL (PHP returned false).
|
||||
type BeforeAddItem struct { Item DefinitionItem; Info ItemInfo; Skip bool }
|
||||
func (e *BeforeAddItem) IsHandled() bool { return e.Skip } // festival.Handleable
|
||||
// AddItem: Fire; listeners may append extra child XML (already escaped) to Extra.
|
||||
type AddItem struct { Item DefinitionItem; Info ItemInfo; Extra []string }
|
||||
// usage: handled, err := app.Events.UntilHandled(ctx, &BeforeAddItem{...})
|
||||
```
|
||||
`festival.Bus.UntilHandled` stops at the first handled event or the first error [VERIFIED: modules/festival/bus.go:100-109]. Write `Extra` before `</url>` so listeners can add elements, as the PHP `$urlElement->appendChild` could.
|
||||
|
||||
### Default tier config (D-13, from PHP `getPriorityForUrl`)
|
||||
```yaml
|
||||
# sm-sitemap-plugin config/config.yaml — defaults equal the PHP rules; host config may replace the list.
|
||||
golem15:
|
||||
sitemap:
|
||||
priority_tiers:
|
||||
- { setting: priority_homepage, exact: ["", "/en"] }
|
||||
- { setting: priority_services, pattern: '^/(uslugi|en/services|realizacje|en/work)/[^/]+$' }
|
||||
- { setting: priority_blog, pattern: '^/(blog|en/blog)/[^/]+/[^/]+$' }
|
||||
- { setting: priority_categories, pattern: '^/(uslugi|en/services|realizacje|en/work|blog|en/blog)(/[^/]+)?$' }
|
||||
- { setting: priority_legal, exact: ["/polityka-prywatnosci", "/en/privacy-policy", "/regulamin", "/en/terms", "/o-nas", "/en/about", "/kontakt", "/en/contact", "/laboratorium-ai", "/en/ai-lab", "/laboratorium-ai/zabawa-animacja", "/en/ai-lab/blob-playground"] }
|
||||
```
|
||||
These patterns are copied from fonoteka `Definition.php` `getPriorityForUrl` [VERIFIED: file read this session]. The path is `parse_url(PATH)` with `rtrim('/')`, and the first match wins. The live grzyby priorities `{1.0, 0.5, 0.4}` are consistent with these rules (posts `/blog/<slug>` hit the categories tier 0.4; root categories fall back to item 0.5) [VERIFIED: live grzyby sitemap]. The config key names are [ASSUMED].
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `pages.menuitem.*` string events | Typed `pact` capability + registry | This phase | Compile-time contract for every content plugin |
|
||||
| CMS-page-based URLs (`cmsPage`) | Headless URL patterns, 3 layers | PHP 2.0.3 added `urlPattern`; Go drops `cmsPage` | Copied items lose `cmsPage` (report them) |
|
||||
| Fake `lastmod = now()` (grzyby's older PHP plugin) | Omit lastmod; manifest fallback | fonoteka plugin version | Output changes vs the live grzyby site, intentionally |
|
||||
| Regex HTML gates | bluemonday + byte-stable compare | 2026-10-09 decision note | Framework adopts the same for `richeditor` |
|
||||
| TipTap v2 StarterKit (no Link) | v3 StarterKit includes Link, Underline, ListKeymap, TrailingNode | TipTap v3 | No extra extension packages for FAQ |
|
||||
|
||||
**Deprecated/outdated:** Winter `$guarded = []` on Definition (grzyby copy). The port follows the fonoteka `$fillable = ['theme','data']` + `$guarded = ['*']` (`MassAssignmentTest`). In Go, `Fillable()` returns exactly `theme`, `data`.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | D-10's Journal-category clause should be dropped because 16.2 shipped `blogeditor` | Summary/Conflict | Low; executing it regresses the Blog Editor. **Needs user confirmation** |
|
||||
| A2 | Module names "signpost" (registry) and "seaglass" (sanitizer); config keys `menu_items.patterns`, `golem15.sitemap.priority_tiers` | Patterns 1-2, tiers | Naming only; published config keys are costly once used |
|
||||
| A3 | Pattern placeholders keep PHP names (`:slug`, `:category`, `:id`, …) rather than D-02's illustrative `:post` | Pattern 2 | Copied PHP `urlPattern`s would not fill otherwise |
|
||||
| A4 | Menu items get pattern overrides through a new nullable `url_pattern` column (additive), and nesting defaults to the type's `Nesting` (no column) | Pattern 8 | Schema is "PHP columns + 1"; needs confirmation against "Tables keep their PHP names and columns" |
|
||||
| A5 | The menu built-in key is `url` (PHP stored `link`, mapped on copy) | D-05 | Stored values; costly to change later |
|
||||
| A6 | The singleton definition uses a controller form + settings link + a new "singleton form" capability, not `HasSettings` | Pattern 7 | Alternative is settings-form repeater support |
|
||||
| A7 | The backend URL for the rewrite = `app.url` if set, else the request base from trusted `X-Forwarded-Proto` + `Host` (Winter `url('/')` likely used the request root; not verified for `linkPolicy`) | Pitfall 6 | A wrong base makes the rewrite a no-op; verify with a staging fetch |
|
||||
| A8 | The FAQ layer ships no client sanitizer; the server sanitizes on output | Pattern 9 | Defense-in-depth choice |
|
||||
| A9 | The live grzyby DB holds sitemap/menu type values beyond the four menu options (for example Blog-plugin types); the mapping table must come from a read-only query | Data copy | An unmapped type is dropped silently unless reported |
|
||||
| A10 | Nuxt layers stacking three layers with no module declarations in the new two avoids duplicate-module issues | Pattern 10 | If a layer needs i18n strings, it must rely on the app's i18n |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **D-10 Journal clause (A1).** We know category content is `blogeditor`. Recommendation: confirm with the user and drop the clause; the remaining D-10 scope is FAQ only.
|
||||
2. **Menu item pattern override storage (A4).** D-02 promises a per-item override, but the PHP menu table has no column for it. Recommendation: add a nullable `url_pattern` column in an additive migration, or leave it out for menus (sitemap items have `urlPattern` already).
|
||||
3. **Live data shapes (A9).** Before writing the type mapping, run read-only queries on the PHP server: `SELECT DISTINCT type FROM golem15_menu_menu_entries;`, `SELECT id, theme, data FROM golem15_sitemap_definitions;`, `SELECT value FROM system_settings WHERE item='golem15_sitemap_settings';`, and `SELECT id, slug, locale_id FROM golem15_faq_faqs;`. Recommendation: a human checkpoint in the integration plan.
|
||||
4. **Site cache after menu/FAQ edits.** Journal purges the Nuxt swr cache on admin saves; menu and FAQ edits would show only after the TTL (≤300 s). The purger type lives in Journal. Recommendation: accept the TTL now, with a follow-up todo for a framework-level purge service.
|
||||
5. **Where the import commands live.** Generic `menu:import-winter` / `faq:import-winter` / `sitemap:import-winter` (JSONL) in the plugins, reusable for other PHP sites, or `grzyby:*` app commands. Recommendation: put them in the plugins (the D-05 mapping is a plugin contract), with `migrate.sh` ordering them after the translate locale step.
|
||||
6. **Empty definition.** PHP answers `200` with an empty body when there are no items and a 404 page when there is no definition. Recommendation: keep both behaviors (empty `application/xml`; a plain 404).
|
||||
7. **Menu `target` field.** The PHP form lacks it but the template uses it. Recommendation: add a dropdown (`_self`/`_blank`) to the port's fields.yaml, documented as an adaptation.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go repos | ✓ | go1.27.0 | — |
|
||||
| Node / npm / pnpm | admin SPA, layers | ✓ | 22.23.2 / 12.0.2 / 11.3.0 | — |
|
||||
| Docker daemon | testcontainers Postgres suites | ✓ | 29.7.2 | `-short` |
|
||||
| php CLI + vendored Winter | golden fixtures (escaping, Url::to, tiers) | ✓ | PHP 8.5.10; `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/vendor` | Commit the goldens so CI needs no PHP |
|
||||
| psql, jq, mysql client, xmllint, xsltproc | migrate.sh, XML well-formedness and XSL smoke | ✓ | present | — |
|
||||
| Live PHP sites (reference output) | parity check | ✓ grzybyfunkcjonalne.pl, www.golem15.com (horoskopia 403) | — | Saved fixtures |
|
||||
| Gitea repos sm-{sitemap,menu,faq}-plugin, vue-{menu,faq}-layer | SC-1, D-18 | ✗ (`git ls-remote` "Cannot find repository") | — | **Human step:** create the repos (layers public) before the first push |
|
||||
|
||||
**Missing dependencies with no fallback:** the five new remote repositories (human creation; pushes are gated anyway).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (+ testcontainers Postgres), vitest 3.2.7 (admin SPA), `node --test` (layers) |
|
||||
| Config file | none for Go; `admin/vite.config`/vitest; layer `package.json` `test` script |
|
||||
| Quick run command | `go test -short ./modules/pact ./modules/cabana/... ./modules/<registry>/... ./modules/<sanitizer>/...` |
|
||||
| Full suite command | `bash scripts/check-phase17.sh --all` (new; staged like `check-phase16.2.sh`: go, admin, plugins, layers, docs, postgres) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| SC-2/D-01 | Duplicate type key fails boot; `Lookup` registry; pattern layers | unit | `go test -short ./modules/<registry> -run 'TestRegistry'` | ❌ Wave 0 |
|
||||
| D-04 | Journal 5 types: references, resolve, alternates order, x-default, published filters | unit+pg | `cd ../sm-journal-plugin && go test ./... -run TestMenuItemTypes` | ❌ |
|
||||
| D-07 | Repeater compile refusals, decode, row errors `content.N.answer`, nested richeditor notices | unit | `go test -short ./modules/cabana -run TestRepeater` | ❌ |
|
||||
| D-08 | dependsOn compile, options route, save recheck 422 | unit+conformance | `go test -short ./modules/cabana -run 'TestDependentDropdown|TestPhase10ConformanceInventory'` | ❌ |
|
||||
| D-09 | Reorder: exact-set rule, cycle/foreign parent refusal, partition, both columns written | pg | `go test ./modules/cabana -run TestReorder` | ❌ |
|
||||
| D-10 | richeditor strips and reports; byte-stable when clean; regex-bypass corpus refused | unit | `go test -short ./modules/<sanitizer> ./modules/cabana -run 'TestSanitize|TestRichEditor'` | ❌ |
|
||||
| SC-3 | Golden parity: escaping, Url::to table, tiers, lastmod format, per-locale rule, rewrite, 50k cap, empty definition | unit (goldens from PHP) | `cd ../sm-sitemap-plugin && go test -short ./... -run 'TestSitemapGolden|TestURLTo|TestTiers|TestRewrite'` | ❌ |
|
||||
| D-14 | XmlEscaping/MassAssignment spec ports; BeforeAddItem skip; AddItem extra | unit | same package `-run 'TestXMLEscaping|TestFillable|TestHooks'` | ❌ |
|
||||
| D-16 | Menu API tree, published filter, inactive 404, URL-scheme filter, registry expansion | pg | `cd ../sm-menu-plugin && go test ./...` | ❌ |
|
||||
| D-17 | FAQ API locale default, active only, output sanitized | pg | `cd ../sm-faq-plugin && go test ./...` | ❌ |
|
||||
| Admin SPA | RepeaterField, RichEditorField (untouched value byte-identical), dependent dropdown, ReorderView | vitest | `npm --prefix admin run test` | ❌ |
|
||||
| D-18 | Layer clients and components, SSR smoke | node test | `pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test` | ❌ |
|
||||
| SC-4 | Host boot with three plugins; import commands (type map, locale remap, setval); `/sitemap.xml` from the host | pg/host | `cd ../sm-grzybyfunkcjonalne-app && go test ./...` | partial (boot_test.go exists) |
|
||||
| SC-4 manual | UAT: menu and FAQ render from the API; `/sitemap.xml` via nginx equals the expected golden; robots.txt Sitemap line | manual | on-server smoke (`scripts/smoke.sh` extension) | ❌ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the quick command for the touched repo, plus `go vet ./...`.
|
||||
- **Per wave merge:** full Go suites per repo with Docker, and the admin vitest.
|
||||
- **Phase gate:** `scripts/check-phase17.sh --all` green before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `scripts/check-phase17.sh`: staged gate (copy the 16.2 structure, named-test PASS checks).
|
||||
- [ ] PHP golden generator script (for example `../sm-sitemap-plugin/tests/php/gen-goldens.php`, run with the local `php` against `grzybyfunkcjonalne.pl/vendor`) plus the committed `testdata/*.golden`.
|
||||
- [ ] `go.work` for each new plugin repo (as in `sm-journal-plugin/go.work`: `use .` + replaces to `../summercms.go`, `../sm-translate-plugin`).
|
||||
- [ ] Test harness per plugin (copy `admin_harness_test.go` / `TestMain` with `flag.Parse()` + `testing.Short()`).
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (admin) | Existing cabana backend guard; public APIs anonymous by design |
|
||||
| V3 Session Management | no new | — |
|
||||
| V4 Access Control | yes | Permission codes `golem15.sitemap.access_definitions`, `golem15.menu.manage_menu`, `golem15.faq.faqs` on controllers; the reorder and dependent-options routes inherit the controller permissions and scopes (`ListExtendQuery`/`FormExtendQuery`) |
|
||||
| V5 Input Validation | yes | Repeater schema-driven decode, dependent-value recheck, URL scheme allowlist, `target` enum, tier regex compiled at boot (RE2, no ReDoS) |
|
||||
| V6 Cryptography | no | — |
|
||||
| V12 Files | yes | `updates_manifest_path`: read only a regular file, size cap, JSON object only; errors omit lastmod (PHP fails safe) |
|
||||
| V14 Config | yes | Host CORS paths, throttle buckets |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Stored XSS via richeditor/FAQ HTML | Tampering/Elevation | bluemonday policy on save **and** on FAQ API output (the copy bypasses save) |
|
||||
| Stored XSS via menu `url` (`javascript:`) | Tampering | Scheme allowlist on save and in the API; the layer refuses non-allowlisted schemes |
|
||||
| XML injection in sitemap | Tampering | libxml-equivalent escaping (XmlEscapingTest spec) |
|
||||
| Mass assignment on Definition | Tampering | `Fillable()` = `theme`, `data` (MassAssignmentTest spec) |
|
||||
| Reorder IDOR (moving another menu's entries) | Elevation | Exact scoped set + partition check in one locked transaction |
|
||||
| Public API scraping/DoS | DoS | `throttle:menu-public-api` / `faq-public-api` buckets; the sitemap is cheap, but the 50k cap bounds it |
|
||||
| Admin-supplied file path (manifest) | Information disclosure | Only dates are derived; never echo file content |
|
||||
|
||||
Run the security-review agent after the framework and plugin plans (CONTEXT discretion item).
|
||||
|
||||
## Suggested plan split (for the plan-count checkpoint)
|
||||
|
||||
1. **Framework Go (summercms.go):** `pact` registry interface + registry module + `party` hook; sanitizer module (bluemonday, decision note); cabana repeater, dependent dropdown, richeditor gate, tree reorder routes, singleton form; OpenAPI, conformance, READMEs, docs.
|
||||
2. **Admin SPA + framework release:** RepeaterField, RichEditorField (TipTap 3.31.4, human package check), dependent dropdown, ReorderView; `schema.d.ts`, dist, `check-admin-dist`; release gate (human) and a summercms tag (next free after v0.1.22).
|
||||
3. **Journal types + sm-sitemap-plugin:** Journal `HasMenuItemTypes` (additive); the sitemap port (models, settings, definitions singleton, generator, `Url::to`, tiers config, hooks, routes, XSL, `sitemap:import`), PHP goldens, README.
|
||||
4. **sm-menu-plugin + sm-faq-plugin:** models and migrations, admin screens (reorder, dependent reference), public APIs, Winter import commands, READMEs.
|
||||
5. **Site integration:** create the repos (human), `vue-menu-layer` + `vue-faq-layer` (tags, human push), grzyby submodules/`go.work`/`summer.yaml`/CORS/nginx, PHP export SQL + `migrate.sh` steps, remove the app menu/FAQ data, disable the Nuxt sitemap, UAT redeploy and smoke.
|
||||
6. **Unit tests + gate:** fill the test map, `scripts/check-phase17.sh`, validation map.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- In-repo files read this session: `modules/pact/capabilities.go`, `modules/cabana/{form_schema,crud,settings,field_markdown}.go`, `modules/cabana/README.md`, `modules/lagoon/jsonable.go`, `modules/festival/bus.go`, `modules/party/registry.go`, `modules/backpack/app.go`, `modules/phrasebook/translator.go`.
|
||||
- Port sources: fonoteka `plugins/golem15/sitemap/*` and `journal/{Plugin.php, models/Category.php, models/Post.php}`; grzybyfunkcjonalne.pl `plugins/golem15/{menu,faq,translate}`, `themes/arsha/*`, vendor `winter/storm` + `laravel/framework` UrlGenerator.
|
||||
- Siblings: `../sm-journal-plugin`, `../sm-translate-plugin`, `../sm-grzybyfunkcjonalne-app` (config, nginx, scripts), `../vue-journal-layer`.
|
||||
- Live: `https://grzybyfunkcjonalne.pl/sitemap.xml` + `.xsl`, `https://www.golem15.com/sitemap.xml` (fetched 2026-10-11).
|
||||
- Local PHP 8.5.10 probes (DOMDocument escaping, Winter `Url::to`/`buildUrl`).
|
||||
- npm registry (`npm view`), Go module proxy (`go list -m`), MSD package-legitimacy seam.
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- tiptap.dev StarterKit docs (v3 contents).
|
||||
- nuxtseo.com sitemap `enabled` and robots `sitemap` options.
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None used as a basis for recommendations.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. Same versions already in use in sibling repos; registry checks run.
|
||||
- Architecture: MEDIUM. The framework API shapes are proposals within locked decisions; the seams they plug into were verified.
|
||||
- Pitfalls: HIGH. Most were reproduced against PHP or found in the code.
|
||||
|
||||
**Research date:** 2026-10-11
|
||||
**Valid until:** 2026-11-10 (stable port; re-check TipTap if planning slips past a month)
|
||||
Reference in New Issue
Block a user