docs(17): create phase plan
This commit is contained in:
@@ -1075,10 +1075,26 @@ Plans:
|
||||
4. grzybyfunkcjonalne.pl reads its menu and FAQ from the plugins instead of app config.
|
||||
5. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 6 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run $gsd-plan-phase 17 to break down)
|
||||
**Wave 1**
|
||||
|
||||
- [ ] 17-01-PLAN.md — Framework Go: pact menu-item types and the signpost registry, seaglass sanitizer, cabana repeater, dependent dropdown, richeditor, tree reorder and singleton form
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
|
||||
- [ ] 17-02-PLAN.md — Admin SPA (RichEditorField on TipTap, RepeaterField, dependent dropdown, ReorderView, singleton form), dist, and the framework release tag
|
||||
- [ ] 17-03-PLAN.md — Journal registers its five item types; sm-sitemap-plugin with PHP byte parity, admin and import commands
|
||||
- [ ] 17-04-PLAN.md — sm-menu-plugin and sm-faq-plugin: admin, Winter imports, and the /_menu and /_faq read APIs
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
|
||||
- [ ] 17-05-PLAN.md — grzybyfunkcjonalne.pl integration: repos, vue-menu-layer and vue-faq-layer, data copy, nginx, UAT redeploy
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
|
||||
- [ ] 17-06-PLAN.md — Unit tests across all repositories, scripts/check-phase17.sh and the validation map
|
||||
|
||||
### Phase 18: Payment Gateway
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
---
|
||||
msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: "14.2"
|
||||
current_phase_name: Local Fonoteka frontend on local SummerCMS backend
|
||||
current_phase: 17
|
||||
current_phase_name: Blog companions
|
||||
status: executing
|
||||
stopped_at: Completed 14.2-02-PLAN.md
|
||||
last_updated: "2026-10-11T17:08:40.471Z"
|
||||
last_updated: "2026-10-11T17:28:11.705Z"
|
||||
last_activity: 2026-10-11
|
||||
last_activity_desc: Phase 14.2 execution started
|
||||
state_head: 5c75b316a71ec876cd64c46a5205a163ca4f39dc
|
||||
state_head: 05442174b67f1687d07530ef90b4ac6998bf7a50
|
||||
progress:
|
||||
total_phases: 40
|
||||
completed_phases: 13
|
||||
total_plans: 165
|
||||
total_plans: 168
|
||||
completed_plans: 160
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
@@ -29,7 +29,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 14.2 (Local Fonoteka frontend on local SummerCMS backend) — EXECUTING
|
||||
Phase: 17 (Blog companions) — READY TO EXECUTE
|
||||
Plan: 3 of 4
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-11 — Phase 14.2 execution started
|
||||
|
||||
414
.planning/phases/17-blog-companions/17-01-PLAN.md
Normal file
414
.planning/phases/17-blog-companions/17-01-PLAN.md
Normal file
@@ -0,0 +1,414 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md
|
||||
- go.mod
|
||||
- go.sum
|
||||
- README.md
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/party/registry.go
|
||||
- modules/party/README.md
|
||||
- modules/signpost/signpost.go
|
||||
- modules/signpost/signpost_smoke_test.go
|
||||
- modules/signpost/README.md
|
||||
- modules/seaglass/seaglass.go
|
||||
- modules/seaglass/seaglass_smoke_test.go
|
||||
- modules/seaglass/README.md
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/field_dependent.go
|
||||
- modules/cabana/field_richeditor.go
|
||||
- modules/cabana/field_repeater.go
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/singleton.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/testdata/companions/controllers/links/config_form.yaml
|
||||
- modules/cabana/testdata/companions/controllers/links/config_list.yaml
|
||||
- modules/cabana/testdata/companions/controllers/links/config_reorder.yaml
|
||||
- modules/cabana/testdata/companions/models/link/fields.yaml
|
||||
- modules/cabana/testdata/companions/models/link/columns.yaml
|
||||
- modules/cabana/companions_smoke_test.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/token_routes_test.go
|
||||
- modules/cabana/phase10_coverage_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- docs/plugins/menu-item-types.md
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/settings.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, D-01, D-02, D-03, D-06, D-07, D-08, D-09, D-10, D-11]
|
||||
estimate:
|
||||
tokens: 210000
|
||||
raw_tokens: 210000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01: a plugin that implements pact.HasMenuItemTypes returns typed pact.MenuItemType values (Key, Label, DefaultPattern, PatternFrom, Placeholders, Nesting, DynamicItems, References, Resolve); signpost.Activate collects them in party.Activate after every Register and before the first Boot and publishes one *signpost.Registry that consumers read with app.Lookup; no festival event and no string event name carries list, type info or resolve."
|
||||
- "SC-2 boot rules (edge probe, adjacency): two plugins registering the same type key fail boot with an error that names the key and both plugin IDs; an empty key, a nil Resolve, a DefaultPattern that is empty or does not start with a slash, a PatternFrom that names a type no plugin registered, the type itself or a type that sets its own PatternFrom, and a host override in menu_items.patterns for a type no plugin registered each fail boot with an error prefixed signpost:."
|
||||
- "D-02: every registered type declares a DefaultPattern (layer 1); the URL pattern is layered as the item override when non-empty, else the host config menu_items.patterns entry for the type key, else, for a list type that sets PatternFrom (the key of the type whose records its items are), the host entry for that record type, else the type's DefaultPattern; so a host override of a record type also reaches the children of the list types that name it, unless the host or the item overrides the list type itself; signpost Registry.Resolve passes that layered pattern to the type in pact.MenuItemResolve.Pattern and a list type builds its children from it; menu_items.patterns is a YAML map read once at boot and documented as file-only (type keys contain dashes, RESEARCH Pitfall 13)."
|
||||
- "D-03: pact.ResolvedMenuItem carries Title, URL, MTime and Alternates as an ordered locale-to-URL list ([]pact.MenuItemAlternate, never a Go map, so hreflang order is stable), plus nested Items for expanding types; a single-locale site resolves to one alternate."
|
||||
- "D-08 (one admin binding): a dropdown with dependsOn naming a sibling field (same form, or same repeater row) gets its options from the controller's pact.DependentOptionsProvider; signpost.ReferenceOptions flattens a type's reference tree into options with a depth; GET {admin api}/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options?values[<sibling>]=<value> answers {data:{applies, options:[{value,label,depth}]}}; a type without references answers applies false (edge probe, empty); a submitted value that is not among the options for the submitted sibling value is a 422 with form.dependent_invalid; null is accepted unless the field is required."
|
||||
- "D-10: the core richeditor field type sanitizes every submitted value (top-level and inside repeater rows) through seaglass, which wraps bluemonday v1.0.27 with the Journal-equivalent policy; HTML the policy leaves token-equal is stored byte-identical; removed elements, attributes and comments come back as meta.notices under the field path; no save path (controller form, repeater row) stores an unsanitized richeditor value, and settings and relation forms refuse richeditor, repeater and dependsOn at boot."
|
||||
- "D-10 resolved deviation (RESEARCH A1, PATTERNS note): Journal's category content stays type blogeditor (shipped in Phase 16.2, todo cabana-richeditor-field-type closed); this phase does not switch it to richeditor; the core richeditor serves FAQ description and the repeater answer."
|
||||
- "D-07: type repeater edits a JSON list in a jsonable column with nested form.fields (allowed nested types text, textarea, number, checkbox, switch, dropdown with static, method or dependsOn options, richeditor); keys prompt, maxItems, minItems, titleFrom; groups, style, a repeater inside a repeater and any other nested type fail boot; rows keep only nested field names with scalars coerced per type; required applies per row with error keys such as content.3.answer; maxItems and minItems are 422s on the field; the nested-value refusal in ProjectWritableFields gets a schema-driven exemption for repeater fields only."
|
||||
- "D-09: a controller with config_reorder.yaml (title, nameFrom, modelClass, parentColumn default parent_id, sortColumn default sort_order, maxDepth, optional partition block) gets GET and POST {admin api}/{vendor}/{plugin}/{controller}/reorder; toolbar.buttons accepts reorder only on such a controller; the POST runs in one transaction that locks the scoped set (ListExtendQuery plus the partition), requires the submitted id set to equal it exactly, refuses a cycle, a parent outside the set and a depth above maxDepth, then writes both parent and sort columns."
|
||||
- "SC-3 edge (concurrency): a reorder POST whose node set no longer matches the locked scoped set (a record added, removed or moved to another partition meanwhile) answers 409 reorder_stale and writes nothing."
|
||||
- "D-11: an admin controller implementing pact.AdminSingleton gets GET {admin api}/{vendor}/{plugin}/{controller}/singleton, which returns the one record's id after SingletonRecord creates it when absent; the form schema marks the controller singleton; its list, create, bulk and delete routes answer 404; a SettingsItem with Controller links to it."
|
||||
- "D-06: every new type, key and route has fail-loud boot checks, swag annotations, regenerated admin/openapi/admin.json and admin/src/api/schema.d.ts, entries in phase09Routes, the token route classes, the phase 10 coverage list and the conformance inventory, and README plus docs updates in the same commit; signpost and seaglass are new beach-named modules with standard READMEs and root README rows; bluemonday enters go.mod with a decision note naming it."
|
||||
artifacts:
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve, ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider, DependentOptionSet, AdminSingleton, Option.Depth"
|
||||
contains: "HasMenuItemTypes"
|
||||
- path: "modules/signpost/signpost.go"
|
||||
provides: "menu item type registry: Activate, Registry, ReferenceOptions"
|
||||
contains: "func Activate"
|
||||
- path: "modules/seaglass/seaglass.go"
|
||||
provides: "bluemonday HTML policy with byte-stable SanitizeReport and Check"
|
||||
contains: "func SanitizeReport"
|
||||
- path: "modules/cabana/field_dependent.go"
|
||||
provides: "dependsOn compile, dependent-options route, save recheck"
|
||||
contains: "DependentOptionsProvider"
|
||||
- path: "modules/cabana/field_repeater.go"
|
||||
provides: "repeater compile, decode and nested checks"
|
||||
contains: "repeater"
|
||||
- path: "modules/cabana/field_richeditor.go"
|
||||
provides: "richeditor save gate through seaglass"
|
||||
contains: "seaglass.SanitizeReport"
|
||||
- path: "modules/cabana/reorder.go"
|
||||
provides: "config_reorder.yaml compile and the GET/POST reorder routes"
|
||||
contains: "reorder_stale"
|
||||
- path: "modules/cabana/singleton.go"
|
||||
provides: "singleton controller route"
|
||||
contains: "AdminSingleton"
|
||||
- path: ".planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md"
|
||||
provides: "decision note naming bluemonday v1.0.27 for the framework"
|
||||
contains: "bluemonday"
|
||||
key_links:
|
||||
- from: "modules/party/registry.go Activate"
|
||||
to: "modules/signpost/signpost.go Activate"
|
||||
via: "called after phrasebook and postcard activation, before the Boot loop"
|
||||
pattern: "signpost.Activate"
|
||||
- from: "modules/cabana/field_dependent.go"
|
||||
to: "pact.DependentOptionsProvider on the admin controller"
|
||||
via: "dependent-options route and save recheck"
|
||||
pattern: "DependentOptions\\("
|
||||
- from: "modules/cabana/field_richeditor.go and field_repeater.go"
|
||||
to: "modules/seaglass/seaglass.go"
|
||||
via: "every richeditor value, top-level or nested, is sanitized before it is written"
|
||||
pattern: "seaglass\\."
|
||||
- from: "modules/cabana/crud.go ProjectWritableFields"
|
||||
to: "modules/cabana/field_repeater.go"
|
||||
via: "schema-driven exemption for repeater fields; nestedValue unchanged"
|
||||
pattern: "repeater"
|
||||
prohibitions:
|
||||
- statement: "Framework modules (signpost, seaglass, cabana, pact, party) never import a plugin module (sm-*), never name a consuming application in code, README or docs, and never carry a site-specific URL pattern."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The nested-value refusal in ProjectWritableFields stays in force for every field that is not a repeater; no richeditor value reaches the database without passing seaglass."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Existing cabana field types, routes, payloads and the Journal blogeditor contract keep their behaviour byte for byte; only additive fields (Option.Depth with omitempty, new schema keys) change the admin schema."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format, flagged for `/msd mvp-phase 17` if a story is wanted): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 1 of 6, framework Go, wave 1): the typed menu-item type registry (D-01 to D-03) and the cabana features the three plugins' admin screens need (D-06 to D-11), server side only. The admin SPA, dist and the framework tag are plan 02.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
No SPEC and no REQUIREMENTS IDs for this phase. The edge probe ran over SC-1..SC-5 as the requirement text (15 items, 2 unclassified). Items owned by this plan: SC-2 adjacency (duplicate key), SC-2 empty (type without references), SC-3 concurrency (stale reorder set); each is an explicit truth above. Prohibitions were recalled in-prompt and are descriptor-less (flagged-unverified). Canon security items (XSS, injection, IDOR) are not minted as prohibitions; they are in the threat model.
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion, CONTEXT)
|
||||
|
||||
- Registry module `signpost` (a beach signpost points the way to places), sanitizer module `seaglass` (glass the sea has worn smooth). Both get the standard README and a root README row.
|
||||
- Host pattern config key `menu_items.patterns` (map from type key to pattern), read with compass `LoadSection` once in `signpost.Activate`. Host apps put it in `config/menu_items.yaml`.
|
||||
- List types (items without a URL of their own, such as "all posts") carry `PatternFrom`, the key of the type whose records they list. Their children then follow the host override of that record type, so a host that changes the post or category URL sets it once and the list types follow. A host or item override of the list type still wins, which keeps the three D-02 layers. Every type, list types included, still declares a valid `DefaultPattern`.
|
||||
- Alternates are `[]pact.MenuItemAlternate` (ordered), honoring D-03's locale-to-URL mapping without Go map ordering (Pitfall 4).
|
||||
- The dependent-options payload carries `applies` so the SPA can tell "type takes no reference" from "no records yet" (UI-SPEC states).
|
||||
- Reorder stale set answers 409 with error code `reorder_stale`; other refusals (cycle, foreign parent, depth) are 422 `validation_failed`.
|
||||
- Settings and relation forms refuse `richeditor`, `repeater` and `dependsOn` at boot ("supported in controller forms only"), so neither save path can bypass the D-10 gate or the D-08 recheck. The sitemap definition uses the singleton controller form (RESEARCH A6, Pattern 7).
|
||||
- D-10 Journal clause: not executed; recorded as a resolved deviation (truth above). The bluemonday decision note is committed as a planning-doc commit before the code commit that adds the dependency.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `modules/pact`: `HasMenuItemTypes`, `MenuItemType`, `MenuItemReference`, `MenuItemResolve`, `ResolvedMenuItem`, `MenuItemAlternate`, `DependentOptionsProvider`, `DependentOptionSet`, `AdminSingleton`; `Option` gains `Depth int` (json `depth,omitempty`).
|
||||
- `modules/signpost` (new): `Activate`, `Registry` (`Types`, `Type`, `Pattern`, `Resolve`), `ReferenceOptions`, `ConfigKey`.
|
||||
- `modules/seaglass` (new): `Sanitize`, `SanitizeReport`, `Check`, `Removals`.
|
||||
- `modules/cabana`: field types `richeditor` and `repeater`; field key `dependsOn`; `config_reorder.yaml`; toolbar button `reorder`; routes `GET …/fields/{field}/dependent-options`, `GET` and `POST …/reorder`, `GET …/singleton`; phrase keys for the server messages.
|
||||
- Docs: `docs/plugins/menu-item-types.md` (new), sections in `docs/backend/forms.md`, `lists-and-filters.md`, `admin-controllers.md`, `settings.md`; decision note `.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md`.
|
||||
- Smoke tests: `TestSignpostSmoke`, `TestSeaglassSmoke`, `TestCompanionsDependentSmoke`, `TestCompanionsRichEditorSmoke`, `TestCompanionsRepeaterSmoke`, `TestCompanionsReorderSmoke`, `TestCompanionsSingletonSmoke` (full unit tests are plan 06).
|
||||
|
||||
<objective>
|
||||
Give plugins a typed menu-item type registry and give cabana the repeater, dependent dropdown, richeditor, tree reorder and singleton form the three ports need, all fail-loud, documented and in the admin OpenAPI.
|
||||
|
||||
Purpose: SC-2 (typed successor of pages.menuitem.*) and the framework half of SC-1 (the PHP admin screens can be expressed in YAML).
|
||||
Output: two new modules, cabana features and routes, regenerated admin.json and schema.d.ts, docs, smoke tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-CONTEXT.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-PATTERNS.md
|
||||
@.planning/phases/17-blog-companions/17-UI-SPEC.md
|
||||
|
||||
Concurrency: another session may hold uncommitted edits in modules/cabana or modules/lagoon. Before Task 1 run `git status --porcelain -- modules admin docs scripts` in summercms.go; if it lists files this plan touches, stop and report (do not stash or overwrite another session's work). Stage files by explicit path; planning docs and code in separate commits; no co-author tags. Docker-backed cabana smoke tests skip under -short; run them on a Docker host (if the socket is denied although the user is in the docker group, run through `sudo -u golem`), and record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
New pact contracts (names fixed by this plan; doc comment on every exported identifier):
|
||||
|
||||
```go
|
||||
type MenuItemType struct {
|
||||
Key string // unique across plugins, e.g. "journal-post"
|
||||
Label string // phrase key
|
||||
DefaultPattern string // layer 1 of D-02, required on every type, starts with "/"
|
||||
PatternFrom string // optional, list types only: key of the type whose records the items are (e.g. "acme-post"); its host override applies when this type has none
|
||||
Placeholders []string // e.g. ":slug", ":category", ":id", ":year", ":month", ":day"
|
||||
Nesting bool
|
||||
DynamicItems bool
|
||||
References func(ctx context.Context) ([]MenuItemReference, error) `json:"-"` // nil: takes no reference
|
||||
Resolve func(ctx context.Context, in MenuItemResolve) (*ResolvedMenuItem, error) `json:"-"`
|
||||
}
|
||||
type MenuItemReference struct { Value, Label string; Items []MenuItemReference }
|
||||
type MenuItemResolve struct { Type, Reference, Pattern string; Nesting bool; UnprefixedLocale string }
|
||||
type ResolvedMenuItem struct { Title, URL string; MTime *time.Time; Alternates []MenuItemAlternate; Items []ResolvedMenuItem }
|
||||
type MenuItemAlternate struct { Locale, URL, Title string }
|
||||
type HasMenuItemTypes interface { MenuItemTypes() []MenuItemType }
|
||||
|
||||
type DependentOptionSet struct { Applies bool; Options []Option }
|
||||
type DependentOptionsProvider interface {
|
||||
DependentOptions(ctx context.Context, field string, values map[string]any) (DependentOptionSet, error)
|
||||
}
|
||||
type AdminSingleton interface {
|
||||
SingletonRecord(ctx context.Context, db *gorm.DB) (any, error) // first or create
|
||||
}
|
||||
// Option gains: Depth int `json:"depth,omitempty"`
|
||||
```
|
||||
|
||||
signpost (new):
|
||||
|
||||
```go
|
||||
const ConfigKey = "menu_items.patterns"
|
||||
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
|
||||
type Registry struct{ /* unexported */ }
|
||||
func (r *Registry) Types() []pact.MenuItemType // plugin activation order, then declaration order
|
||||
func (r *Registry) Type(key string) (pact.MenuItemType, bool)
|
||||
func (r *Registry) Pattern(key, itemOverride string) string // itemOverride, else host[key], else host[PatternFrom] when set, else DefaultPattern
|
||||
func (r *Registry) Resolve(ctx context.Context, key, reference, itemPattern string, nesting bool, unprefixedLocale string) (*pact.ResolvedMenuItem, error)
|
||||
func ReferenceOptions(ctx context.Context, r *Registry, typeKey string) (pact.DependentOptionSet, error)
|
||||
```
|
||||
|
||||
seaglass (new):
|
||||
|
||||
```go
|
||||
type Removals struct { Elements []string; Attributes []string; Comments int; Changed bool }
|
||||
func Sanitize(html string) string
|
||||
func SanitizeReport(html string) (string, Removals)
|
||||
func Check(html string) error
|
||||
```
|
||||
|
||||
Existing seams (read, do not change their behaviour): pact.FormFieldNotice and cabana localizeNotices/FieldNotice (meta.notices), cabana ProjectWritableFields and nestedValue (crud.go :159, :1272), requireSettingInputs (settings.go :287), markdownPreview route shape (field_markdown.go :71), fileReorderOn exact-set rule (field_file.go :1303), phrasebook.Activate wiring (party/registry.go :128).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a form's reference dropdown lists the records of the menu item type chosen in its sibling, served through the registry from a plugin's typed registration</name>
|
||||
<reversibility rating="costly">D-01 and D-08: pact.MenuItemType, HasMenuItemTypes and DependentOptionsProvider are implemented by Journal, menu, sitemap and every later content plugin; reshaping them means coordinated changes in all of them.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/signpost/signpost.go, modules/signpost/signpost_smoke_test.go, modules/signpost/README.md, modules/party/registry.go, modules/party/README.md, modules/cabana/form_schema.go, modules/cabana/field_dependent.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/companions/controllers/links/config_form.yaml, modules/cabana/testdata/companions/controllers/links/config_list.yaml, modules/cabana/testdata/companions/models/link/fields.yaml, modules/cabana/testdata/companions/models/link/columns.yaml, modules/cabana/companions_smoke_test.go, modules/cabana/security_coverage_test.go, modules/cabana/token_routes_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/openapi_conformance_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, README.md, docs/plugins/menu-item-types.md, docs/backend/forms.md</files>
|
||||
<read_first>modules/pact/capabilities.go (HasFormFieldTypes :491, SettingsItem :543, Option :568, DropdownOptionsProvider :574), modules/pact/README.md, modules/phrasebook/translator.go (Activate :84-120, the shape to copy), modules/party/registry.go (:100-150), modules/party/README.md, modules/cabana/form_schema.go (formFieldTypes and formFieldKeys :24-48, compileFieldNode), modules/cabana/field_markdown.go (markdownPreview :71-100: principal, tokenRouteGate, WriteData), modules/cabana/http.go (route table :250-398), modules/cabana/admin_openapi.go (AdminMarkdownPreview block :394-409), modules/cabana/security_coverage_test.go (phase09Routes :34), modules/cabana/token_routes_test.go (class map :51), modules/cabana/phase10_coverage_test.go (:101), modules/cabana/openapi_conformance_test.go (TestPhase10ConformanceInventory :134, newConformEnv :787, the markdown case :261), modules/cabana/field_type_smoke_test.go (TestFieldTypeTracerSmoke :160, fixture plugin pattern), modules/cabana/testdata/roster (fixture layout), modules/festival/README.md (standard README structure), README.md (module table), docs/backend/field-types.md (front matter), docs/site.yaml, scripts/check-admin-openapi.sh, .planning/phases/17-blog-companions/17-RESEARCH.md (Patterns 1, 2, 6)</read_first>
|
||||
<action>Per D-01, D-02, D-03 and D-08, wire one dependent dropdown through every framework layer.
|
||||
|
||||
pact: add the contracts listed in the interfaces block of this plan's context, with a doc comment on each exported identifier; Option gains Depth (json depth, omitempty) so existing dropdown payloads are unchanged. pact README: API reference rows and a Features line.
|
||||
|
||||
signpost (new module, package signpost): Activate iterates the activated plugins in order, type-asserts pact.HasMenuItemTypes and builds a Registry. Boot errors, each prefixed "signpost: ": empty Key; duplicate Key (message names the key and both plugin IDs); nil Resolve; DefaultPattern empty or not starting with "/" (every type, list types included, D-02 layer 1); after all types are collected, a PatternFrom that names an unregistered key, the type's own key, or a type that itself sets PatternFrom (one level only). It then reads ConfigKey with app.Config.LoadSection into a map from type key to pattern (absent key means no overrides); an override for an unregistered key or one not starting with "/" is a boot error. It publishes the Registry with app.Publish. Registry.Pattern(key, itemOverride) returns itemOverride when non-empty, else the host override for key, else (when the type sets PatternFrom) the host override for the PatternFrom key, else the type's own DefaultPattern; this is how a list type's children follow the host pattern of their record type while an item or host override of the list type still wins (D-02). Registry.Resolve looks up the type (unknown key is an error), builds pact.MenuItemResolve with the layered pattern and calls the type's Resolve. ReferenceOptions returns Applies false when the type is unknown to the registry or its References is nil; otherwise it calls References and flattens the tree depth-first into options whose Depth is the nesting level (0 at the top). README in the standard structure (H1, summary sentence, import line, Name mnemonic, Overview, Features, Usage with an acme example, API reference, Configuration naming menu_items.patterns as YAML-file only, Dependencies, Testing); root README table row reusing the summary sentence.
|
||||
|
||||
party: call signpost.Activate(app, ordered) after postcard.Activate and before the Boot loop, wrapping errors as "party: menu item types: %w". party README notes the step.
|
||||
|
||||
cabana compile: `dependsOn` becomes a known field key, valid only on type dropdown (elsewhere a boot error naming the field); its value is a sibling field name in the same form that must exist (boot error otherwise); a dependsOn dropdown may not also declare options; the controller must implement pact.DependentOptionsProvider (boot error naming the controller and field otherwise). The compiled form schema exposes dependsOn to the SPA.
|
||||
|
||||
cabana route (field_dependent.go): register GET {vendor}/{plugin}/{controller}/fields/{field}/dependent-options in http.go next to the other controller routes. It authenticates like the form schema route (backend principal, controller RequiredPermissions, the token gate the schema routes use), reads query parameters values[name]=value into a map, refuses a field that is not a dependsOn dropdown with 404, calls the provider, localizes option labels in the request locale and writes {data:{applies, options}} with WriteData. Add the swag block (AdminDependentOptions, result type AdminDependentOptionsResult), the phase09Routes entry, the token route class (read route gated like GET schema/form), the phase 10 coverage entry and a conformance case; run scripts/check-admin-openapi.sh (regenerates admin/openapi/admin.json and admin/src/api/schema.d.ts) and npm --prefix admin run typecheck.
|
||||
|
||||
Fixture and smoke: testdata/companions with controller links (fields type dropdown fed by DropdownOptionsProvider = built-in url plus the registry's types, and reference dropdown with dependsOn type). companions_smoke_test.go: a fixture plugin registers type acme-page with a two-level References tree and a Resolve that echoes the layered pattern, and a second type acme-list with nil References; TestCompanionsDependentSmoke (Postgres, conform-style env) asserts: values[type]=acme-page answers applies true with depth 0 and 1 options; values[type]=acme-list answers applies false; an unauthenticated call is 401. signpost_smoke_test.go TestSignpostSmoke (no Docker): duplicate keys fail Activate with both plugin IDs in the message; Pattern layers item override, host override and default; a list type acme-posts with PatternFrom acme-post follows a host override set only for acme-post, a host override of acme-posts beats it, and an item override beats both; a PatternFrom naming an unregistered type fails Activate.
|
||||
|
||||
Docs: new docs/plugins/menu-item-types.md (front matter section plugins) explaining HasMenuItemTypes, Resolve, alternates order, pattern layers including PatternFrom for list types (declare the same DefaultPattern as the listed type and build children from MenuItemResolve.Pattern) and menu_items.patterns with neutral acme examples; docs/backend/forms.md gains a dependsOn section with DependentOptionsProvider and signpost.ReferenceOptions. cabana README Features and API reference updated in the same commit.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -run '^TestSignpostSmoke$' -v ./modules/signpost 2>&1 | grep -- '^--- PASS: TestSignpostSmoke' && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && go test -count=1 ./modules/party ./modules/pact && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestSignpostSmoke" (duplicate keys accepted, the pattern layers wrong, or a PatternFrom list type ignores its record type's host override); fewer than three inventory passes (the new route lacks a phase09Routes entry, a token class or a conformance case); party or pact tests regress; admin.json or schema.d.ts drift from the swag annotations; the SPA no longer typechecks; the docs checker reports an unknown identifier, a missing README or a broken link.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^TestCompanionsDependentSmoke$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestCompanionsDependentSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the route did not answer the registry's references with depths, answered applies true for a type without references, or let an anonymous caller through.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type HasMenuItemTypes interface" modules/pact/capabilities.go` and `grep -n "type DependentOptionsProvider interface" modules/pact/capabilities.go` each print one line.
|
||||
- `grep -c "PatternFrom" modules/pact/capabilities.go modules/signpost/signpost.go docs/plugins/menu-item-types.md` reports at least 1 per file.
|
||||
- `grep -n "signpost.Activate" modules/party/registry.go` prints one line located after the postcard.Activate call.
|
||||
- `grep -n "dependent-options" modules/cabana/http.go modules/cabana/security_coverage_test.go modules/cabana/token_routes_test.go` prints at least one line per file.
|
||||
- `grep -c "signpost" README.md` is at least 1 and modules/signpost/README.md contains the lines "**Name:**" and "menu_items.patterns".
|
||||
- `bash scripts/check-admin-openapi.sh --check` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>A plugin's typed menu item type is collected at boot, and an admin dropdown that depends on its sibling lists that type's references through one route, documented and in the OpenAPI.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: richeditor field type on a new seaglass sanitizer (bluemonday, decision note): every save sanitized, clean HTML byte-identical, removals reported</name>
|
||||
<reversibility rating="costly">D-10: a new field type in the typed admin schema plus a framework dependency; later plugins and the SPA editor build on its value contract.</reversibility>
|
||||
<files>.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md, go.mod, go.sum, README.md, modules/seaglass/seaglass.go, modules/seaglass/seaglass_smoke_test.go, modules/seaglass/README.md, modules/cabana/form_schema.go, modules/cabana/field_richeditor.go, modules/cabana/settings.go, modules/cabana/relation_form.go, modules/cabana/README.md, modules/cabana/testdata/companions/models/link/fields.yaml, modules/cabana/companions_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, docs/backend/forms.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/format_html.go (SanitizeHTMLReport :164, removalReport, CheckHTML :335, sanitizeHTML :348 and the policy it builds, sameTokens), ../sm-journal-plugin/classes/blog_editor.go (decodeBlogHTML :154, removalNotices :171), .planning/notes/journal-html-sanitizing-bluemonday.md (rejected regex gates, bypass list), modules/cabana/field_type.go (pluginNotice, decodeOne :530, localizeNotices :781), modules/cabana/relation_field.go (FieldNotice :104), modules/cabana/settings.go (requireSettingInputs :287 and the settings compile path), modules/cabana/relation_form.go (refusing registry for plugin types), modules/cabana/field_markdown.go (the existing markdown gate to leave untouched), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (form: block), ../sm-journal-plugin/lang/en/lang.yaml (editor.* notice copy to mirror), go.mod, .planning/phases/17-blog-companions/17-UI-SPEC.md (Copywriting: richeditor.removed_* and markup_changed)</read_first>
|
||||
<action>Per D-10 and D-06, add the core richeditor type with a real sanitizer.
|
||||
|
||||
Decision note first, committed alone as a planning doc: .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md names github.com/microcosm-cc/bluemonday v1.0.27 for the framework (same library and version Journal already uses, RESEARCH Standard Stack and Pitfall 12), says why the regex gate of cabana's markdown preview is not reused, and records the D-10 Journal-clause deviation (Journal category content stays blogeditor).
|
||||
|
||||
seaglass (new module): port Journal's policy construction from format_html.go without the four Blog Editor figure and embed rules (the framework must not import the plugin), keep its link scheme allowlist (http, https, mailto, tel, relative) and its rel handling, and its token-equality comparison through golang.org/x/net/html so re-escaping alone is not a change. SanitizeReport returns the input unchanged with a zero Removals when the policy output is token-equal; otherwise the clean string and the removed element names, attribute names and comment count with Changed true. Sanitize returns only the string; Check returns an error when anything would change. `go get github.com/microcosm-cc/bluemonday@v1.0.27`. README in the standard structure with Dependencies naming bluemonday; root README row.
|
||||
|
||||
cabana richeditor: add "richeditor" to formFieldTypes (accepted keys: the common field keys plus size). On create and update every submitted richeditor value must be a string or null (anything else is a 422 on the field); a string goes through seaglass.SanitizeReport and the clean value is written; a non-zero Removals becomes notices on the field path through the same notice plumbing plugin field types use (phrase keys richeditor.removed_elements, richeditor.removed_attributes, richeditor.removed_comments with count and names, richeditor.markup_changed when only markup changed), localized into meta.notices. Settings forms and relation forms refuse richeditor at boot with "richeditor is supported in controller forms only" (and the same for repeater and dependsOn), so no save path bypasses the gate. Phrase keys added to modules/phrasebook/backend/lang/en and pl with the UI-SPEC copy (CLDR plural forms for pl).
|
||||
|
||||
Smoke: TestSeaglassSmoke (no Docker): a clean paragraph-and-list snippet comes back byte-identical; a snippet with an inline event handler, a javascript: link and a script element comes back without them and with those names in Removals. TestCompanionsRichEditorSmoke (Postgres): the links fixture gains a description richeditor field; creating a record with a script element and an onclick attribute stores the clean value and answers meta.notices for description; saving clean HTML stores it byte-identical and answers no notices.
|
||||
|
||||
Docs: docs/backend/forms.md gains a richeditor section (value contract, sanitize on save, notices, controller forms only); cabana README Features and field type list updated in the same commit as the code.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -run '^TestSeaglassSmoke$' -v ./modules/seaglass 2>&1 | grep -- '^--- PASS: TestSeaglassSmoke' && grep -q 'github.com/microcosm-cc/bluemonday v1.0.27' go.mod && go test -count=1 -short ./modules/cabana && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; no "--- PASS: TestSeaglassSmoke" (clean HTML was rewritten or an unsafe construct survived); go.mod lacks bluemonday v1.0.27; a Docker-free cabana test regressed (for example a settings or relation form accepted richeditor); the docs checker reports an unknown identifier or a missing README.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^TestCompanionsRichEditorSmoke$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestCompanionsRichEditorSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or an unsanitized value was stored, clean HTML was not stored byte-identical, or the removal notices are missing from meta.notices.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md` succeeds and `git log --format=%s -1 -- .planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md` starts with "docs".
|
||||
- `grep -n '"richeditor"' modules/cabana/form_schema.go` prints at least one line and `grep -n "seaglass.SanitizeReport" modules/cabana/field_richeditor.go` prints at least one line.
|
||||
- `grep -c "removed_elements" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
- `grep -n "seaglass" README.md` prints a table row.
|
||||
</acceptance_criteria>
|
||||
<done>A controller form field of type richeditor stores only sanitized HTML, leaves clean HTML byte-identical and tells the editor what it removed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: repeater field type with nested fields, per-row errors, nested richeditor sanitizing and nested dependsOn, plus the dependent save recheck</name>
|
||||
<reversibility rating="costly">D-07: the repeater schema keys and the stored JSON row shape are what FAQ content and sitemap definitions persist; reshaping them means migrating stored rows in every plugin that uses it.</reversibility>
|
||||
<files>modules/cabana/form_schema.go, modules/cabana/field_repeater.go, modules/cabana/field_dependent.go, modules/cabana/crud.go, modules/cabana/README.md, modules/cabana/testdata/companions/models/link/fields.yaml, modules/cabana/companions_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/crud.go (ProjectWritableFields :159-175, the second nestedValue use :1135, nestedValue :1272, create and update decode paths), modules/cabana/form_schema.go (compile of fields, formFieldKeys), modules/cabana/field_richeditor.go and field_dependent.go (Tasks 1 and 2), modules/cabana/field_type.go (sameJSON :651, untouched skip, notices), modules/lagoon/jsonable.go (Jsonable :21), modules/cabana/field_ml.go (nested compile of a wrapper field), .planning/phases/17-blog-companions/17-RESEARCH.md (Pattern 5, Pattern 6 save recheck), .planning/phases/17-blog-companions/17-UI-SPEC.md (RepeaterField contract, error copy repeater.max_items and min_items, form.dependent_invalid)</read_first>
|
||||
<action>Per D-07 and D-08, add the repeater and close the dependent dropdown on save.
|
||||
|
||||
Compile: "repeater" joins formFieldTypes. Accepted keys: label, comment, span (always compiled to full), required, prompt, maxItems, minItems, titleFrom, form. form must hold fields; groups and style are refused loudly ("repeater groups are not supported" and the same for style). Nested fields compile with the same rules as top-level fields restricted to text, textarea, number, checkbox, switch, dropdown and richeditor; any other nested type (relation, relation-manager, fileupload, widget, partial, password, datepicker, permissioneditor, markdown, ml types, plugin field types, repeater) fails boot naming the repeater and the nested field. titleFrom must name a nested field. A nested dropdown with dependsOn must name a sibling in the same row. minItems and maxItems are non-negative and minItems is at most maxItems. The compiled schema carries form.fields, prompt, maxItems, minItems and titleFrom.
|
||||
|
||||
Save: in ProjectWritableFields exempt only fields compiled as repeater from the nested-value refusal (nestedValue itself stays unchanged). The repeater decode accepts null (stored as an empty list) or an array of objects; anything else is a 422 on the field. Each row keeps only nested field names; scalars are coerced per nested type (number to a JSON number, checkbox and switch to bool, the rest to string or null); a nested required value missing in a row is a 422 under the key field.index.nested (for example content.3.answer); a row count above maxItems or below minItems is a 422 on the field with repeater.max_items or repeater.min_items. Nested richeditor values go through seaglass with notices under field.index.nested. The model column receives the decoded []map[string]any (models use lagoon.Jsonable).
|
||||
|
||||
Dependent recheck (D-08): on create and update, every dependsOn dropdown (top level, and in each repeater row with the row's sibling value) is rechecked by calling the provider with the submitted sibling value. When applies is false the stored value is null. When applies is true a non-null value that is not among the option values is a 422 with form.dependent_invalid on the field (row path for nested fields).
|
||||
|
||||
Phrase keys repeater.max_items, repeater.min_items and form.dependent_invalid (en, pl) from the UI-SPEC copy.
|
||||
|
||||
Smoke: the links fixture gains a repeater rows with nested title (text, required), body (richeditor) and target type plus reference (dependsOn type). TestCompanionsRepeaterSmoke (Postgres): a valid two-row save stores both rows with only nested keys; a row missing title answers 422 with key rows.1.title; a nested body with a script element is stored clean with a notice under rows.0.body; a nested reference not offered for its row's type answers 422 under rows.0.reference; a top-level reference outside the options answers 422 with form.dependent_invalid. Docs: docs/backend/forms.md repeater section with an acme example and the nested type list; cabana README updated.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana ./modules/lagoon && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free cabana or lagoon test regressed (for example nested values are now accepted on non-repeater fields); the docs checker reports an unknown identifier.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestCompanionsRepeaterSmoke|TestCompanionsRichEditorSmoke|TestCompanionsDependentSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: a repeater row kept unknown keys, a per-row error used the wrong key, a nested richeditor value was stored unsanitized, or a dependent value outside its options was accepted.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"repeater"' modules/cabana/form_schema.go` prints at least one line.
|
||||
- The nestedValue body is unchanged against the pre-phase head 5c75b31 (one bash command, no temporary file, no unset environment variable; the git status is captured before any pipe) exits 0: `PRE=$(git show 5c75b31:modules/cabana/crud.go) && test "$(printf '%s\n' "$PRE" | grep -c '^func nestedValue')" -eq 1 && diff <(printf '%s\n' "$PRE" | sed -n '/^func nestedValue/,/^}/p') <(sed -n '/^func nestedValue/,/^}/p' modules/cabana/crud.go)`.
|
||||
- `grep -c "dependent_invalid" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 1 per file.
|
||||
</acceptance_criteria>
|
||||
<done>Editors can save lists of rows with nested fields; every row is validated, sanitized and rechecked on the server.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: tree reorder (config_reorder.yaml, GET and POST routes, toolbar button) and the singleton controller, with OpenAPI, inventories and docs</name>
|
||||
<reversibility rating="costly">D-09 and D-11: the config_reorder.yaml keys, the reorder payload and pact.AdminSingleton become the contract the menu and sitemap plugins (and later ones) declare.</reversibility>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/reorder.go, modules/cabana/singleton.go, modules/cabana/list_schema.go, modules/cabana/form_schema.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/README.md, modules/cabana/testdata/companions/controllers/links/config_reorder.yaml, modules/cabana/testdata/companions/controllers/links/config_list.yaml, modules/cabana/companions_smoke_test.go, modules/cabana/security_coverage_test.go, modules/cabana/token_routes_test.go, modules/cabana/phase10_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, docs/backend/lists-and-filters.md, docs/backend/admin-controllers.md, docs/backend/settings.md</files>
|
||||
<read_first>modules/cabana/field_file.go (fileReorderOn :1303: exact visible-set rule, transaction shape), modules/cabana/list_schema.go (toolbarButtons :315-380, compileToolbarButtons), modules/cabana/crud.go (ListExtendQuery and FormExtendQuery use, delete and create handlers), modules/cabana/http.go (route table), modules/cabana/settings.go (SettingsItem Controller link handling), modules/cabana/admin_openapi.go, the four inventory test files, .planning/phases/17-blog-companions/17-RESEARCH.md (Pattern 7), .planning/phases/17-blog-companions/17-UI-SPEC.md (Reorder page saving contract, Singleton form), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/controllers/menuentries/reorder_config.yaml</read_first>
|
||||
<action>Per D-09, D-11 and D-06.
|
||||
|
||||
Reorder config: a controller's ConfigDir may hold config_reorder.yaml with keys title, nameFrom, modelClass (Winter), parentColumn (default parent_id), sortColumn (default sort_order), maxDepth (0 means unlimited) and an optional partition block {column, relation, nameFrom, label}; any other key fails boot. At boot the parent, sort and partition columns must be columns of the controller's record (NewRecord); the partition relation must be a belongsTo of that record. toolbar.buttons accepts reorder only when the controller has this config (boot error "toolbar.buttons: reorder needs config_reorder.yaml" otherwise); the list schema exposes reorder (and the partition column) to the SPA.
|
||||
|
||||
Routes (reorder.go): GET {vendor}/{plugin}/{controller}/reorder?partition=<id> answers {data:{title, partition:{label, options:[{value,label}], value}|null, nodes:[{id, parent_id, sort_order, name}]}} with the records scoped through ListExtendQuery and the partition filter, ordered by sort column then id; the partition defaults to the first option when absent. POST {vendor}/{plugin}/{controller}/reorder (requireAjax, CSRF like every write) with {partition, nodes:[{id, parent_id, sort_order}]} runs in one transaction: select the scoped set FOR UPDATE; when the submitted ids differ from it in any way answer 409 with error code reorder_stale and message reorder.stale; refuse a duplicate id, a parent_id outside the set, a cycle and a depth above maxDepth with 422 validation_failed; then update the parent and sort columns of every node and answer the new tree. Both routes require the controller's RequiredPermissions; the POST is a write route for tokens (gated like update).
|
||||
|
||||
Singleton (singleton.go): pact.AdminSingleton (SingletonRecord(ctx, db) (any, error), first-or-create) on an admin controller enables GET {vendor}/{plugin}/{controller}/singleton answering {data:{id}}; the compiled form schema carries singleton true; such a controller needs no config_list.yaml, and its list, create, bulk and delete routes answer 404 not_found. A SettingsItem whose Controller names a singleton controller stays a link (the SPA resolves the record).
|
||||
|
||||
Gates: swag blocks for the three routes, phase09Routes entries, token route classes, phase 10 coverage entries and conformance cases; scripts/check-admin-openapi.sh regenerates admin.json and schema.d.ts; npm --prefix admin run typecheck. Phrase keys reorder.stale and reorder.load_failed (server side) in en and pl.
|
||||
|
||||
Smoke (Postgres): TestCompanionsReorderSmoke on the links fixture with partition group: GET lists only the partition's nodes; a POST that nests one node under another writes both columns; a POST that omits a node answers 409 reorder_stale and changes nothing; a POST with a cycle answers 422; a POST naming a node of another partition answers 409. TestCompanionsSingletonSmoke: a fixture singleton controller creates its record on the first GET, returns the same id on the second, and answers 404 on its list and delete routes.
|
||||
|
||||
Docs: docs/backend/lists-and-filters.md reorder section (config_reorder.yaml keys, partition, the 409 contract); docs/backend/admin-controllers.md singleton section; docs/backend/settings.md notes a Controller link to a singleton; pact and cabana READMEs updated.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase10ConformanceInventory|TestTokenRouteGates|TestPhase09PermissionMatrix)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go test -count=1 -short ./... && bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet fails; fewer than four inventory passes (a new route lacks an inventory entry, token class or conformance case); any Docker-free test in the module regressed; admin.json or schema.d.ts drift; the SPA does not typecheck; the docs checker fails.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestCompanionsReorderSmoke|TestCompanionsSingletonSmoke|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: a stale or foreign set was written, a cycle was accepted, both columns were not written, the singleton was created twice or its list answered, or a response does not match its OpenAPI schema.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "reorder_stale" modules/cabana/reorder.go` prints at least one line.
|
||||
- `grep -n "type AdminSingleton interface" modules/pact/capabilities.go` prints one line.
|
||||
- `grep -n '/reorder\|/singleton' modules/cabana/http.go` prints at least three lines.
|
||||
- `grep -n "config_reorder.yaml" docs/backend/lists-and-filters.md` and `grep -n "AdminSingleton" docs/backend/admin-controllers.md` each print at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>Any parent and sort-order model can be reordered as a tree on the server with stale and cycle protection, and a controller can expose exactly one record as a form.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → cabana API | authenticated but untrusted input (HTML, row arrays, reorder sets, query values) |
|
||||
| plugin code → signpost registry | trusted compiled code, validated at boot |
|
||||
| host config → signpost | operator-controlled patterns |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-01 | Tampering (stored XSS) | richeditor save, top-level and nested | high | mitigate | seaglass (bluemonday v1.0.27, Journal-equivalent policy) on every create and update; settings and relation forms refuse the type at boot; TestCompanionsRichEditorSmoke and TestCompanionsRepeaterSmoke |
|
||||
| T-17-02 | Tampering (mass assignment) | repeater rows | medium | mitigate | rows keep only compiled nested field names; the nested-value exemption is limited to repeater fields; non-array input is a 422 |
|
||||
| T-17-03 | Tampering | dependent dropdown values | medium | mitigate | server recheck against the provider's options for the submitted sibling value, per row; 422 form.dependent_invalid |
|
||||
| T-17-04 | Elevation of Privilege (IDOR) | reorder POST | high | mitigate | scoped set through ListExtendQuery plus partition, locked FOR UPDATE; the submitted id set must equal it exactly (409 otherwise); parents must be in the set; controller permissions and token write gate |
|
||||
| T-17-05 | Denial of Service / Tampering | reorder tree validation | medium | mitigate | cycle detection, duplicate ids and maxDepth refused before any write; one transaction |
|
||||
| T-17-06 | Information Disclosure | dependent-options and singleton routes | medium | mitigate | backend principal, controller RequiredPermissions and the same token gate as schema routes; anonymous 401 in the smoke test |
|
||||
| T-17-07 | Spoofing (CSRF) | reorder POST | medium | mitigate | requireAjax plus the existing CSRF check, like every cabana write route |
|
||||
| T-17-08 | Tampering | signpost boot | low | mitigate | duplicate keys, empty keys, nil Resolve, empty or relative DefaultPattern, a dangling, self or chained PatternFrom and unknown host overrides fail boot; no runtime registration |
|
||||
| T-17-09 | Tampering (supply chain) | bluemonday in go.mod | low | mitigate | exact v1.0.27, already used by Journal; decision note; go.sum committed |
|
||||
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no npm package is installed in this plan; the Go dependency is pinned with go.sum (package-legitimacy gate applies to the TipTap install in plan 02) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: signpost smoke, route inventories, OpenAPI drift check, typecheck, docs; the dependent-options smoke on a Docker host.
|
||||
- Task 2: seaglass smoke, bluemonday pinned, Docker-free cabana suite, docs; the richeditor smoke on a Docker host.
|
||||
- Task 3: Docker-free cabana and lagoon suites, docs; repeater, richeditor and dependent smokes on a Docker host.
|
||||
- Task 4: inventories, whole-repo -short suite, OpenAPI drift, typecheck, docs; reorder, singleton and OpenAPI conformance on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2: the typed registry exists and fails boot on conflicts; the one admin binding (dependent dropdown) reads it.
|
||||
- SC-1 (framework half): repeater, richeditor, dependent dropdown, tree reorder and singleton form exist server side with fail-loud schemas, docs and OpenAPI.
|
||||
- `go vet ./...` and `go test -short ./...` stay green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-01-SUMMARY.md` when done, with the commits, the exported identifiers added per module, the Docker smoke tests run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
340
.planning/phases/17-blog-companions/17-02-PLAN.md
Normal file
340
.planning/phases/17-blog-companions/17-02-PLAN.md
Normal file
@@ -0,0 +1,340 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["17-01"]
|
||||
files_modified:
|
||||
- admin/package.json
|
||||
- admin/package-lock.json
|
||||
- admin/src/components/form/fields/RichEditorField.vue
|
||||
- admin/src/components/form/richeditor/editor.ts
|
||||
- admin/src/components/form/richeditor/LinkPopover.vue
|
||||
- admin/src/components/form/richeditor/lossy.ts
|
||||
- admin/src/components/form/fields/RepeaterField.vue
|
||||
- admin/src/components/form/fields/DropdownField.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/FormGrid.vue
|
||||
- admin/src/views/ReorderView.vue
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/SettingsIndexView.vue
|
||||
- admin/src/app/router.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/tests/form/RichEditorField.test.ts
|
||||
- admin/tests/form/RepeaterField.test.ts
|
||||
- admin/tests/form/DependentDropdown.test.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
- admin/tests/fixtures/companions.form-schema.json
|
||||
- admin/tests/fixtures/lang.json
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/forms.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, D-06, D-07, D-08, D-09, D-10, D-11]
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-10 (SPA): richeditor renders the RichEditorField from the UI-SPEC (Reka toolbar with bold, italic, underline, heading 3, bullet and numbered list, quote, link and unlink, undo and redo, an Edit HTML toggle, a link popover that accepts only http, https, mailto, tel and relative URLs) on TipTap 3.31.4 (core, pm, starter-kit pinned exact, installed only after the human package check); the TipTap code is a lazy chunk with a skeleton while it loads and a plain HTML textarea fallback when it fails."
|
||||
- "D-10 untouched rule (UI-SPEC backstop): a loaded richeditor value the user does not edit is submitted byte-identical (never re-serialized by TipTap); after the first edit the value is the editor HTML and an editor holding only an empty paragraph emits an empty string; imported HTML with elements outside the editor schema shows the lossy note and stays editable without loss in the HTML view."
|
||||
- "D-10 notices: removal notices the server returns in meta.notices render under the field through the existing FormField warning callout, and the editor shows the sanitized value the save returned."
|
||||
- "D-07 (SPA): RepeaterField renders rows with drag handle, index, titleFrom title (repeater.untitled when empty), error chip, move up and down, expand and collapse, remove (asking first only for a non-empty row), an outline add button with the field prompt, the maxItems counter, the dashed empty state, collapse all and expand all; a collapsed row does not mount its richeditor; path errors such as content.3.answer expand the row and focus the first invalid nested control; every add, remove, reorder and nested edit marks the form dirty and nothing is sent before Save."
|
||||
- "D-08 (SPA): a dropdown with dependsOn shows the UI-SPEC states (waiting for the sibling, loading with aria-busy, not used when applies is false, empty with form.dependent_empty, loaded with depth-indented labels, failed with a retry button); a sibling change refetches with last-request-wins and caches per sibling value, and a value no longer offered is cleared to null, marks the form dirty and is announced politely; the same component serves the menu entry form and the sitemap repeater rows."
|
||||
- "D-09 (SPA): a list whose schema declares reorder shows an outline Reorder button before New record that opens ReorderView with the partition query; ReorderView renders the indented tree with drag handle and move up, move down, indent and outdent buttons (keyboard arrows on the handle do the same), refuses drops into a node's own subtree, saves the whole node set in one POST debounced 400 ms after the last move, flushes on leave, reverts with reorder.failed on error, reloads with reorder.stale on a 409, and shows loading, empty, load-failure and 403 states."
|
||||
- "D-11 (SPA): a settings entry that links to a singleton controller opens that controller's one record in FormView update mode through GET …/singleton, with no Delete button, back to the settings index, and a subtitle link supplied by the form schema."
|
||||
- "D-06: admin/src/api/schema.d.ts stays in sync, modules/boardwalk/dist is rebuilt and committed so scripts/check-admin-dist.sh passes, and every new UI string is a key in modules/phrasebook/backend/lang en and pl with the UI-SPEC copy (no literal UI strings in components)."
|
||||
- "Release (user checkpoint): after a human approves at a blocking checkpoint, an annotated summercms tag (the next free v0.1.x after the highest at execution time; v0.1.22 at planning time) containing plans 01 and 02 is on origin and master is pushed; no pushed tag is moved."
|
||||
artifacts:
|
||||
- path: "admin/src/components/form/fields/RichEditorField.vue"
|
||||
provides: "richeditor field on TipTap with untouched rule, HTML view, link popover, lossy note"
|
||||
contains: "richeditor"
|
||||
- path: "admin/src/components/form/fields/RepeaterField.vue"
|
||||
provides: "repeater rows with nested FormGrid"
|
||||
contains: "data-repeater"
|
||||
- path: "admin/src/views/ReorderView.vue"
|
||||
provides: "tree reorder page"
|
||||
contains: "data-reorder-tree"
|
||||
- path: "admin/tests/form/RichEditorField.test.ts"
|
||||
provides: "untouched byte-identical smoke"
|
||||
contains: "byte-identical"
|
||||
- path: "modules/boardwalk/dist"
|
||||
provides: "committed SPA build"
|
||||
key_links:
|
||||
- from: "admin/src/components/form/registry.ts"
|
||||
to: "RichEditorField.vue and RepeaterField.vue"
|
||||
via: "renderers map entries richeditor and repeater; repeater in groupLabelledTypes"
|
||||
pattern: "\\['repeater', RepeaterField\\]"
|
||||
- from: "admin/src/components/form/fields/DropdownField.vue"
|
||||
to: "GET …/fields/{field}/dependent-options"
|
||||
via: "openapi-fetch client keyed on the sibling value"
|
||||
pattern: "dependent-options"
|
||||
- from: "admin/src/views/ReorderView.vue"
|
||||
to: "GET and POST …/reorder"
|
||||
via: "debounced full-set POST"
|
||||
pattern: "reorder"
|
||||
prohibitions:
|
||||
- statement: "No package is installed before the human approves it at the legitimacy checkpoint; only @tiptap/core, @tiptap/pm and @tiptap/starter-kit enter admin/package.json, pinned exact; no drag-and-drop or UI library is added."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No tag or branch is pushed before the human approves at the release checkpoint; no pushed tag is moved or deleted; no credential is printed or committed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No new admin tokens, colours, fonts, radii or shadows; components reuse the Direction C classes named in the UI-SPEC."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 2 of 6, admin SPA plus framework release, wave 2, runs beside plans 03 and 04 which never edit summercms.go): the admin renders and edits the plan 01 features, the dist is committed, and a human-approved summercms tag publishes them.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Edge items owned by this plan are the UI-SPEC backstops lifted into truths (untouched value byte-identical, repeater remove and reorder semantics, reorder save contract). Prohibitions recalled in-prompt, descriptor-less.
|
||||
|
||||
## UI Considerations (lifted from 17-UI-SPEC.md)
|
||||
|
||||
Resolved explicit items are truths above (states of richeditor, repeater, dependent dropdown, reorder tree, singleton form). Backstops: `{statement: "loading HTML with b, div and attributes and saving without an edit submits the stored string byte-identical", verification: backstop}`, `{statement: "removing a non-empty repeater row asks first, an empty one does not; arrow keys and buttons reorder and announce repeater.moved; nothing is sent before Save", verification: backstop}`, `{statement: "three quick tree moves send one debounced POST with the full node set; a failed POST restores the previous tree; each keyboard move announces in the live region", verification: backstop}`. This plan writes smoke specs for the first; plan 06 completes all three. No unresolved items.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- TipTap 3.31.4 (core, pm, starter-kit) mounted on a ref element without @tiptap/vue-3 (RESEARCH Supporting table), starter-kit configured as the UI-SPEC says (headings [3], Link with openOnClick false, autolink false, protocols http, https, mailto, tel; Code, CodeBlock, HorizontalRule off; injectCSS false).
|
||||
- Native HTML5 drag and drop plus button and keyboard alternatives, as FileuploadField does; no drag-and-drop dependency.
|
||||
- Singleton handling lives in FormView behind the schema's singleton flag (no separate view).
|
||||
- Release tag: the highest `v0.1.*` plus one at execution time, annotated, created only after the checkpoint. Hosts are not bumped here; plan 05 bumps the plugins' go.mod requires to this tag.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- SPA: `RichEditorField.vue` with `richeditor/{editor.ts, LinkPopover.vue, lossy.ts}`, `RepeaterField.vue`, dependsOn support in `DropdownField.vue`, `ReorderView.vue` and its route, the Reorder list button, singleton handling in `FormView.vue` and `SettingsIndexView.vue`.
|
||||
- Phrase keys (en, pl): `repeater.*`, `richeditor.*`, `reorder.*`, `form.dependent_*`, `messages.list.reorder`.
|
||||
- Smoke specs: `RichEditorField.test.ts`, `RepeaterField.test.ts`, `DependentDropdown.test.ts`, `ReorderView.test.ts`.
|
||||
- Rebuilt `modules/boardwalk/dist`; summercms annotated tag (next free v0.1.x) on origin.
|
||||
|
||||
<objective>
|
||||
Let administrators use the plan 01 features in the admin SPA and publish the framework as a tagged release.
|
||||
|
||||
Purpose: SC-1 needs working admin screens; D-06 requires the SPA, dist and types to ship with the framework features.
|
||||
Output: four SPA features with smoke specs, rebuilt dist, a pushed tag after human approval.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-UI-SPEC.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
|
||||
@.planning/notes/2026-10-07-embedded-admin-release-gate.md
|
||||
|
||||
Plan 01's routes and schema keys are the contract: dependsOn on dropdown fields, the repeater schema (form.fields, prompt, maxItems, minItems, titleFrom), richeditor, GET …/fields/{field}/dependent-options answering {applies, options[{value,label,depth}]}, GET and POST …/reorder (409 reorder_stale), GET …/singleton, the list schema reorder flag and the form schema singleton flag. Types come from admin/src/api/schema.d.ts (already regenerated by plan 01).
|
||||
|
||||
Concurrency: before Task 2 run `git status --porcelain -- admin modules/boardwalk modules/phrasebook` in summercms.go; stop and report if another session has edits there. Stage by explicit path; no co-author tags; SPA source, dist and planning docs in separate commits.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 1: Package legitimacy check before installing TipTap in the admin SPA (all three packages flagged too-new)</name>
|
||||
<read_first>.planning/phases/17-blog-companions/17-RESEARCH.md (Package Legitimacy Audit, Standard Stack JS), admin/package.json, ../sm-journal-plugin/package.json (the same versions the Phase 16.2 check approved for Journal)</read_first>
|
||||
<action>Install nothing. Run the read-only registry query in the verify command and print, for @tiptap/core, @tiptap/pm and @tiptap/starter-kit at 3.31.4: version, license, repository URL, publish time and the scripts object (preinstall, install and postinstall must be absent). Print https://www.npmjs.com/package/@tiptap/core, https://www.npmjs.com/package/@tiptap/pm, https://www.npmjs.com/package/@tiptap/starter-kit and https://github.com/ueberdosis/tiptap/releases. State the recorded choice: 3.31.4 is the version Journal already pins after the Phase 16.2 human check; the flag is the release age, not the packages. Stop for the human. This checkpoint is never auto-approved.</action>
|
||||
<what-built>Nothing yet: a read-only registry report for the three editor packages.</what-built>
|
||||
<how-to-verify>Open each npm page: publisher is the TipTap organisation (ueberdosis), repository github.com/ueberdosis/tiptap, license MIT, version 3.31.4 exists with normal weekly downloads and no install scripts; check the 3.31.4 release notes for a security advisory or a yanked release.</how-to-verify>
|
||||
<resume-signal>Reply "packages approved" to install 3.31.4, "use X.Y.Z" to pin another 3.x release for all three (the executor re-runs the query for it), or "stop".</resume-signal>
|
||||
<verify>
|
||||
<automated>for p in @tiptap/core @tiptap/pm @tiptap/starter-kit; do npm view "$p@3.31.4" version license repository.url --json || exit 1; npm view "$p@3.31.4" scripts --json | grep -E '"(preinstall|install|postinstall)"' && exit 1; done; echo registry-ok</automated>
|
||||
<fails_when>Non-zero exit or "registry-ok" absent: a package or version is missing from the registry, or a package declares a preinstall, install or postinstall script.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The verify printed registry-ok and the SUMMARY records each package's license, repository URL and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human approved the exact TipTap version, or the plan stopped.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: an administrator edits a richeditor field, saves, sees what the server removed, and an untouched imported value is submitted byte-identical</name>
|
||||
<precondition>Task 1 was answered "packages approved" (or "use X.Y.Z" with a passing re-run of its query) in this session.</precondition>
|
||||
<files>admin/package.json, admin/package-lock.json, admin/src/components/form/fields/RichEditorField.vue, admin/src/components/form/richeditor/editor.ts, admin/src/components/form/richeditor/LinkPopover.vue, admin/src/components/form/richeditor/lossy.ts, admin/src/components/form/registry.ts, admin/tests/form/RichEditorField.test.ts, admin/tests/fixtures/companions.form-schema.json, admin/tests/fixtures/lang.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist, docs/backend/forms.md</files>
|
||||
<read_first>.planning/phases/17-blog-companions/17-UI-SPEC.md (Component Contracts 1 RichEditorField, Copywriting richeditor.*, UI Considerations rows for richeditor), admin/src/components/form/fields/MarkdownField.vue (control classes, read-only box, summer-markdown), admin/src/components/form/control.ts, admin/src/components/form/FormField.vue (notices callout), admin/src/components/form/registry.ts (renderers :64-82, valueless :96, groupLabelledTypes :101), admin/src/views/FormView.vue (dirty tracking and submit body), admin/src/app/i18n.ts, admin/tests/form/MarkdownField.test.ts (vitest harness), ../sm-journal-plugin/assets/src/blogeditor/element.ts and extensions.ts (TipTap 3.31.4 setup and the untouched-value pattern), modules/phrasebook/backend/lang/en/lang.yaml, scripts/check-admin-dist.sh</read_first>
|
||||
<action>Per D-10 and the UI-SPEC RichEditorField contract.
|
||||
|
||||
Install: `npm --prefix admin install --save-exact @tiptap/core@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4` (or the version the human named).
|
||||
|
||||
Component: RichEditorField.vue renders the container (controlClass minus px-3.5, overflow-hidden, focus-within ring, data-richeditor), the Reka ToolbarRoot with the buttons, separators, aria-pressed state, mousedown.prevent and labels from richeditor.* keys, the editing area (summer-markdown, min-h-[160px], max-h-[480px], role textbox, aria-multiline, aria-labelledby the field label), and the HTML view textarea (font-mono 13px, spellcheck false) toggled by the Edit HTML button. editor.ts creates the TipTap Editor on a ref element with StarterKit configured as this plan's decisions say and is imported lazily (dynamic import) so TipTap is its own chunk; while it loads the container shows the 200px skel block with aria-busy; when the import fails the container becomes the HTML view with the toggle hidden and richeditor.load_failed above it. LinkPopover.vue (Reka Popover, 320px) validates http, https, mailto, tel, a leading slash, #, ? or ./ and bare relative paths, shows richeditor.link_invalid otherwise; Enter applies, Esc closes and refocuses the Link button; Ctrl or Cmd+K opens it. lossy.ts compares the stored HTML's tag names (case-insensitive) with p, br, strong, b, em, i, u, s, h3, ul, ol, li, blockquote, a, div, span and shows the role note note with richeditor.lossy_note when another tag is present.
|
||||
|
||||
Value contract: the field emits nothing until the user edits (the loaded string stays the model value byte for byte); after the first edit it emits editor.getHTML(), an empty paragraph emits an empty string, and the HTML view emits the textarea text as typed. Read-only shows the existing read-only box with summer-markdown or a muted dash. Notices for the field from meta.notices render through FormField as they do for plugin field types; after a save the field shows the returned value.
|
||||
|
||||
registry.ts: add ['richeditor', RichEditorField]. Phrase keys richeditor.* from the UI-SPEC copy (EN and PL) in modules/phrasebook/backend/lang and the test lang fixture.
|
||||
|
||||
Smoke: RichEditorField.test.ts with a test named "RichEditorField tracer: untouched value is submitted byte-identical" (loads HTML with b, div and an attribute, mounts the field in a form, saves without editing and asserts the submitted string equals the stored string exactly) and "RichEditorField edit emits editor HTML" (types into the editor, asserts the emitted value is the editor HTML).
|
||||
|
||||
Build: `npm --prefix admin run build`, then commit modules/boardwalk/dist separately; `bash scripts/check-admin-dist.sh`. Docs: docs/backend/forms.md richeditor section gains the admin behaviour (untouched rule, HTML view, lossy note).</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/RichEditorField.test.ts && grep -q '"@tiptap/core": "3.31.4"' admin/package.json && bash scripts/check-admin-dist.sh && go test -count=1 ./modules/phrasebook ./modules/boardwalk</automated>
|
||||
<fails_when>Non-zero exit: the SPA does not typecheck; a RichEditorField spec fails or vitest reports zero tests for the file; TipTap is not pinned exact; the committed dist differs from a fresh build; phrasebook or boardwalk tests fail (for example an en key missing in pl).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "\['richeditor', RichEditorField\]" admin/src/components/form/registry.ts` prints one line.
|
||||
- `grep -n "import(" admin/src/components/form/fields/RichEditorField.vue admin/src/components/form/richeditor/editor.ts` shows the lazy TipTap import.
|
||||
- `grep -c "lossy_note" modules/phrasebook/backend/lang/en/lang.yaml modules/phrasebook/backend/lang/pl/lang.yaml` reports 1 per file.
|
||||
- The vitest output for RichEditorField.test.ts lists the test "RichEditorField tracer: untouched value is submitted byte-identical" as passed.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator can edit HTML in the richeditor, sees server removals, and opening and saving an imported value changes nothing.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: RepeaterField with nested rows and the dependent dropdown states, per the UI-SPEC</name>
|
||||
<files>admin/src/components/form/fields/RepeaterField.vue, admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/registry.ts, admin/src/components/form/formState.ts, admin/src/components/form/FormGrid.vue, admin/src/api/types.ts, admin/tests/form/RepeaterField.test.ts, admin/tests/form/DependentDropdown.test.ts, admin/tests/fixtures/companions.form-schema.json, admin/tests/fixtures/lang.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist</files>
|
||||
<read_first>.planning/phases/17-blog-companions/17-UI-SPEC.md (Component Contracts 2 RepeaterField and 3 Dependent dropdown, Copywriting repeater.* and form.dependent_*, UI Considerations rows), admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/fields/MLField.vue (wrapper rendering inner fields), admin/src/components/form/FormGrid.vue (idPrefix, spans), admin/src/components/form/formState.ts (fieldErrors, focusField), admin/src/components/ui/ConfirmDialog.vue and confirm.ts (useConfirm), admin/src/components/form/fields/FileuploadField.vue (native drag and drop :904-910), admin/src/api/client.ts, admin/src/api/schema.d.ts (dependent-options operation), admin/tests/form/FormGrid.test.ts and MLField.test.ts</read_first>
|
||||
<action>Per D-07 and D-08 and the UI-SPEC contracts 2 and 3.
|
||||
|
||||
RepeaterField.vue: the role group container labelled by the field label (repeater joins groupLabelledTypes), data-repeater, rows in Reka Collapsible (open by default), header with drag handle (repeater.move), index, title from titleFrom (dropdown option label, richeditor text without tags, else repeater.untitled), error chip with form.tab_errors and border-danger, move up and down (disabled at the ends), expand and collapse (aria-expanded), remove (repeater.remove_named, disabled at minItems; asks through the shared ConfirmDialog with repeater.remove_confirm only when the row has any non-empty value; focus moves to the next handle, else the previous, else the add button). The body renders a nested FormGrid with idPrefix form-field-rowKey so ids, labels, errors at field.index.nested and notices resolve per row; a collapsed row keeps its values and does not mount its richeditor. Footer: outline add button (prompt or repeater.add, data-action repeater-add, disabled at maxItems, appends a row with nested defaults and focuses its first control), counter repeater.count only with maxItems, collapse all and expand all when more than one row. Empty: the dashed block with repeater.empty and repeater.empty_hint. Reorder by drag (opacity-50 source, a dashed sel drop slot of the header height), ArrowUp and ArrowDown on the handle and the buttons; focus stays on the moved handle and a polite live region announces repeater.moved. A 422 with path keys expands the rows with errors and focusField focuses the first invalid nested control (formState maps field.index.nested paths). Every change marks the form dirty; nothing is sent before Save. Read-only renders rows expanded without controls.
|
||||
|
||||
DropdownField.vue dependsOn: when the schema field has dependsOn, read the sibling value (same form, or same repeater row through the row's form scope) and render the UI-SPEC states: sibling empty (disabled, form.dependent_waiting with the sibling label), loading (disabled, aria-busy, form.loading_options), applies false (disabled, form.dependent_not_used, value sent null), zero options (disabled, form.dependent_empty plus the same text as a muted described-by line), loaded (options with labels prefixed by four U+00A0 per depth), stale stored value (the disabled prompt option is selected, never a wrong choice), failed (role alert line with form.dependent_failed and a ghost Retry button form.dependent_retry). Fetch through the openapi-fetch client; cache per sibling value for the life of the form view; discard late responses for an old sibling value; when the current value is not in new options set it to null, mark dirty and announce form.dependent_cleared in the form's polite live region.
|
||||
|
||||
registry.ts: ['repeater', RepeaterField]; neither type is valueless. Phrase keys repeater.* and form.dependent_* (EN, PL) from the UI-SPEC.
|
||||
|
||||
Smoke specs: RepeaterField.test.ts "RepeaterField tracer: add, edit nested, reorder and remove change form state only" and DependentDropdown.test.ts "DependentDropdown loads options for the sibling value and clears a stale value". Rebuild and commit dist; check-admin-dist.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/RepeaterField.test.ts tests/form/DependentDropdown.test.ts tests/form/FormGrid.test.ts tests/form/MLField.test.ts && bash scripts/check-admin-dist.sh && go test -count=1 ./modules/phrasebook</automated>
|
||||
<fails_when>Non-zero exit: typecheck fails; a repeater or dependent spec fails or vitest finds zero tests in a named file; existing FormGrid or MLField specs regress; dist is stale; a phrase key is missing in one locale.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "\['repeater', RepeaterField\]" admin/src/components/form/registry.ts` prints one line.
|
||||
- `grep -n "dependent-options" admin/src/components/form/fields/DropdownField.vue` prints at least one line.
|
||||
- `grep -c "dependent_not_used\|empty_hint" modules/phrasebook/backend/lang/pl/lang.yaml` reports at least 2.
|
||||
</acceptance_criteria>
|
||||
<done>Editors add, edit, reorder and remove repeater rows, and dependent dropdowns follow their sibling with every UI-SPEC state.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: ReorderView with the list Reorder button, and the singleton form opened from settings</name>
|
||||
<files>admin/src/views/ReorderView.vue, admin/src/views/ListView.vue, admin/src/views/FormView.vue, admin/src/views/SettingsIndexView.vue, admin/src/app/router.ts, admin/tests/views/ReorderView.test.ts, admin/tests/fixtures/lang.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist</files>
|
||||
<read_first>.planning/phases/17-blog-companions/17-UI-SPEC.md (Component Contracts 4 Reorder page and 5 Singleton form, Copywriting reorder.*, UI Considerations rows), admin/src/views/ListView.vue (header button group), admin/src/views/FormView.vue (header, footer buttons, unsaved guard, load states), admin/src/views/SettingsIndexView.vue (Controller link entries), admin/src/app/router.ts and controllerRoutes.ts, admin/src/state/useToasts.ts, admin/src/components/form/fields/FileuploadField.vue (debounced reorder POST, native drag and drop), admin/tests/form/FormView.test.ts and tests/app/router.test.ts</read_first>
|
||||
<action>Per D-09 and D-11.
|
||||
|
||||
ListView: when the list schema declares reorder, render the outline ArrowUpDown button with messages.list.reorder (overridable label from the schema) and data-action reorder before the primary New record button, linking to the controller's reorder route with ?partition from the current filter on the partition column.
|
||||
|
||||
ReorderView.vue (new route next to the controller form route): header copied from FormView (40px back button to the list keeping its query, h1 with the config title, save status with LoaderCircle and form.saving or reorder.saved for 2 s in a polite region, partition select labelled with the partition label that replaces ?partition and reloads); card with the nested ul (role list, aria-label reorder.tree, data-reorder-tree), rows with min-h-row, indent calc(16px + depth * 24px), data-reorder-node, drag handle (reorder.move), the nameFrom name or reorder.untitled, and move up, move down, outdent and indent buttons disabled when impossible. Pointer drag with before, after and inside zones (2px primary line or the dashed sel inside target), no drop into the node's own subtree. Keyboard on the handle: ArrowUp, ArrowDown, ArrowRight (nest under the previous sibling), ArrowLeft (out one level); focus stays on the moved handle and a separate polite region announces reorder.moved, reorder.nested or reorder.unnested. Saving: one POST with the whole scoped node set, debounced 400 ms after the last move, flushed when leaving; on failure revert to the last saved tree and show the reorder.failed danger toast; on 409 show reorder.stale and reload. States: five skeleton rows with aria-busy, the load-failure alert block, the empty block with reorder.empty and reorder.empty_hint, a 403 with the form.forbidden alert, one node with every move disabled.
|
||||
|
||||
Singleton (D-11): when SettingsIndexView opens a Controller link whose form schema is singleton, call GET …/singleton and open FormView in update mode for the returned id; FormView in singleton mode hides Delete, sends Cancel and Save and close back to the settings index (settings.back), and renders the schema's subtitle link when present.
|
||||
|
||||
Phrase keys reorder.*, messages.list.reorder (EN, PL). Smoke spec ReorderView.test.ts "ReorderView tracer: a keyboard move sends one debounced POST with the full node set". Rebuild and commit dist; check-admin-dist.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && go test -count=1 ./modules/phrasebook ./modules/boardwalk</automated>
|
||||
<fails_when>Non-zero exit: typecheck fails; any admin vitest spec fails (including the existing ListView, FormView and router specs); dist or OpenAPI output is stale; a phrase key is missing in one locale.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "ReorderView" admin/src/app/router.ts` prints at least one line.
|
||||
- `grep -n 'data-action="reorder"' admin/src/views/ListView.vue` prints one line.
|
||||
- `grep -n "singleton" admin/src/views/FormView.vue admin/src/views/SettingsIndexView.vue` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>Editors reorder tree records by pointer or keyboard with saved feedback, and a singleton settings entry opens its one record directly.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 5: Release gate: local checks green, tag chosen, one-way surfaces listed, a human approves the framework tag and the push</name>
|
||||
<read_first>.planning/phases/17-blog-companions/17-01-SUMMARY.md, this plan's Task 1-4 SUMMARY notes (commits, Docker smoke tests NOT RUN), .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md (how the last release ran), .planning/notes/2026-10-07-embedded-admin-release-gate.md, 17-REVIEW.md and 17-SECURITY.md when present</read_first>
|
||||
<action>Do everything automatable, print a release sheet, then stop.
|
||||
|
||||
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts cmd go.mod go.sum` prints nothing, `git fetch origin --tags`, and `git log --oneline HEAD..origin/master` prints nothing; if origin has commits this checkout lacks, stop and report (never merge or rebase on your own).
|
||||
|
||||
Tag: propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one (v0.1.23 if nobody tagged after v0.1.22) and confirm with `git ls-remote --tags origin` that it does not exist.
|
||||
|
||||
Run the gate (verify). Release sheet: the proposed tag; the one-way surfaces it publishes (pact HasMenuItemTypes, MenuItemType with its PatternFrom field, MenuItemReference, MenuItemResolve, ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider, DependentOptionSet, AdminSingleton, Option.Depth; signpost Activate, Registry, ReferenceOptions and config key menu_items.patterns; seaglass; cabana field types richeditor and repeater, the dependsOn key, config_reorder.yaml keys, toolbar button reorder; routes dependent-options, reorder GET and POST, singleton); commits to push; the Docker smoke tests recorded NOT RUN in plans 01 and 02; open high findings from 17-REVIEW.md or 17-SECURITY.md if they exist (an open high finding allows only docker-first or hold); that plans 03 and 04 (wave 2) build against this checkout through go.work and get their go.mod requires bumped to this tag in plan 05. Stop for the human.</action>
|
||||
<decision>Push summercms master and the proposed annotated tag now?</decision>
|
||||
<context>A pushed tag is read by plugin go.mod files and the Go module proxy and publishes the registry, sanitizer and cabana contracts above; it cannot be moved, only superseded. The unit-test plan (06) runs after this release, as in 16.2 and 16.3; its tests ship with the next tag.</context>
|
||||
<options>
|
||||
<option id="release-now">
|
||||
<name>Release now with the proposed tag</name>
|
||||
<pros>Plugins and hosts can require the framework features immediately; plan 05 is unblocked</pros>
|
||||
<cons>Docker smoke tests recorded NOT RUN run later; a defect then needs the next tag</cons>
|
||||
</option>
|
||||
<option id="docker-first">
|
||||
<name>Run the Docker-host smoke tests first, then release</name>
|
||||
<pros>Every companions smoke test and the OpenAPI conformance suite run before the tag exists</pros>
|
||||
<cons>Needs a Docker-enabled machine and delays the release</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the release</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>Plan 05 cannot push plugins that require the new framework API</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the suites ran), or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./... && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|TestTokenRouteGates)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4 && go test -count=1 -run '^(TestDocsTree|TestDelegateCommandsHelp)$' ./cmd/summer && go run ./cmd/summer docs:build --check && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails, a Docker-free Go test fails, fewer than four cabana inventory passes, a docs check fails, an admin spec fails, or the dist or OpenAPI drift gate reports stale output.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The gate printed release-gate-ok and summercms.go was clean and not behind origin.
|
||||
- The SUMMARY records the release sheet and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human chose release-now or docker-first with a named tag, or hold (the plan stops).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Release: annotated summercms tag and master pushed</name>
|
||||
<reversibility rating="one-way">A pushed annotated tag publishes pact.HasMenuItemTypes, the signpost registry and the cabana field and route contracts to the Go module proxy and to every plugin go.mod that requires it; it can only be superseded.</reversibility>
|
||||
<precondition>Task 5 was answered "release vX" or "docker passed, release vX"; RELEASE_TAG is that tag in the session and `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing.</precondition>
|
||||
<files>none (git tag and push only)</files>
|
||||
<read_first>this plan's Task 5 release sheet in the SUMMARY, .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-03-SUMMARY.md (Release table)</read_first>
|
||||
<action>Re-run `git log --oneline HEAD..origin/master` (must print nothing), create the annotated tag on summercms.go master HEAD with message "$RELEASE_TAG: menu item type registry (signpost), seaglass sanitizer, cabana repeater, richeditor, dependent dropdown, tree reorder and singleton form", then push master and the tag. Record the tag object, the tagged commit and the pushed head in the SUMMARY. Do not bump any plugin or host here (plan 05 does).</action>
|
||||
<verify>
|
||||
<automated>test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" && test "$(git rev-parse master)" = "$(git rev-parse origin/master)" && test "$(git cat-file -t "$RELEASE_TAG")" = tag && echo release-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-ok" absent: the tag is not on origin, master is not pushed, or the tag is lightweight instead of annotated.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git ls-remote --tags origin` lists the release tag.
|
||||
- The verify prints release-ok and the SUMMARY records RELEASE_TAG for plan 05.
|
||||
</acceptance_criteria>
|
||||
<done>The framework release carrying plans 01 and 02 is on origin.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| stored HTML → admin editor | imported or saved HTML rendered inside the admin |
|
||||
| admin SPA → npm registry | new editor dependency |
|
||||
| executor → git origin | the tag is read by other repositories |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-10 | Tampering (XSS in admin) | RichEditorField read-only view and editor | high | mitigate | the read-only view renders server-sanitized HTML only; the editor parses through the TipTap schema; link popover allowlist; the server gate (plan 01) is authoritative |
|
||||
| T-17-11 | Tampering | link insertion | medium | mitigate | client allowlist http, https, mailto, tel and relative; StarterKit Link protocols restricted; server sanitizer re-checks |
|
||||
| T-17-12 | Tampering (silent data rewrite) | untouched imported HTML | medium | mitigate | untouched rule: no emit until edit; byte-identical smoke spec; lossy note plus HTML view for out-of-schema markup |
|
||||
| T-17-13 | Elevation of Privilege | ReorderView and singleton calls | low | mitigate | the server enforces permissions, scope and the stale set (plan 01); the SPA shows the 403 and 409 states and never retries a write silently |
|
||||
| T-17-14 | Tampering / Repudiation | tag and push | high | mitigate | clean, not-behind tree and an absent tag checked at a blocking-human checkpoint; one-way surfaces listed; heads recorded; no pushed tag moved |
|
||||
| T-17-SC | Tampering | npm installs (@tiptap/core, @tiptap/pm, @tiptap/starter-kit) | high | mitigate | blocking-human package legitimacy checkpoint (Task 1, never auto-approved) before install; exact pins; lockfile committed; no install scripts verified |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: registry-ok and a human approval.
|
||||
- Tasks 2-4: typecheck, named vitest specs, dist and OpenAPI drift checks, phrasebook key parity.
|
||||
- Task 5: release-gate-ok and a human decision.
|
||||
- Task 6: release-ok.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1 (admin half): every plan 01 feature is usable in the admin SPA per the UI-SPEC.
|
||||
- D-06: dist, types and phrase keys ship with the features; the framework is tagged after human approval.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-02-SUMMARY.md` when done, with the commits, the package check reply, the release sheet, the human's reply, RELEASE_TAG and the pushed heads.
|
||||
</output>
|
||||
343
.planning/phases/17-blog-companions/17-03-PLAN.md
Normal file
343
.planning/phases/17-blog-companions/17-03-PLAN.md
Normal file
@@ -0,0 +1,343 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["17-01"]
|
||||
coupling_justified: ["17-02: 17-02 edits only admin/, modules/boardwalk/dist and modules/phrasebook lang in summercms.go; this plan reads summercms.go through go.work and never edits it, so the order between the two is irrelevant", "17-04: disjoint repositories (sm-journal-plugin and sm-sitemap-plugin here, sm-menu-plugin and sm-faq-plugin there); both only read summercms.go"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/menu_item_types.go
|
||||
- ../sm-journal-plugin/classes/menu_item_types.go
|
||||
- ../sm-journal-plugin/menu_item_types_smoke_test.go
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-sitemap-plugin/.gitignore
|
||||
- ../sm-sitemap-plugin/go.mod
|
||||
- ../sm-sitemap-plugin/go.sum
|
||||
- ../sm-sitemap-plugin/go.work
|
||||
- ../sm-sitemap-plugin/README.md
|
||||
- ../sm-sitemap-plugin/plugin.go
|
||||
- ../sm-sitemap-plugin/routes.go
|
||||
- ../sm-sitemap-plugin/admin.go
|
||||
- ../sm-sitemap-plugin/admin_permissions.go
|
||||
- ../sm-sitemap-plugin/assets/sitemap.xsl
|
||||
- ../sm-sitemap-plugin/config/config.yaml
|
||||
- ../sm-sitemap-plugin/lang/en/lang.yaml
|
||||
- ../sm-sitemap-plugin/lang/pl/lang.yaml
|
||||
- ../sm-sitemap-plugin/models/registry.go
|
||||
- ../sm-sitemap-plugin/models/definition.go
|
||||
- ../sm-sitemap-plugin/models/definition_item.go
|
||||
- ../sm-sitemap-plugin/models/settings.go
|
||||
- ../sm-sitemap-plugin/models/definition/fields.yaml
|
||||
- ../sm-sitemap-plugin/models/settings/fields.yaml
|
||||
- ../sm-sitemap-plugin/controllers/admin_registry.go
|
||||
- ../sm-sitemap-plugin/controllers/definitions.go
|
||||
- ../sm-sitemap-plugin/controllers/definitions/config_form.yaml
|
||||
- ../sm-sitemap-plugin/classes/generator.go
|
||||
- ../sm-sitemap-plugin/classes/serialize.go
|
||||
- ../sm-sitemap-plugin/classes/urlto.go
|
||||
- ../sm-sitemap-plugin/classes/tiers.go
|
||||
- ../sm-sitemap-plugin/classes/manifest.go
|
||||
- ../sm-sitemap-plugin/classes/rewrite.go
|
||||
- ../sm-sitemap-plugin/classes/events.go
|
||||
- ../sm-sitemap-plugin/classes/golden_test.go
|
||||
- ../sm-sitemap-plugin/classes/testdata/tracer.json
|
||||
- ../sm-sitemap-plugin/classes/testdata/tracer.golden
|
||||
- ../sm-sitemap-plugin/classes/testdata/urlto.json
|
||||
- ../sm-sitemap-plugin/classes/testdata/urlto.golden
|
||||
- ../sm-sitemap-plugin/classes/testdata/full.json
|
||||
- ../sm-sitemap-plugin/classes/testdata/full.golden
|
||||
- ../sm-sitemap-plugin/tests/php/gen-goldens.php
|
||||
- ../sm-sitemap-plugin/console/helpers.go
|
||||
- ../sm-sitemap-plugin/console/import.go
|
||||
- ../sm-sitemap-plugin/console/import_winter.go
|
||||
- ../sm-sitemap-plugin/scripts/winter-export-definitions.sql
|
||||
- ../sm-sitemap-plugin/scripts/winter-export-settings.sql
|
||||
- ../sm-sitemap-plugin/updates/registry.go
|
||||
- ../sm-sitemap-plugin/updates/202610110001_create_golem15_sitemap_definitions.go
|
||||
- ../sm-sitemap-plugin/updates/202610110002_create_golem15_sitemap_settings.go
|
||||
- ../sm-sitemap-plugin/testmain_test.go
|
||||
- ../sm-sitemap-plugin/sitemap_smoke_test.go
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, SC-3, D-01, D-02, D-03, D-04, D-10, D-11, D-12, D-13, D-14]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-04: sm-journal-plugin implements pact.HasMenuItemTypes with exactly the five PHP keys journal-category, all-journal-categories, journal-post, all-journal-posts and category-journal-posts (labels golem15.journal::lang.menuitem.* in en and pl with the UI-SPEC copy); the change is additive: no existing Journal route, payload, table, migration ID, config key or field definition changes."
|
||||
- "D-10 resolved deviation: Journal's category content field stays type blogeditor (Phase 16.2); this plan does not switch it to richeditor."
|
||||
- "D-02 (every type has a valid DefaultPattern; list children follow their record type's host pattern): journal-post /blog/:slug and journal-category /blog/category/:slug; the list types declare the DefaultPattern of the type they list plus pact.MenuItemType.PatternFrom naming it: all-journal-categories /blog/category/:slug with PatternFrom journal-category, all-journal-posts and category-journal-posts /blog/:slug with PatternFrom journal-post; every list type builds its children from MenuItemResolve.Pattern, which signpost layers as item override, host override of the list key, host override of the PatternFrom record key, DefaultPattern; so with a host config that overrides only journal-category to /:slug the all-journal-categories children resolve to /<slug>, and an item urlPattern on a list item (PHP buildAlternateLinks with item->urlPattern) still reaches its children."
|
||||
- "D-02 and D-03 (Journal semantics from the frozen PHP tree): the PHP placeholders (:slug, :id; posts also :category, :year, :month, :day, the date ones only for a published post with published_at); Resolve fills the layered pattern by ordered literal replacement like PHP buildUrlFromPattern; alternates are one entry per enabled translate locale in sort_order (edge probe, ordering: ties by id), the unprefixed locale (MenuItemResolve.UnprefixedLocale, else the default locale) without a prefix and the others with /code, slugs translated per locale; with no enabled locales the only alternate is x-default; journal-category with nesting returns the subtree with default URLs only; all-journal-categories orders by name, all-journal-posts and category-journal-posts list published posts ordered by title (category-journal-posts includes descendant categories), journal-post applies no published filter."
|
||||
- "SC-3 and D-14: GET /sitemap.xml serves the single definition (theme = the fixed key default, D-11) in the PHP libxml format: XML declaration and stylesheet PI each followed by a newline, everything else on one line ending with the closing urlset tag and a newline, lastmod as 2006-01-02T15:04:05+00:00 in UTC and omitted when there is neither an mtime nor a manifest date, xhtml:link alternates self-closing and emitted only when there are more than one, Content-Type application/xml; the bytes equal the goldens generated from the local PHP with the vendored Winter router."
|
||||
- "SC-3 edge (encoding): text nodes escape ampersand, less-than, greater-than and carriage return; attributes also escape double quote, tab and newline; single quotes and UTF-8 stay literal; Url::to is ported (root plus trimmed path, a bare root keeps its slash, a trailing slash is dropped, every path segment re-encoded like PHP rawurlencode of rawurldecode, valid absolute, mailto, tel and fragment URLs kept) and pinned by the urlto golden table."
|
||||
- "SC-3 edges (boundary, empty, precision): the 50,000th URL is emitted and the 50,001st is not; a definition with no items answers 200 application/xml with an empty body, items that all resolve to nothing produce a self-closing urlset, and no definition answers 404; priority is written as the stored or settings string (1.0, 0.5) and never reformatted through a float."
|
||||
- "D-14 rewrite (edge probe, adjacency): when settings frontend_url is set, the backend URL (app.url when set, else the request scheme from a trusted X-Forwarded-Proto or TLS plus Host, right-trimmed of slashes) is replaced literally inside every loc element, every xhtml:link href and the exact stylesheet href, as PHP str_replace does; when both URLs are equal the output is unchanged; frontend_default_locale reaches Resolve as UnprefixedLocale."
|
||||
- "D-14 hooks: golem15.sitemap.beforeAddItem and addItem are typed festival events classes.BeforeAddItem (UntilHandled; a listener sets Skip to drop the URL) and classes.AddItem (Fire; listeners may append pre-escaped child XML to Extra, written before the url closing tag); the generation order follows Definition::addItemToSet (cap, url fill, lastmod, BeforeAddItem, empty URL drop, count, tier or item priority, elements, AddItem)."
|
||||
- "D-12: item types are url plus registry types only (static pages and cmsPage dropped); the per-locale rule reproduces PHP: when a resolved item has no URL (a list type) each child is emitted once per alternate, otherwise each child once with its default URL; updates_manifest_path is an optional setting read as a regular JSON file (size-capped) mapping a URL path to an ISO date."
|
||||
- "D-13: enable_priority_tiers and priority_homepage, _services, _blog, _categories, _legal port as settings with the PHP defaults; the tier path rules come from config golem15.sitemap.priority_tiers (a list of {setting, exact or pattern}, first match wins, path = URL path right-trimmed of slashes), compiled at boot as RE2 (a bad pattern fails boot); the plugin's default list reproduces the PHP getPriorityForUrl rules so a site configured the same way produces the same output, and a host config may replace the list."
|
||||
- "D-11 and SC-1: the sitemap admin opens the one definition through the definitions singleton controller (settings link, permission golem15.sitemap.access_definitions) whose form is a repeater on data with row fields type (url plus registry types), reference (dependsOn type through signpost.ReferenceOptions), url, urlPattern, priority, changefreq and nesting; a Sitemap settings form holds frontend_url, frontend_default_locale, updates_manifest_path and the tier settings; Definition.Fillable is exactly theme and data (PHP MassAssignmentTest); stored PHP-shaped item JSON decodes tolerantly (string, number and bool coercion, unknown keys such as cmsPage, exists and fillable ignored)."
|
||||
- "SC-1 commands: sitemap:import FILE with --theme and --append ports the PHP ImportCommand JSON format 1:1; sitemap:import-winter --definitions FILE --settings FILE copies a Winter export (scripts/winter-export-definitions.sql and winter-export-settings.sql) into the singleton definition and the settings row, maps type values (url kept, static and CMS page types dropped, extra old=new pairs through repeatable --map), drops an item whose reference is not among the type's current references, prints a report of every dropped or remapped item, refuses a non-empty definition without --force and supports --dry-run; both commands open and publish the database themselves when none is published."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/classes/menu_item_types.go"
|
||||
provides: "the five Journal menu item types with references, resolve and alternates"
|
||||
contains: "all-journal-categories"
|
||||
- path: "../sm-sitemap-plugin/classes/serialize.go"
|
||||
provides: "libxml-compatible sitemap writer"
|
||||
contains: "xhtml:link"
|
||||
- path: "../sm-sitemap-plugin/classes/urlto.go"
|
||||
provides: "Winter Url::to and rawurlencode port"
|
||||
contains: "rawurlencode"
|
||||
- path: "../sm-sitemap-plugin/classes/testdata/full.golden"
|
||||
provides: "PHP-generated golden output"
|
||||
- path: "../sm-sitemap-plugin/tests/php/gen-goldens.php"
|
||||
provides: "golden generator run with the local php and the vendored Winter router"
|
||||
- path: "../sm-sitemap-plugin/routes.go"
|
||||
provides: "/sitemap.xml and /sitemap.xsl"
|
||||
contains: "/sitemap.xml"
|
||||
- path: "../sm-sitemap-plugin/README.md"
|
||||
provides: "plugin README with the Winter YAML adaptations"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/menu_item_types.go"
|
||||
to: "summercms modules/signpost Activate"
|
||||
via: "pact.HasMenuItemTypes on the Journal plugin"
|
||||
pattern: "MenuItemTypes\\(\\)"
|
||||
- from: "../sm-journal-plugin/classes/menu_item_types.go list types"
|
||||
to: "summercms modules/signpost Registry.Pattern"
|
||||
via: "PatternFrom journal-category or journal-post; children built from MenuItemResolve.Pattern so the host override of the record type reaches them"
|
||||
pattern: "PatternFrom"
|
||||
- from: "../sm-sitemap-plugin/classes/generator.go"
|
||||
to: "summercms modules/signpost Registry.Resolve"
|
||||
via: "app.Lookup of *signpost.Registry at request time"
|
||||
pattern: "signpost"
|
||||
- from: "../sm-sitemap-plugin/controllers/definitions.go"
|
||||
to: "signpost.ReferenceOptions"
|
||||
via: "DependentOptions for the repeater row reference field"
|
||||
pattern: "ReferenceOptions"
|
||||
prohibitions:
|
||||
- statement: "The sitemap output is not improved over PHP: element order, escaping, whitespace, date format, headers and the str_replace rewrite stay exactly as the PHP output and goldens show (CLAUDE.md core value)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Plugin code, READMEs and lang files never name a consuming application or site; the PHP site-specific tier paths appear only as the documented default rule list that reproduces the PHP behaviour."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Neither plugin edits summercms.go in this plan (wave 2 runs beside the framework release); a framework gap found here is reported in the SUMMARY as a deviation, not patched in place."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed: the new sm-sitemap-plugin repository and the Journal commits stay local until plan 05's human-approved push."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 3 of 6, wave 2): Journal registers its five item types (D-04) and the sitemap plugin is ported with byte parity against PHP (SC-3), its admin screens and its import commands (SC-1). Both repositories build against the local summercms.go through go.work; nothing is pushed here.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Edge items owned by this plan, all explicit truths above: SC-2 ordering (alternates order), SC-3 boundary (50k), adjacency (rewrite), empty (no items, no definition), encoding (escaping, Url::to), precision (priority strings). Backstop: `{statement: "list-type children ordered by name or title under Postgres ICU pl-PL may differ from the PHP MySQL order for non-ASCII names; parity is byte-equal on ASCII fixtures and set-equal on live data (plan 05)", verification: backstop}`. Prohibitions recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- Fixed definition theme key: `default` (constant `models.DefinitionTheme`); imports write it regardless of the PHP theme name and report the original. `sitemap:import --theme` keeps the PHP flag; a value other than `default` prints that /sitemap.xml serves only the default definition.
|
||||
- Tier config key `golem15.sitemap.priority_tiers` in the plugin's `config/config.yaml` (list of `{setting, exact: [...]}` or `{setting, pattern: '...'}`), defaults equal to PHP `getPriorityForUrl` (RESEARCH Code Examples). A host overrides it in `config/golem15.sitemap.yaml`.
|
||||
- Events live in package `classes` (`classes.BeforeAddItem`, `classes.AddItem`) because the generator fires them and the root package imports classes.
|
||||
- Backend URL for the rewrite: `app.url` when set, else request scheme (trusted `X-Forwarded-Proto` through surf trusted proxies, else TLS) plus `Host` (RESEARCH A7; plan 05 verifies on staging).
|
||||
- Headers: `/sitemap.xml` `Content-Type: application/xml`; `/sitemap.xsl` `text/xsl; charset=UTF-8`; both `Cache-Control: no-cache, private` (live PHP responses).
|
||||
- Winter export SQL lives in the plugin (`scripts/winter-export-definitions.sql` and `scripts/winter-export-settings.sql`, one output each) so any Winter site can use it with `sitemap:import-winter` (RESEARCH Open Question 5).
|
||||
- Journal list types take the record type's DefaultPattern and set PatternFrom (17-01) instead of asking hosts to override five keys: a host overrides journal-post and journal-category, and the three list types follow.
|
||||
- The sitemap tests use a fake type plugin; the real Journal plus sitemap combination is proven by the host test in plan 05, so neither plugin's go.mod requires the other.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `sm-journal-plugin`: `(*Plugin).MenuItemTypes`, `classes.MenuItemTypes(app func() *backpack.App) []pact.MenuItemType`, lang `menuitem.*`, README section; smoke `TestMenuItemTypesSmoke`.
|
||||
- New local repository `../sm-sitemap-plugin` (branch main, module `git.golem15.com/golem15/sm-sitemap-plugin`, plugin ID `golem15.sitemap`): tables `golem15_sitemap_definitions` (id, theme nullable indexed, data text, timestamps) and `golem15_sitemap_settings` (singleton id 1); routes `/sitemap.xml`, `/sitemap.xsl`; settings entries Sitemap (link to the singleton) and Sitemap settings; permission `golem15.sitemap.access_definitions`; commands `sitemap:import`, `sitemap:import-winter`; events `classes.BeforeAddItem`, `classes.AddItem`; goldens and `tests/php/gen-goldens.php`; smoke tests `TestSitemapGolden`, `TestSitemapTracer`, `TestSitemapAdminSmoke`, `TestSitemapImportSmoke`.
|
||||
|
||||
<objective>
|
||||
Make Journal content addressable through the typed registry and serve a PHP-identical sitemap.xml from a plugin editors manage in the admin.
|
||||
|
||||
Purpose: SC-2 (Journal registers, sitemap consumes) and SC-3 (sitemap parity), plus the sitemap half of SC-1.
|
||||
Output: Journal types, the sm-sitemap-plugin repository with goldens, admin and commands.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-CONTEXT.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-PATTERNS.md
|
||||
@.planning/phases/17-blog-companions/17-UI-SPEC.md
|
||||
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
|
||||
|
||||
Repositories: ../sm-journal-plugin (main, builds against ../summercms.go through its go.work replace); ../sm-sitemap-plugin is created here with `git init -b main`, no remote (plan 05 adds it after a human creates the repository). Its go.work copies Journal's: `use .` plus replaces to ../summercms.go and ../sm-translate-plugin; its go.mod requires summercms at the current highest tag (nominal; plan 05 bumps it to the plan 02 tag) with the same `replace git.golem15.com/golem15/summercms => ../summercms.go` line Journal carries. Do not edit summercms.go in this plan. Commits: no co-author tags, stage by explicit path in each repository. Docker: Postgres smoke tests run through a TestMain that calls flag.Parse() and skips under testing.Short(); record NOT RUN when no daemon answers. PHP: the golden generator runs with the local `php` (8.5) and `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/vendor/autoload.php`; the goldens are committed so CI needs no PHP.
|
||||
|
||||
Port sources (frozen): /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/ (models/Definition.php, classes/DefinitionItem.php, classes/definitionitem/fields.yaml, models/settings/fields.yaml, routes.php, console/ImportCommand.php, assets/sitemap.xsl, lang/, tests/security/, README.md) and /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/ (Plugin.php :242 extendWinterPagesPlugin, models/Category.php :119-460, models/Post.php :804-1140).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: Journal registers journal-post, and /sitemap.xml serves a stored definition with a url item and a registry item, byte-identical to the PHP golden</name>
|
||||
<reversibility rating="costly">D-03 and D-14: the serialized item shape and the stored data JSON (PHP DefinitionItem keys) are what copied PHP rows and later admin saves share; changing them means migrating stored definitions.</reversibility>
|
||||
<files>../sm-journal-plugin/menu_item_types.go, ../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/menu_item_types_smoke_test.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/.gitignore, ../sm-sitemap-plugin/go.mod, ../sm-sitemap-plugin/go.sum, ../sm-sitemap-plugin/go.work, ../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/models/registry.go, ../sm-sitemap-plugin/models/definition.go, ../sm-sitemap-plugin/models/definition_item.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/updates/202610110001_create_golem15_sitemap_definitions.go, ../sm-sitemap-plugin/classes/generator.go, ../sm-sitemap-plugin/classes/serialize.go, ../sm-sitemap-plugin/classes/urlto.go, ../sm-sitemap-plugin/classes/golden_test.go, ../sm-sitemap-plugin/classes/testdata/tracer.json, ../sm-sitemap-plugin/classes/testdata/tracer.golden, ../sm-sitemap-plugin/tests/php/gen-goldens.php, ../sm-sitemap-plugin/testmain_test.go, ../sm-sitemap-plugin/sitemap_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/field_types.go (lazy app accessor pattern), ../sm-journal-plugin/classes/blog_editor.go (BlogEditorTypes :80), ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/models/post.go and category.go, ../sm-journal-plugin/admin_harness_test.go (TestMain :53, Postgres boot), ../sm-translate-plugin/models/locale.go and classes/translatable.go (TranslatedExact :73), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (:804-1140), summercms modules/signpost/signpost.go and modules/pact/capabilities.go (plan 01 contracts), /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/models/Definition.php (:94-320 generateSitemap, getXmlObject, getUrlSet, addItemToSet), classes/DefinitionItem.php, routes.php, ../sm-journal-plugin/go.mod and go.work, ../sm-journal-plugin/updates/202610060003_create_golem15_journal_tags.go and updates/registry.go, ../sm-journal-plugin/routes.go, .planning/phases/17-blog-companions/17-RESEARCH.md (Patterns 3, 4, 4b, Code Examples)</read_first>
|
||||
<action>Per D-01 to D-04, D-11 and D-14, one Journal type and one sitemap request end to end.
|
||||
|
||||
Journal (additive, D-04): menu_item_types.go adds `func (p *Plugin) MenuItemTypes() []pact.MenuItemType` returning classes.MenuItemTypes with a lazy app accessor (the field_types.go pattern) and the compile-time assertion for pact.HasMenuItemTypes. classes/menu_item_types.go implements journal-post: References lists posts ordered by title (value = id, label = title); Resolve loads the post by reference id, fills the layered pattern (DefaultPattern /blog/:slug) by ordered literal replacement of :slug, :category, :id, :year, :month, :day as PHP buildUrlFromPattern does, sets MTime from updated_at and builds the ordered alternates (PHP buildAlternateLinks: enabled locales in sort_order, the unprefixed locale without a prefix, the others with /code, translated slugs through translate TranslatedExact; x-default only when there are no enabled locales). Lang menuitem.journal_post (en "Journal post", pl "Wpis (Journal)"). TestMenuItemTypesSmoke (Postgres): Journal boots through party with translate; the signpost registry holds journal-post; Resolve with a host pattern override returns the overridden URL and pl and en alternates in sort_order.
|
||||
|
||||
Sitemap repository: create ../sm-sitemap-plugin (git init -b main, .gitignore like Journal's), go.mod, go.work, plugin.go (ID golem15.sitemap, Requires none, Register keeps the app, HasMigrations, HasModels, HasRoutes, HasLang added in Task 4), migration 202610110001_create_golem15_sitemap_definitions (id SERIAL, theme VARCHAR(255) NULL with an index, data TEXT NULL, created_at, updated_at; Rollback drops it), models.Definition (TableName golem15_sitemap_definitions, MorphName Golem15\Sitemap\Models\Definition, Fillable theme and data, Data as a typed item list in a lagoon.Jsonable or custom Scanner) and models.DefinitionItem with a tolerant UnmarshalJSON (type, url, reference, urlPattern, nesting, priority, changefreq; numbers, strings and bools coerced; cmsPage, exists, fillable and any other key ignored) whose MarshalJSON writes the PHP toArray key set (changefreq, priority, nesting, type, url, reference, cmsPage as null, urlPattern).
|
||||
|
||||
classes: urlto.go ports Url::to (RESEARCH Pattern 4b: valid-URL test ^(#|//|https?://|(mailto|tel|sms):) or a parseable absolute URL; otherwise root plus "/" plus the path trimmed of slashes; an empty path keeps the root slash; each path segment re-encoded with rawurlencode(rawurldecode(seg)), escaping everything except A-Z a-z 0-9 - _ . ~). serialize.go writes the PHP libxml format (RESEARCH Pattern 4 format facts and escape tables) with a strings.Builder, never encoding/xml. generator.go walks the definition items: a url item goes through addItem; a registry item goes through Registry.Resolve with the item's urlPattern as override and its nesting; a resolved item with a URL is added; its Items are added recursively (the per-locale rule arrives in Task 3). routes.go mounts GET /sitemap.xml at the root (not under a throttle group, not behind the backend middleware): it loads the definition with theme default (404 when absent, empty body when it has no items), builds the base URL and writes the XML with Content-Type application/xml.
|
||||
|
||||
Golden generator: tests/php/gen-goldens.php reads a JSON input (base URL plus items with loc, lastmod, changefreq, priority and alternates, or Url::to inputs), runs the same DOMDocument calls as Definition.php getXmlObject, getUrlSet and addItemToSet and the vendored Winter UrlGenerator for Url::to, and writes the golden file. Generate tracer.golden from tracer.json (one url item "/", one item with two alternates). TestSitemapGolden (Docker-free) feeds tracer.json through the Go serializer and compares bytes with tracer.golden. TestSitemapTracer (Postgres): boots summercms with a fake test plugin that registers type acme-post resolving to a fixed URL with two alternates, stores a definition with a url item and an acme-post item, GETs /sitemap.xml and compares the body with tracer.golden and the Content-Type header.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -count=1 -run '^TestSitemapGolden$' -v ./classes/ 2>&1 | grep -- '^--- PASS: TestSitemapGolden' && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
|
||||
<fails_when>Non-zero exit: either repository fails vet; no "--- PASS: TestSitemapGolden" (the Go serializer differs from the PHP golden by a byte); a Journal classes or controllers test regressed.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-sitemap-plugin test -count=1 -run '^TestSitemapTracer$' -v . 2>&1 | grep -- '^--- PASS: TestSitemapTracer' && go -C ../sm-journal-plugin test -count=1 -run '^TestMenuItemTypesSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestMenuItemTypesSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or /sitemap.xml differs from the golden or has the wrong Content-Type, or Journal's journal-post does not resolve through the registry with the override pattern and ordered alternates.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-sitemap-plugin rev-parse --abbrev-ref HEAD` prints main and `git -C ../sm-sitemap-plugin remote` prints nothing.
|
||||
- `grep -n "module git.golem15.com/golem15/sm-sitemap-plugin" ../sm-sitemap-plugin/go.mod` prints one line.
|
||||
- `grep -n "encoding/xml" ../sm-sitemap-plugin/classes/serialize.go` prints nothing.
|
||||
- `grep -n "journal-post" ../sm-journal-plugin/classes/menu_item_types.go` prints at least one line and `git -C ../sm-journal-plugin diff --stat 58851dd -- routes.go controllers/api models/category/fields.yaml updates` prints nothing (58851dd is the pre-phase Journal head).
|
||||
</acceptance_criteria>
|
||||
<done>A Journal post is addressable through the registry, and /sitemap.xml from a stored definition matches PHP byte for byte.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: The other four Journal types: category tree references, nesting, list types, category posts, alternates and lang</name>
|
||||
<files>../sm-journal-plugin/classes/menu_item_types.go, ../sm-journal-plugin/menu_item_types_smoke_test.go, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/menu_item_types.go (Task 1), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php (:119-460 getMenuTypeInfo, resolveMenuItem, buildAlternateLinks, buildUrlFromPattern), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Post.php (:804-1140), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/Plugin.php (:242-260), ../sm-journal-plugin/controllers/api/posts.go (serializeCategoryTree, published filter), ../sm-journal-plugin/models/category.go (nest_left, nest_right, parent_id), ../sm-journal-plugin/README.md (Features, API reference, Testing), .planning/phases/17-blog-companions/17-UI-SPEC.md (Plugin admin copy: journal registry type labels)</read_first>
|
||||
<action>Per D-04, D-02 and D-03, complete the five PHP types (RESEARCH Pattern 3).
|
||||
|
||||
Patterns (D-02, every type valid at signpost boot): each list type declares the DefaultPattern of the type it lists and sets PatternFrom to that type's key, and builds every child URL from in.Pattern (the pattern signpost already layered), never from a hard-coded default and never by looking up the registry itself; PHP likewise built list children from the item's urlPattern.
|
||||
|
||||
journal-category: References is the category tree (parent_id, ordered by nest_left then name; value id, label name, children in Items); DefaultPattern /blog/category/:slug with :slug and :id; Resolve returns the category's URL (the first alternate's URL, or the pattern with the default slug), MTime updated_at, alternates as in Task 1; with Nesting its Items are the subtree, each child with its default URL only (no per-locale entries), built from the same in.Pattern. all-journal-categories: DefaultPattern /blog/category/:slug, PatternFrom journal-category, no References (nil); Resolve returns an empty URL and Items = every category ordered by name, each with URL, MTime and alternates from in.Pattern. journal-post: unchanged from Task 1. all-journal-posts: DefaultPattern /blog/:slug, PatternFrom journal-post, no References; Items = published posts (the API's published rule) ordered by title, each with URL, MTime and alternates from in.Pattern. category-journal-posts: DefaultPattern /blog/:slug, PatternFrom journal-post; References is the category tree; Items = published posts in the category or any descendant (nest_left and nest_right range), ordered by title, URLs from in.Pattern. Post patterns fill :category from the post's first category slug translated per locale, and :year, :month, :day only for a published post with published_at. Ordering ties break by id.
|
||||
|
||||
Lang menuitem.journal_category, all_journal_categories, journal_post, all_journal_posts, category_journal_posts in en and pl with the UI-SPEC labels. README: a "Menu item types" section (the five keys, default patterns, PatternFrom of the three list types, placeholders, alternates rule, how a host overrides patterns through menu_items.patterns: override journal-post and journal-category and the list types follow, or override a list key to give its children a different pattern) and an API reference row for MenuItemTypes; state that category content stays on blogeditor. TestMenuItemTypesSmoke grows: all five keys registered, each with a DefaultPattern starting with a slash and the three list types with PatternFrom set as above; with a test host config menu_items.patterns that overrides only journal-category to /:slug and journal-post to /p/:slug, all-journal-categories children resolve to /<slug>, all-journal-posts and category-journal-posts children to /p/<slug>, and an all-journal-posts resolve with the item override /x/:slug returns children at /x/<slug>; journal-category with nesting returns its child with the default URL only; all-journal-categories returns name-ordered items with no top URL; category-journal-posts includes a post of a child category and excludes an unpublished one; with no enabled locales the single alternate is x-default.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && grep -c "category_journal_posts" ../sm-journal-plugin/lang/en/lang.yaml ../sm-journal-plugin/lang/pl/lang.yaml | grep -c ':1$' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a Journal classes or controllers test regressed, or a menuitem label is missing in one locale.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-journal-plugin test -count=1 -run '^TestMenuItemTypesSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestMenuItemTypesSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a type is missing or lacks a valid DefaultPattern, a list type's children ignore the host override of their record type or the item override, nesting returned per-locale children, list ordering or the published filter differs from PHP, or the x-default rule is wrong.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -o '"\(journal-category\|all-journal-categories\|journal-post\|all-journal-posts\|category-journal-posts\)"' ../sm-journal-plugin/classes/menu_item_types.go | sort -u | wc -l` prints 5.
|
||||
- `grep -n "Menu item types" ../sm-journal-plugin/README.md` prints one line and `grep -n "type: blogeditor" ../sm-journal-plugin/models/category/fields.yaml` still prints one line.
|
||||
- `grep -c "PatternFrom" ../sm-journal-plugin/classes/menu_item_types.go` is at least 3 and `grep -c "DefaultPattern" ../sm-journal-plugin/classes/menu_item_types.go` is at least 5.
|
||||
</acceptance_criteria>
|
||||
<done>Menus and the sitemap can address every Journal content shape PHP offered, with per-locale URLs.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Full sitemap generation: per-locale list rule, lastmod and manifest, priority tiers, typed hooks, 50k cap, frontend rewrite, XSL, settings model, and the two import commands</name>
|
||||
<files>../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/routes.go, ../sm-sitemap-plugin/assets/sitemap.xsl, ../sm-sitemap-plugin/config/config.yaml, ../sm-sitemap-plugin/models/registry.go, ../sm-sitemap-plugin/models/settings.go, ../sm-sitemap-plugin/updates/registry.go, ../sm-sitemap-plugin/updates/202610110002_create_golem15_sitemap_settings.go, ../sm-sitemap-plugin/classes/generator.go, ../sm-sitemap-plugin/classes/tiers.go, ../sm-sitemap-plugin/classes/manifest.go, ../sm-sitemap-plugin/classes/rewrite.go, ../sm-sitemap-plugin/classes/events.go, ../sm-sitemap-plugin/classes/golden_test.go, ../sm-sitemap-plugin/classes/testdata/urlto.json, ../sm-sitemap-plugin/classes/testdata/urlto.golden, ../sm-sitemap-plugin/classes/testdata/full.json, ../sm-sitemap-plugin/classes/testdata/full.golden, ../sm-sitemap-plugin/tests/php/gen-goldens.php, ../sm-sitemap-plugin/console/helpers.go, ../sm-sitemap-plugin/console/import.go, ../sm-sitemap-plugin/console/import_winter.go, ../sm-sitemap-plugin/scripts/winter-export-definitions.sql, ../sm-sitemap-plugin/scripts/winter-export-settings.sql, ../sm-sitemap-plugin/sitemap_smoke_test.go</files>
|
||||
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/models/Definition.php (whole file: MAX_URLS, addItemToSet :204-310, lookupManifestLastmod :318, getUpdatesManifest :342, normalizeManifestPath :372, getPriorityForUrl :393), routes.php, console/ImportCommand.php, assets/sitemap.xsl, models/settings/fields.yaml, models/Settings.php, summercms modules/festival/bus.go (Handleable :18, UntilHandled :100), summercms modules/surf/clientip.go (TrustedProxies), ../sm-journal-plugin/models/settings.go and updates/202610060006_create_golem15_journal_settings.go (singleton settings table), ../sm-journal-plugin/console/import_posts.go (:39-64 command shape), ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go (lookupDB :50-69), ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql (header and JSON_OBJECT style), .planning/phases/17-blog-companions/17-RESEARCH.md (Pattern 4 generation order, Pitfalls 1, 2, 6, 7, 8, 11, Code Examples default tier config)</read_first>
|
||||
<action>Per D-12, D-13 and D-14 and SC-3.
|
||||
|
||||
Settings: migration 202610110002_create_golem15_sitemap_settings (id 1 singleton like Journal's: frontend_url, frontend_default_locale, updates_manifest_path as nullable text; enable_priority_tiers boolean default true; priority_homepage '1.0', priority_services '0.8', priority_blog '0.6', priority_categories '0.4', priority_legal '0.2' as strings) and models.Settings with Fillable and Rules mirroring PHP (no validation beyond PHP).
|
||||
|
||||
Generator (Definition::addItemToSet order): stop at 50,000 URLs; fill a missing URL through Url::to; lastmod = the resolved MTime, else the manifest date for the URL path, else omitted; fire BeforeAddItem through app.Events.UntilHandled (handled means skip); drop an empty URL and pass it through Url::to again; count it; priority = the first matching tier's settings value when enable_priority_tiers is on, else the item priority string; emit loc, lastmod, changefreq, priority and the alternates (only when more than one, each href through Url::to); write AddItem.Extra before the url closing tag after app.Events.Fire. Per-locale rule (D-12, Pitfall 3): when a resolved registry item has an empty URL, each child is emitted once per alternate (loc = that alternate's URL, the full alternate list as hreflang); otherwise each child is emitted once with its default URL. Resolve receives the settings frontend_default_locale as UnprefixedLocale. events.go defines BeforeAddItem {Item, Info, Skip} with IsHandled and AddItem {Item, Info, Extra []string} with doc comments naming the PHP events.
|
||||
|
||||
plugin.go adds pact.HasConfig (embedded config/config.yaml), pact.HasCommands and a Boot that compiles the tier rules. tiers.go: read golem15.sitemap.priority_tiers with LoadSection at Boot into compiled rules (setting name must be one of the five priority settings, exactly one of exact or pattern, RE2 compile errors fail boot); config/config.yaml ships the PHP getPriorityForUrl rules as the default list (homepage exact "" and "/en"; services, blog, categories patterns; legal exact list) with a comment that a host replaces the list. manifest.go: read updates_manifest_path only when it is a regular file of at most 5 MiB holding a JSON object, normalize keys like PHP normalizeManifestPath, cache per request, and on any error omit lastmod (fail safe, never echo file content). rewrite.go: the three literal replacements of routes.php after serialization (inside every loc, every xhtml:link href, and the exact stylesheet href), with the backend URL from app.url or the trusted request base, right-trimmed of slashes. routes.go adds GET /sitemap.xsl serving the embedded assets/sitemap.xsl copied byte-identical from the PHP plugin, and the headers from this plan's decisions.
|
||||
|
||||
Goldens: extend gen-goldens.php and commit urlto.json and urlto.golden (the RESEARCH Pattern 4b table plus non-ASCII, colon, query and fragment cases) and full.json and full.golden (ampersands, quotes, angle brackets, carriage returns, tabs and newlines in loc, alternates and priorities; an empty urlset case; an hreflang list type expanded per alternate; tier priorities over the default rules; a rewritten frontend URL). TestSitemapGolden runs every case.
|
||||
|
||||
Commands (console/helpers.go copies lookupDB): sitemap:import FILE [--theme] [--append] ports ImportCommand (JSON format, non-append keeps non-url items first then the imported URLs) into the singleton definition. sitemap:import-winter --definitions FILE --settings FILE [--map old=new ...] [--force] [--dry-run]: definitions JSONL rows {id, theme, data, created_at, updated_at} and the settings JSON object from system_settings; the type map keeps url, drops the static and CMS page types and cmsPage keys, applies --map pairs; an item whose registry type takes references is dropped when its reference is not among signpost.ReferenceOptions for that type; every dropped or remapped item is printed; a non-empty definition is refused without --force; the settings row is upserted (id 1). scripts/winter-export-definitions.sql (one JSON_OBJECT line per golem15_sitemap_definitions row) and scripts/winter-export-settings.sql (the system_settings value of item golem15_sitemap_settings), both read-only SELECTs with the categories.sql style header (how to run with mysql -N -B --raw, what to check first).
|
||||
|
||||
Smoke (Postgres) in sitemap_smoke_test.go: TestSitemapImportSmoke imports a fixture export (one url item, one page item, one mapped list type) and reports the dropped page item; /sitemap.xml with frontend_url set rewrites loc and the stylesheet href; /sitemap.xsl answers the PHP bytes with text/xsl; a BeforeAddItem listener that sets Skip drops its URL.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -count=1 -short ./... && go -C ../sm-sitemap-plugin test -count=1 -run '^TestSitemapGolden$' -v ./classes/ 2>&1 | grep -- '^--- PASS: TestSitemapGolden' && cmp ../sm-sitemap-plugin/assets/sitemap.xsl /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/assets/sitemap.xsl</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails; a golden case differs by a byte (escaping, Url::to, tiers, per-locale expansion, rewrite or empty urlset); the embedded XSL differs from the PHP file.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-sitemap-plugin test -count=1 -run '^(TestSitemapTracer|TestSitemapImportSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than two passes: the import kept an unmapped type, the rewrite missed loc or the stylesheet href, the XSL headers differ, or a skipped BeforeAddItem URL was still written.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "50000\|50_000" ../sm-sitemap-plugin/classes/generator.go` prints at least one line.
|
||||
- `grep -n "priority_tiers" ../sm-sitemap-plugin/config/config.yaml` prints one line.
|
||||
- `grep -n "type BeforeAddItem struct\|type AddItem struct" ../sm-sitemap-plugin/classes/events.go` prints two lines.
|
||||
- The export SQL is SELECT-only: `cat ../sm-sitemap-plugin/scripts/winter-export-definitions.sql ../sm-sitemap-plugin/scripts/winter-export-settings.sql | grep -v '^[[:space:]]*--' | grep -ciE '\b(insert|update|delete|drop|alter|truncate|replace)\b'` prints 0.
|
||||
- `grep -n "HasConfig\|HasCommands" ../sm-sitemap-plugin/plugin.go` prints at least two lines.
|
||||
</acceptance_criteria>
|
||||
<done>The sitemap reproduces every PHP output rule, is extensible through typed hooks, and a Winter site's definition and settings can be copied in.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Sitemap admin: the singleton definition form with a repeater and the registry-bound reference, the settings form, permissions, lang and the README</name>
|
||||
<files>../sm-sitemap-plugin/plugin.go, ../sm-sitemap-plugin/admin.go, ../sm-sitemap-plugin/admin_permissions.go, ../sm-sitemap-plugin/controllers/admin_registry.go, ../sm-sitemap-plugin/controllers/definitions.go, ../sm-sitemap-plugin/controllers/definitions/config_form.yaml, ../sm-sitemap-plugin/models/definition/fields.yaml, ../sm-sitemap-plugin/models/settings/fields.yaml, ../sm-sitemap-plugin/lang/en/lang.yaml, ../sm-sitemap-plugin/lang/pl/lang.yaml, ../sm-sitemap-plugin/README.md, ../sm-sitemap-plugin/sitemap_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/admin.go (adminFS embed, AdminControllers, Settings :38-50), ../sm-journal-plugin/admin_permissions.go, ../sm-journal-plugin/controllers/tags.go and controllers/tags/config_form.yaml, ../sm-journal-plugin/controllers/admin_registry.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/Plugin.php (permissions :42, settings registration), classes/definitionitem/fields.yaml, models/settings/fields.yaml, lang/en/lang.php and lang/pl/lang.php, README.md, summercms modules/cabana/README.md (repeater, dependsOn, singleton, settings Controller link from plan 01), .planning/phases/17-blog-companions/17-UI-SPEC.md (§5 Singleton form, §6 Sitemap screens, Copywriting sitemap rows), .planning/phases/17-blog-companions/17-RESEARCH.md (Pitfall 9 adaptations)</read_first>
|
||||
<action>Per D-11, D-12, D-13 and SC-1.
|
||||
|
||||
Controller golem15.sitemap.definitions (ModelName Golem15\Sitemap\Models\Definition, ConfigDir controllers/definitions, RequiredPermissions golem15.sitemap.access_definitions) implements pact.AdminSingleton (first or create the row with theme default), pact.DropdownOptionsProvider for data.type (url "URL" plus every registry type in registry order with its label) and pact.DependentOptionsProvider for the row reference (url answers applies false; registry types call signpost.ReferenceOptions). config_form.yaml with PHP modelClass and messages; models/definition/fields.yaml: one full-width repeater data (prompt golem15.sitemap::lang.item.add_item, titleFrom url) with nested type (dropdown, left), reference (dropdown, dependsOn type, right), url (text, left), urlPattern (text with url_pattern_comment, right), priority (dropdown 0.1 to 1.0, left), changefreq (dropdown with the seven PHP frequencies, right), nesting (checkbox, default on, full). The form schema subtitle links to /sitemap.xml (definition.view).
|
||||
|
||||
Settings (admin.go): HasSettings with two entries in the CMS category: sitemap (label plugin.name, icon network, Controller golem15.sitemap.definitions) and sitemap_settings (label settings.label, icon settings, Form models/settings/fields.yaml, NewModel Settings); both require golem15.sitemap.access_definitions. models/settings/fields.yaml: frontend_url (left), frontend_default_locale (right), updates_manifest_path (full), enable_priority_tiers (checkbox default on, full), the five priority_* text fields with their defaults and the generic tier comments from the UI-SPEC; the PHP hint and section blocks become comments and README text, trigger conditions are dropped (fields always show). HasPermissions registers golem15.sitemap.access_definitions (PHP label, developer role). Lang en and pl keep the PHP key names and values with the UI-SPEC corrections (add_item without the access-key markup, new pl values, no site names).
|
||||
|
||||
README (standard plugin README, no consuming application or site names): overview, the two routes and their PHP parity rules, the definition singleton, item types (url plus registry types; static and CMS pages dropped, D-12), settings, priority tiers and how to replace the rule list in host config, the two hooks with listener examples, the two commands and the export SQL, the Winter adaptations list (tabs flattened, span none mapped, hint and section to comments, trigger dropped, the SitemapItems widget replaced by a repeater, empty_confirm not ported), dependencies and testing.
|
||||
|
||||
Smoke (Postgres) TestSitemapAdminSmoke: the singleton route creates the definition once; saving rows through the admin API stores PHP-keyed item JSON; a reference not offered for its row type answers 422; the settings form saves the frontend URL; a user without golem15.sitemap.access_definitions gets 403.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-sitemap-plugin vet ./... && go -C ../sm-sitemap-plugin test -count=1 -short ./... && grep -q "golem15.sitemap.access_definitions" ../sm-sitemap-plugin/admin_permissions.go && grep -q "## " ../sm-sitemap-plugin/README.md</automated>
|
||||
<fails_when>Non-zero exit: vet fails; a Docker-free test fails (for example the admin YAML does not compile at boot); the permission code is missing; the README has no sections.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-sitemap-plugin test -count=1 -run '^(TestSitemapTracer|TestSitemapImportSmoke|TestSitemapAdminSmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes: the singleton was created twice, rows were not stored with PHP keys, an invalid reference was accepted, settings did not save, or permissions were not enforced.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: repeater" ../sm-sitemap-plugin/models/definition/fields.yaml` and `grep -n "dependsOn: type" ../sm-sitemap-plugin/models/definition/fields.yaml` each print one line.
|
||||
- `grep -n "SingletonRecord" ../sm-sitemap-plugin/controllers/definitions.go` prints one line.
|
||||
- `grep -ci "grzyby\|horoskopia\|fonoteka\|www\.golem15\.com" ../sm-sitemap-plugin/README.md ../sm-sitemap-plugin/lang/en/lang.yaml ../sm-sitemap-plugin/lang/pl/lang.yaml` reports 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>Editors manage the sitemap definition and settings in the admin, and the plugin is documented for any host.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| internet → /sitemap.xml, /sitemap.xsl | anonymous GETs |
|
||||
| admin → definition and settings | authenticated editors; stored item text reaches public XML |
|
||||
| Winter export files → import commands | operator-supplied JSONL and JSON |
|
||||
| operator setting → manifest file path | a server path read by the plugin |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-15 | Tampering (XML injection) | serialize.go | high | mitigate | libxml-equivalent text and attribute escaping for every value (PHP XmlEscapingTest spec); golden cases with ampersands, quotes, angle brackets and control characters; no string concatenation of unescaped input |
|
||||
| T-17-16 | Tampering (mass assignment) | models.Definition | medium | mitigate | Fillable exactly theme and data (PHP MassAssignmentTest); settings Fillable lists only the settings columns |
|
||||
| T-17-17 | Information Disclosure | updates_manifest_path | medium | mitigate | regular file only, 5 MiB cap, JSON object only; only dates are derived; errors omit lastmod and never echo content |
|
||||
| T-17-18 | Denial of Service | /sitemap.xml generation | medium | mitigate | 50,000 URL cap; tier patterns compiled once as RE2 (no backtracking); manifest cached per request |
|
||||
| T-17-19 | Spoofing | backend URL for the rewrite | medium | mitigate | X-Forwarded-Proto honored only from surf trusted proxies; app.url preferred when set; literal replace only of the backend URL |
|
||||
| T-17-20 | Elevation of Privilege | definitions controller and settings | medium | mitigate | permission golem15.sitemap.access_definitions on both settings entries and the controller; dependent reference rechecked on save |
|
||||
| T-17-21 | Tampering | import commands | medium | mitigate | refuse a non-empty definition without --force; unmapped and reference-less items dropped with a report; dry run; SELECT-only export SQL |
|
||||
| T-17-22 | Tampering | Journal additive change | low | mitigate | no existing Journal route, field or migration changes (diff check against 58851dd) |
|
||||
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package installs; Go dependencies are the framework's own and pinned in go.sum |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: sitemap golden and Journal Docker-free suites; the sitemap tracer and Journal smoke on a Docker host.
|
||||
- Task 2: Journal suites and lang parity; Journal smoke on a Docker host.
|
||||
- Task 3: full golden table, short suite, XSL byte check; import and tracer smokes on a Docker host.
|
||||
- Task 4: short suite, permission and README checks; admin smoke on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2: Journal registers its five PHP types through the registry and the sitemap consumes them.
|
||||
- SC-3: sitemap.xml and sitemap.xsl match PHP output, including the frontend_url rewrite and hreflang.
|
||||
- SC-1 (sitemap): schema, models, admin screens and import commands ported in their own repository with a README.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-03-SUMMARY.md` when done, with the commits per repository (sm-journal-plugin, sm-sitemap-plugin), the golden generator command used, Docker smoke tests run or NOT RUN, and any framework gap found (reported, not patched).
|
||||
</output>
|
||||
387
.planning/phases/17-blog-companions/17-04-PLAN.md
Normal file
387
.planning/phases/17-blog-companions/17-04-PLAN.md
Normal file
@@ -0,0 +1,387 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["17-01"]
|
||||
coupling_justified: ["17-02: 17-02 edits only admin/, modules/boardwalk/dist and modules/phrasebook lang in summercms.go; this plan reads summercms.go through go.work and never edits it", "17-03: disjoint repositories; both only read summercms.go"]
|
||||
files_modified:
|
||||
- ../sm-faq-plugin/.gitignore
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-faq-plugin/go.sum
|
||||
- ../sm-faq-plugin/go.work
|
||||
- ../sm-faq-plugin/README.md
|
||||
- ../sm-faq-plugin/plugin.go
|
||||
- ../sm-faq-plugin/routes.go
|
||||
- ../sm-faq-plugin/admin.go
|
||||
- ../sm-faq-plugin/admin_navigation.go
|
||||
- ../sm-faq-plugin/admin_permissions.go
|
||||
- ../sm-faq-plugin/lang/en/lang.yaml
|
||||
- ../sm-faq-plugin/lang/pl/lang.yaml
|
||||
- ../sm-faq-plugin/models/registry.go
|
||||
- ../sm-faq-plugin/models/faq.go
|
||||
- ../sm-faq-plugin/models/faq/fields.yaml
|
||||
- ../sm-faq-plugin/models/faq/columns.yaml
|
||||
- ../sm-faq-plugin/controllers/admin_registry.go
|
||||
- ../sm-faq-plugin/controllers/faqs.go
|
||||
- ../sm-faq-plugin/controllers/faqs/config_form.yaml
|
||||
- ../sm-faq-plugin/controllers/faqs/config_list.yaml
|
||||
- ../sm-faq-plugin/controllers/api/faqs.go
|
||||
- ../sm-faq-plugin/console/helpers.go
|
||||
- ../sm-faq-plugin/console/import_winter.go
|
||||
- ../sm-faq-plugin/scripts/winter-export.sql
|
||||
- ../sm-faq-plugin/updates/registry.go
|
||||
- ../sm-faq-plugin/updates/202610110001_create_golem15_faq_faqs.go
|
||||
- ../sm-faq-plugin/testmain_test.go
|
||||
- ../sm-faq-plugin/faq_smoke_test.go
|
||||
- ../sm-menu-plugin/.gitignore
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-menu-plugin/go.sum
|
||||
- ../sm-menu-plugin/go.work
|
||||
- ../sm-menu-plugin/README.md
|
||||
- ../sm-menu-plugin/plugin.go
|
||||
- ../sm-menu-plugin/routes.go
|
||||
- ../sm-menu-plugin/admin.go
|
||||
- ../sm-menu-plugin/admin_navigation.go
|
||||
- ../sm-menu-plugin/admin_permissions.go
|
||||
- ../sm-menu-plugin/lang/en/lang.yaml
|
||||
- ../sm-menu-plugin/lang/pl/lang.yaml
|
||||
- ../sm-menu-plugin/models/registry.go
|
||||
- ../sm-menu-plugin/models/menu.go
|
||||
- ../sm-menu-plugin/models/menu_entry.go
|
||||
- ../sm-menu-plugin/models/menu/fields.yaml
|
||||
- ../sm-menu-plugin/models/menu/columns.yaml
|
||||
- ../sm-menu-plugin/models/menuentry/fields.yaml
|
||||
- ../sm-menu-plugin/models/menuentry/columns.yaml
|
||||
- ../sm-menu-plugin/classes/links.go
|
||||
- ../sm-menu-plugin/classes/tree.go
|
||||
- ../sm-menu-plugin/controllers/admin_registry.go
|
||||
- ../sm-menu-plugin/controllers/menus.go
|
||||
- ../sm-menu-plugin/controllers/menus/config_form.yaml
|
||||
- ../sm-menu-plugin/controllers/menus/config_list.yaml
|
||||
- ../sm-menu-plugin/controllers/menuentries.go
|
||||
- ../sm-menu-plugin/controllers/menuentries/config_form.yaml
|
||||
- ../sm-menu-plugin/controllers/menuentries/config_list.yaml
|
||||
- ../sm-menu-plugin/controllers/menuentries/config_filter.yaml
|
||||
- ../sm-menu-plugin/controllers/menuentries/config_reorder.yaml
|
||||
- ../sm-menu-plugin/controllers/api/menus.go
|
||||
- ../sm-menu-plugin/console/helpers.go
|
||||
- ../sm-menu-plugin/console/import_winter.go
|
||||
- ../sm-menu-plugin/scripts/winter-export-menus.sql
|
||||
- ../sm-menu-plugin/scripts/winter-export-entries.sql
|
||||
- ../sm-menu-plugin/updates/registry.go
|
||||
- ../sm-menu-plugin/updates/202610110001_create_golem15_menu_menus.go
|
||||
- ../sm-menu-plugin/updates/202610110002_create_golem15_menu_menu_entries.go
|
||||
- ../sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go
|
||||
- ../sm-menu-plugin/testmain_test.go
|
||||
- ../sm-menu-plugin/menu_smoke_test.go
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, D-02, D-05, D-07, D-08, D-09, D-10, D-16, D-17]
|
||||
estimate:
|
||||
tokens: 200000
|
||||
raw_tokens: 200000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-1 (FAQ): table golem15_faq_faqs keeps the PHP columns (id, name, slug, locale_id NOT NULL, is_active default false, description text, content text holding the jsonable question and answer list) and timestamps; the admin has a top-level FAQ entry (order 500, side menu FAQs, permission golem15.faq.faqs) whose form has name, slug (preset from name), locale (relation to the translate Locale, nameFrom name), is_active, description (richeditor, D-10) and content (repeater, prompt add_question, titleFrom question, nested question text required and answer richeditor, D-07); the list adds a locale column so rows of the same slug are told apart."
|
||||
- "SC-1 (menu): tables golem15_menu_menus (id, name, code unique, is_active default true, css_class) and golem15_menu_menu_entries (id, menu_id, parent_id, nest_left, nest_right, nest_depth NOT NULL, sort_order, type, title, url, reference, code, icon, css_class, target, is_published default true) keep the PHP columns, plus one additive nullable url_pattern column on entries for the D-02 per-item pattern override; MenuEntry saves set nest_left, nest_right and nest_depth to 0 when unset, set sort_order to the menu's maximum plus one when unset and inherit menu_id from the parent, as PHP beforeSave did."
|
||||
- "D-05: the entry type dropdown offers the built-ins url (Link) and divider (Divider) followed by every registry type with its owning plugin's label; the reference dropdown depends on type through signpost.ReferenceOptions (built-ins answer applies false, D-08); the entry url accepts only http, https, mailto, tel, a leading slash or a fragment (anything else is a 422 with errors.url_scheme, RESEARCH Pitfall 15) and target is _self, _blank or empty."
|
||||
- "D-09: the menu entries list has a menu filter and the toolbar buttons reorder, create and delete; config_reorder.yaml (title Reorder menu entries, nameFrom title, partition menu_id through relation menu, nameFrom name) lets editors drag entries into a tree scoped to one menu; the PHP Children tab and partial are not ported."
|
||||
- "D-05 and D-19 import: menu:import-winter --menus FILE --entries FILE maps link to url and blog_categories to all-journal-categories, drops page entries (and their subtrees) and any type that is neither built-in, registered nor mapped through repeatable --map old=new, drops a registry entry whose reference is not among the type's references, prints one report line per dropped or remapped entry, keeps ids, menu_id and parent_id, bumps both sequences with setval, refuses non-empty tables without --force and supports --dry-run; faq:import-winter --file FILE remaps locale_id by locale code (an unknown code aborts before writing), keeps ids, bumps the sequence and has the same --force and --dry-run rules; the export SQL files are read-only SELECTs."
|
||||
- "SC-1 edge (empty): each import command given an empty input file writes nothing and exits 0 with a zero-count report."
|
||||
- "D-16 (one-way, after the human's checkpoint answer): GET /_menu/api/v1/menus/{code} is anonymous, throttled by bucket menu-public-api (120 per minute per client IP), answers 404 JSON for an unknown or inactive menu, and returns the menu with its published entries as a nested tree ordered by sort_order then id within each parent (edge probe, ordering), each entry with id, title, type, url, target, css_class, icon, code and items; url entries return their stored URL only when it passes the scheme allowlist (else null); dividers return a null URL; single registry types return the resolved URL for the requested locale (else the default URL) through signpost with the entry's url_pattern as the item override; list registry types keep the entry's stored URL and title and carry the resolved children in items; an entry whose type is not registered or whose reference no longer resolves is left out."
|
||||
- "D-17 (one-way, after the human's checkpoint answer): GET /_faq/api/v1/faqs/{slug}?locale=xx is anonymous, throttled by bucket faq-public-api, resolves the locale by code among enabled locales (default: the translate default locale), selects the active row with that slug and locale ordered by id (edge probe, adjacency: duplicate slugs serve the lowest id), answers 404 JSON when none, and returns name, slug, locale, description and content as [{question, answer}] with description and every answer passed through seaglass on output, so rows copied straight into the database are sanitized too (RESEARCH Pitfall 10)."
|
||||
- "D-17 locale relation: FAQ Requires golem15.translate and keeps one row per locale through locale_id (1:1 with PHP)."
|
||||
artifacts:
|
||||
- path: "../sm-faq-plugin/models/faq.go"
|
||||
provides: "FAQ model with jsonable content and the Locale relation"
|
||||
contains: "golem15_faq_faqs"
|
||||
- path: "../sm-faq-plugin/controllers/api/faqs.go"
|
||||
provides: "public FAQ API with output sanitizing"
|
||||
contains: "seaglass"
|
||||
- path: "../sm-menu-plugin/models/menu_entry.go"
|
||||
provides: "MenuEntry with the PHP beforeSave rules"
|
||||
contains: "nest_left"
|
||||
- path: "../sm-menu-plugin/controllers/menuentries/config_reorder.yaml"
|
||||
provides: "tree reorder scoped to one menu"
|
||||
contains: "partition"
|
||||
- path: "../sm-menu-plugin/controllers/api/menus.go"
|
||||
provides: "public menu API with registry resolution"
|
||||
contains: "signpost"
|
||||
- path: "../sm-menu-plugin/console/import_winter.go"
|
||||
provides: "Winter menu import with the D-05 type map"
|
||||
contains: "all-journal-categories"
|
||||
key_links:
|
||||
- from: "../sm-menu-plugin/controllers/menuentries.go"
|
||||
to: "signpost.ReferenceOptions"
|
||||
via: "DependentOptions for the reference field"
|
||||
pattern: "ReferenceOptions"
|
||||
- from: "../sm-menu-plugin/controllers/api/menus.go"
|
||||
to: "signpost Registry.Resolve"
|
||||
via: "registry entries resolved at request time with url_pattern as the item override"
|
||||
pattern: "Resolve\\("
|
||||
- from: "../sm-faq-plugin/controllers/api/faqs.go"
|
||||
to: "summercms modules/seaglass"
|
||||
via: "description and answers sanitized on output"
|
||||
pattern: "seaglass\\.Sanitize"
|
||||
prohibitions:
|
||||
- statement: "The two public API shapes are written only after the human answers the D-16 and D-17 checkpoint; once shipped they are not changed in place (a change is a new version path)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Plugin code, READMEs and lang files never name a consuming application or site; examples use neutral names (acme, main)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Neither plugin edits summercms.go in this plan; a framework gap is reported in the SUMMARY; nothing is pushed (plan 05 pushes after human approval)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 4 of 6, wave 2): the FAQ and menu plugins are ported in their own local repositories with admin screens, Winter import commands and the two published read APIs (D-16, D-17) behind a human checkpoint on their shapes.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Edge items owned by this plan, explicit truths above: SC-1 adjacency (duplicate FAQ slug serves the lowest id), SC-1 empty (empty import files), SC-1 ordering (equal sort_order breaks by id). Prohibitions recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- Menu built-in type key `url` (PHP stored `link`, mapped on import; RESEARCH A5) and `divider`.
|
||||
- Per-item pattern override for menu entries through an additive nullable `url_pattern` column (D-02 requires a per-item override; the PHP table has none; RESEARCH A4 / Open Question 2). PHP rows copy with NULL there. The UI-SPEC's optional `url_pattern` form field is added after `reference`.
|
||||
- A new `target` dropdown (`_self`, `_blank`) in the entry form (RESEARCH Open Question 7; the PHP column exists, the PHP form lacked it).
|
||||
- The menu API filters `is_published` (D-16 "published tree") although the PHP component did not; documented in the README as a deliberate D-16 behaviour.
|
||||
- JSON field names are proposed at the checkpoint (Task 4); errors mirror Journal: `{"error":"Menu not found"}` and `{"error":"FAQ not found"}`; success bodies use the Journal envelope `{"data": …}`.
|
||||
- Import refusals: unknown FAQ locale code aborts the whole import (the operator adds the locale first); a dropped menu entry drops its subtree, each dropped entry reported.
|
||||
- Menu navigation: top-level "Menus" (icon list) with side items Menus and Menu entries (PHP hung them on SiteManager, which is out of scope), documented as an adaptation.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- New local repository `../sm-faq-plugin` (branch main, module `git.golem15.com/golem15/sm-faq-plugin`, plugin ID `golem15.faq`, Requires golem15.translate): table `golem15_faq_faqs`, controller `golem15.faq.faqs`, permission `golem15.faq.faqs`, route `GET /_faq/api/v1/faqs/{slug}`, bucket `faq-public-api`, command `faq:import-winter`, `scripts/winter-export.sql`, README.
|
||||
- New local repository `../sm-menu-plugin` (branch main, module `git.golem15.com/golem15/sm-menu-plugin`, plugin ID `golem15.menu`): tables `golem15_menu_menus`, `golem15_menu_menu_entries` (+ `url_pattern`), controllers `golem15.menu.menus` and `golem15.menu.menuentries` (reorder), permission `golem15.menu.manage_menu`, route `GET /_menu/api/v1/menus/{code}`, bucket `menu-public-api`, command `menu:import-winter`, `scripts/winter-export-menus.sql` and `winter-export-entries.sql`, README.
|
||||
- Smoke tests: `TestFaqAdminSmoke`, `TestMenuAdminSmoke`, `TestFaqImportSmoke`, `TestMenuImportSmoke`, `TestFaqAPISmoke`, `TestMenuAPISmoke`.
|
||||
|
||||
<objective>
|
||||
Give editors FAQ and menu screens in the admin, copy Winter data in, and publish the two read APIs the Nuxt layers will consume.
|
||||
|
||||
Purpose: SC-1 (menu and FAQ ports) and SC-3's API half (D-16, D-17).
|
||||
Output: two plugin repositories with admin, imports, APIs and READMEs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-CONTEXT.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-PATTERNS.md
|
||||
@.planning/phases/17-blog-companions/17-UI-SPEC.md
|
||||
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
|
||||
|
||||
Repositories: ../sm-faq-plugin and ../sm-menu-plugin are created here (git init -b main, no remote; plan 05 adds remotes after a human creates them). go.work and go.mod copy Journal's layout (`use .`, replaces to ../summercms.go and ../sm-translate-plugin; the summercms require is nominal and plan 05 bumps it to the plan 02 tag). Do not edit summercms.go here. Commits: no co-author tags, stage by explicit path in each repository. Docker: Postgres smoke tests run through a TestMain with flag.Parse() and testing.Short(); record NOT RUN when no daemon answers. Menu tests use a fake plugin that registers two types (one with references, one list type) so the menu plugin needs no Journal require.
|
||||
|
||||
Port sources (read-only): /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq/ (Plugin.php :60, models/FAQ.php, models/faq/fields.yaml and columns.yaml, updates/v1.0.1, controllers/faqs/, lang/) and /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/ (Plugin.php :77, models/Menu.php, MenuEntry.php :106 beforeSave, models/*/fields.yaml and columns.yaml, updates/v1.0.1 and v1.0.2, controllers/menus/, controllers/menuentries/ incl. reorder_config.yaml, components/Menu.php, lang/), and the rendered markup in /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/partials/g15_menu/default.htm and g15_faq/.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: an editor creates a FAQ with questions in the admin, and the stored answers are sanitized with a notice</name>
|
||||
<files>../sm-faq-plugin/.gitignore, ../sm-faq-plugin/go.mod, ../sm-faq-plugin/go.sum, ../sm-faq-plugin/go.work, ../sm-faq-plugin/plugin.go, ../sm-faq-plugin/admin.go, ../sm-faq-plugin/admin_navigation.go, ../sm-faq-plugin/admin_permissions.go, ../sm-faq-plugin/lang/en/lang.yaml, ../sm-faq-plugin/lang/pl/lang.yaml, ../sm-faq-plugin/models/registry.go, ../sm-faq-plugin/models/faq.go, ../sm-faq-plugin/models/faq/fields.yaml, ../sm-faq-plugin/models/faq/columns.yaml, ../sm-faq-plugin/controllers/admin_registry.go, ../sm-faq-plugin/controllers/faqs.go, ../sm-faq-plugin/controllers/faqs/config_form.yaml, ../sm-faq-plugin/controllers/faqs/config_list.yaml, ../sm-faq-plugin/updates/registry.go, ../sm-faq-plugin/updates/202610110001_create_golem15_faq_faqs.go, ../sm-faq-plugin/testmain_test.go, ../sm-faq-plugin/faq_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/plugin.go, admin.go, admin_navigation.go, admin_permissions.go, controllers/tags.go, controllers/tags/config_form.yaml and config_list.yaml, models/tag.go, models/registry.go, updates/202610060003_create_golem15_journal_tags.go, updates/registry.go, admin_harness_test.go (TestMain, bootJournal, e.admin, adminAPI), go.mod, go.work; ../sm-translate-plugin/models/locale.go; summercms modules/lagoon/jsonable.go; summercms modules/cabana/README.md (repeater and richeditor from plan 01); /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/faq/ (whole plugin); .planning/phases/17-blog-companions/17-UI-SPEC.md (§6 FAQ, plugin copy rows for faq)</read_first>
|
||||
<action>Per SC-1, D-07, D-10 and D-17 (relation), the FAQ admin path end to end.
|
||||
|
||||
Repository: git init -b main in ../sm-faq-plugin; go.mod (module git.golem15.com/golem15/sm-faq-plugin, requires summercms and sm-translate-plugin like Journal, the summercms replace line), go.work like Journal's. plugin.go: ID golem15.faq, Requires golem15.translate, HasMigrations, HasModels, HasLang, HasAdminControllers, AdminAssets, HasPermissions, HasNavigation, the compile-time assertions.
|
||||
|
||||
Schema: migration 202610110001_create_golem15_faq_faqs with the PHP columns (id SERIAL, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, locale_id INTEGER NOT NULL, is_active BOOLEAN NOT NULL DEFAULT false, description TEXT NULL, content TEXT NULL, created_at and updated_at TIMESTAMP NULL) and a non-unique index on (slug, locale_id); Rollback drops the table. models.FAQ: TableName golem15_faq_faqs, MorphName Golem15\Faq\Models\FAQ, Content as lagoon.Jsonable of []map[string]any, a belongsTo Locale (translate models.Locale through locale_id), Fillable name, slug, locale_id, is_active, description, content; Rules name, slug and locale required.
|
||||
|
||||
Admin: controller golem15.faq.faqs (ModelName Golem15\Faq\Models\FAQ, ConfigDir controllers/faqs, RequiredPermissions golem15.faq.faqs, NewRecord). columns.yaml: id, name, slug, locale (relation, nameFrom name), is_active (switch), created_at (invisible), updated_at. fields.yaml (flat, Pitfall 9): name (left), slug (right, preset name), locale (relation, nameFrom name, comment labels.locale_comment, left), is_active (switch, comment, right), description (richeditor, full), content (repeater, prompt golem15.faq::lang.labels.add_question, titleFrom question; form.fields question text required, answer richeditor). Navigation: top-level FAQ (icon circle-question-mark, order 500) with side item FAQs; permission golem15.faq.faqs (developer and publisher roles). Lang en and pl with the PHP key names and the UI-SPEC copy (pl added).
|
||||
|
||||
TestFaqAdminSmoke (Postgres; harness modelled on Journal's admin_harness_test.go): boot translate plus faq, create an admin with golem15.faq.faqs, POST a FAQ through the admin API with two content rows where one answer holds a script element and an onclick attribute; expect 201, the stored answer without them, a meta.notices entry under content.1.answer, and the list endpoint showing the row with its locale name.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-faq-plugin vet ./... && go -C ../sm-faq-plugin test -count=1 -short ./... && git -C ../sm-faq-plugin rev-parse --abbrev-ref HEAD | grep -qx main</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a Docker-free test or the boot-time YAML compile fails (for example a Winter key cabana does not accept), or the repository is not on branch main.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-faq-plugin test -count=1 -run '^TestFaqAdminSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestFaqAdminSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN in the SUMMARY), or the FAQ was not created, an answer was stored unsanitized, the notice is missing under content.1.answer, or the list lacks the locale name.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: repeater" ../sm-faq-plugin/models/faq/fields.yaml` and `grep -c "type: richeditor" ../sm-faq-plugin/models/faq/fields.yaml` (at least 2) both succeed.
|
||||
- `grep -n '"golem15.translate"' ../sm-faq-plugin/plugin.go` prints one line.
|
||||
- `git -C ../sm-faq-plugin remote` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>An editor can create and edit FAQs with question rows in the admin, and every answer is stored sanitized.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Menu admin: menus and entries with the PHP save rules, the D-05 type set, the registry-bound reference, URL scheme validation and the tree reorder page</name>
|
||||
<files>../sm-menu-plugin/.gitignore, ../sm-menu-plugin/go.mod, ../sm-menu-plugin/go.sum, ../sm-menu-plugin/go.work, ../sm-menu-plugin/plugin.go, ../sm-menu-plugin/admin.go, ../sm-menu-plugin/admin_navigation.go, ../sm-menu-plugin/admin_permissions.go, ../sm-menu-plugin/lang/en/lang.yaml, ../sm-menu-plugin/lang/pl/lang.yaml, ../sm-menu-plugin/models/registry.go, ../sm-menu-plugin/models/menu.go, ../sm-menu-plugin/models/menu_entry.go, ../sm-menu-plugin/models/menu/fields.yaml, ../sm-menu-plugin/models/menu/columns.yaml, ../sm-menu-plugin/models/menuentry/fields.yaml, ../sm-menu-plugin/models/menuentry/columns.yaml, ../sm-menu-plugin/classes/links.go, ../sm-menu-plugin/controllers/admin_registry.go, ../sm-menu-plugin/controllers/menus.go, ../sm-menu-plugin/controllers/menus/config_form.yaml, ../sm-menu-plugin/controllers/menus/config_list.yaml, ../sm-menu-plugin/controllers/menuentries.go, ../sm-menu-plugin/controllers/menuentries/config_form.yaml, ../sm-menu-plugin/controllers/menuentries/config_list.yaml, ../sm-menu-plugin/controllers/menuentries/config_filter.yaml, ../sm-menu-plugin/controllers/menuentries/config_reorder.yaml, ../sm-menu-plugin/updates/registry.go, ../sm-menu-plugin/updates/202610110001_create_golem15_menu_menus.go, ../sm-menu-plugin/updates/202610110002_create_golem15_menu_menu_entries.go, ../sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go, ../sm-menu-plugin/testmain_test.go, ../sm-menu-plugin/menu_smoke_test.go</files>
|
||||
<read_first>../sm-faq-plugin (Task 1 layout to copy), ../sm-journal-plugin/models/category.go (parent_id and nest columns), ../sm-journal-plugin/controllers/posts/config_filter.yaml (filter shape), summercms modules/cabana/README.md (dependsOn, config_reorder.yaml, toolbar reorder, FormBeforeCreate and FormBeforeUpdate, DropdownOptionsProvider), summercms modules/signpost/README.md (ReferenceOptions, Registry.Types), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/ (Plugin.php :77, models/MenuEntry.php :106-130, models/menuentry/fields.yaml, models/menu/fields.yaml, columns.yaml files, updates/v1.0.1 and v1.0.2, controllers/menuentries/reorder_config.yaml and _list_toolbar.php), .planning/phases/17-blog-companions/17-UI-SPEC.md (§6 Menu, plugin copy rows for menu)</read_first>
|
||||
<action>Per SC-1, D-02, D-05, D-08 and D-09.
|
||||
|
||||
Repository like Task 1 (module git.golem15.com/golem15/sm-menu-plugin, plugin ID golem15.menu, no Requires). Migrations: 202610110001_create_golem15_menu_menus (id SERIAL, name VARCHAR(255) NOT NULL, code VARCHAR(255) NOT NULL UNIQUE, is_active BOOLEAN NOT NULL DEFAULT true, css_class VARCHAR(255) NULL, timestamps), 202610110002_create_golem15_menu_menu_entries (the PHP v1.0.1 columns: menu_id INTEGER NOT NULL, parent_id INTEGER NULL, nest_left, nest_right, nest_depth INTEGER NOT NULL, sort_order INTEGER NULL, type VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL, url, reference, code, icon, css_class, target VARCHAR(255) NULL, is_published BOOLEAN NOT NULL DEFAULT true, timestamps) with an index on (menu_id, parent_id, sort_order), and 202610110003_add_url_pattern_to_golem15_menu_menu_entries (url_pattern VARCHAR(255) NULL, D-02); each Rollback undoes only its own step.
|
||||
|
||||
Models: Menu (golem15_menu_menus, MorphName Golem15\Menu\Models\Menu, hasMany entries, Fillable name, code, is_active, css_class, Rules name and code required) and MenuEntry (golem15_menu_menu_entries, MorphName Golem15\Menu\Models\MenuEntry, belongsTo menu and parent, Fillable menu_id, parent_id, type, title, url, url_pattern, reference, code, icon, css_class, target, is_published). A GORM BeforeSave hook ports PHP beforeSave: nest_left, nest_right and nest_depth become 0 when unset, sort_order becomes the menu's maximum plus one when nil, menu_id is copied from the parent when a parent is set, title becomes an empty string when nil. classes/links.go: AllowedURL(url string) bool (http, https, mailto, tel, a leading slash or #; empty allowed) and a target check (_self, _blank or empty), used by the form hooks (a 422 field error errors.url_scheme or errors.target) and later by the API.
|
||||
|
||||
Admin: controller golem15.menu.menus (list and form: name left, code right with preset name, css_class left, is_active switch right). Controller golem15.menu.menuentries: list with the PHP columns, config_filter.yaml menu filter, toolbar buttons reorder, create, delete (showCheckboxes); config_reorder.yaml (title golem15.menu::lang.models.menuentry.reorder_title, nameFrom title, partition {column menu_id, relation menu, nameFrom name, label golem15.menu::lang.models.menuentry.menu}); form fields per the UI-SPEC table (menu dropdown full, parent relation nameFrom title with emptyOption, type dropdown, title, url with url_comment, reference dropdown with dependsOn type and reference_comment, url_pattern text with the url_pattern comment, code, icon, css_class, target dropdown default _self, is_published switch default on with context update). The controller implements DropdownOptionsProvider (menu: menus by name; type: url "Link", divider "Divider", then the signpost registry's types with their labels), DependentOptionsProvider (reference: url and divider answer applies false; registry types call signpost.ReferenceOptions; an unknown type answers applies false) and FormBeforeCreate and FormBeforeUpdate for the URL and target checks. Navigation: top-level Menus (icon list) with side items Menus (list) and Menu entries (list-ordered); permission golem15.menu.manage_menu (developer, publisher). Lang en and pl with the PHP keys and the UI-SPEC copy (Menuses corrected to Menus, Is_published to Published).
|
||||
|
||||
TestMenuAdminSmoke (Postgres, fake type plugin registering acme-page with references and acme-list without): create a menu and three entries through the admin API; the second entry saved without sort_order gets the maximum plus one and nest columns 0; a child inherits menu_id from its parent; reference outside acme-page's options answers 422; url javascript:alert(1) answers 422 with errors.url_scheme; a reorder POST nests the third entry under the first and writes parent_id and sort_order; a reorder POST naming an entry of another menu answers 409.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-menu-plugin vet ./... && go -C ../sm-menu-plugin test -count=1 -short ./... && grep -q "golem15.menu.manage_menu" ../sm-menu-plugin/admin_permissions.go</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a Docker-free test or the boot-time admin YAML compile fails (for example reorder declared without config_reorder.yaml, or dependsOn without a provider), or the PHP permission code is missing.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-menu-plugin test -count=1 -run '^TestMenuAdminSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestMenuAdminSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or the PHP save rules, the reference recheck, the URL scheme refusal or the menu-scoped reorder did not behave as stated.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "url_pattern" ../sm-menu-plugin/updates/202610110003_add_url_pattern_to_golem15_menu_menu_entries.go` prints at least one line.
|
||||
- `grep -n "buttons: \[reorder" ../sm-menu-plugin/controllers/menuentries/config_list.yaml` prints one line.
|
||||
- `grep -n "dependsOn: type" ../sm-menu-plugin/models/menuentry/fields.yaml` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Editors manage menus and their entries, pick registry content by type, and drag entries into a tree per menu.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Winter import commands and read-only export SQL for both plugins (D-05 type map, locale remap, sequences)</name>
|
||||
<reversibility rating="costly">D-05: the stored menu type values (url, divider and registry keys) written by the import are what the menu tables keep; renaming them later needs a data migration.</reversibility>
|
||||
<files>../sm-faq-plugin/plugin.go, ../sm-faq-plugin/console/helpers.go, ../sm-faq-plugin/console/import_winter.go, ../sm-faq-plugin/scripts/winter-export.sql, ../sm-faq-plugin/faq_smoke_test.go, ../sm-menu-plugin/plugin.go, ../sm-menu-plugin/console/helpers.go, ../sm-menu-plugin/console/import_winter.go, ../sm-menu-plugin/scripts/winter-export-menus.sql, ../sm-menu-plugin/scripts/winter-export-entries.sql, ../sm-menu-plugin/menu_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/console/import_posts.go (:39-64 bonfire.Command shape, flags, dry run), ../sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go (lookupDB :50-69) and import_categories.go (JSONL reading and report), ../sm-grzybyfunkcjonalne-app/scripts/php-export/categories.sql and README.md (header, mysql -N -B --raw, translate table name checks), .planning/phases/17-blog-companions/17-RESEARCH.md (Runtime State Inventory, Pitfalls 7, 11, 14, Open Questions 3 and 5), .planning/phases/17-blog-companions/17-CONTEXT.md (D-05, D-19)</read_first>
|
||||
<action>Per D-05 and D-19 (plugin half) and SC-1.
|
||||
|
||||
Both plugins add pact.HasCommands; console/helpers.go copies lookupDB (use the published *gorm.DB, else lagoon.OpenFromApp plus lagoon.Publish) so the commands work in a summer build host (Pitfall 14).
|
||||
|
||||
faq:import-winter --file FILE [--force] [--dry-run]: JSON Lines rows {id, name, slug, locale_code, is_active, description, content, created_at, updated_at} where content is the PHP JSON string or an array; locale_id is the id of the translate Locale with that code (an unknown code aborts before any write, naming the code); one transaction; explicit ids kept; then SELECT setval(pg_get_serial_sequence('golem15_faq_faqs','id'), max(id)); a non-empty table is refused unless --force, which replaces the rows in the same transaction; --dry-run prints the report only; an empty file writes nothing and reports zero rows.
|
||||
|
||||
menu:import-winter --menus FILE --entries FILE [--map old=new ...] [--force] [--dry-run]: menus rows {id, name, code, is_active, css_class, created_at, updated_at}; entries rows with every PHP column; type map link to url and blog_categories to all-journal-categories plus --map pairs; page entries and entries of any type that is neither built-in, registered in signpost nor mapped are dropped with their subtree; a registry entry whose reference is not among signpost.ReferenceOptions for its type is dropped; every dropped or remapped entry prints one report line (id, title, old type, new type or reason); ids, menu_id, parent_id, nest columns and sort_order kept; setval for both tables; the same --force, --dry-run and empty-file rules.
|
||||
|
||||
Export SQL (read-only SELECT JSON_OBJECT, header in the categories.sql style: how to run with mysql -N -B --raw, what to check first, the translate table prefix check): sm-faq-plugin/scripts/winter-export.sql joins the translate locales table to emit locale_code; sm-menu-plugin/scripts/winter-export-menus.sql and winter-export-entries.sql emit one row per line.
|
||||
|
||||
Smoke (Postgres): TestFaqImportSmoke (two locales by code, ids kept, a new admin create after the import gets max id plus one, an unknown code aborts with nothing written, a second run without --force is refused, an empty file reports zero). TestMenuImportSmoke (link becomes url, blog_categories becomes all-journal-categories when the fake plugin registers it, a page entry and its child are dropped and reported, ids and parent_id kept, sequences bumped, --dry-run writes nothing).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-faq-plugin vet ./... && go -C ../sm-menu-plugin vet ./... && go -C ../sm-faq-plugin test -count=1 -short ./... && go -C ../sm-menu-plugin test -count=1 -short ./... && cat ../sm-faq-plugin/scripts/winter-export.sql ../sm-menu-plugin/scripts/winter-export-menus.sql ../sm-menu-plugin/scripts/winter-export-entries.sql | grep -v '^[[:space:]]*--' | grep -ciE '\b(insert|update|delete|drop|alter|truncate)\b' | grep -qx 0</automated>
|
||||
<fails_when>Non-zero exit: vet or a Docker-free test fails in either plugin, or an export SQL file contains a writing statement outside comments.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-faq-plugin test -count=1 -run '^TestFaqImportSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestFaqImportSmoke' && go -C ../sm-menu-plugin test -count=1 -run '^TestMenuImportSmoke$' -v . 2>&1 | grep -- '^--- PASS: TestMenuImportSmoke'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a type was not mapped, a dropped entry was not reported, a locale was not remapped, a sequence was not bumped, or a refusal wrote rows.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"faq:import-winter"' ../sm-faq-plugin/console/import_winter.go` and `grep -n '"menu:import-winter"' ../sm-menu-plugin/console/import_winter.go` each print one line.
|
||||
- `grep -n "setval" ../sm-faq-plugin/console/import_winter.go ../sm-menu-plugin/console/import_winter.go` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>A Winter site's menus and FAQs can be copied into the plugins with mapped types, remapped locales and a report of everything left out.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Confirm the published shapes of GET /_menu/api/v1/menus/{code} and GET /_faq/api/v1/faqs/{slug} before they are written (D-16, D-17)</name>
|
||||
<read_first>.planning/phases/17-blog-companions/17-CONTEXT.md (D-16, D-17), ../sm-journal-plugin/controllers/api/posts.go (envelope and error bodies), .planning/phases/17-blog-companions/17-UI-SPEC.md (§7 and §8 layer needs), this plan's Task 1-3 SUMMARY notes</read_first>
|
||||
<action>Print the proposal below with one example body each, built from the Task 1-3 fixtures, then stop.
|
||||
|
||||
Menu: GET /_menu/api/v1/menus/{code}?locale=xx (code matches letters, digits, dash and underscore). 200: {"data":{"code","name","css_class","items":[Item]}}; Item: {"id" (entry id, null for generated children), "title", "type" (stored type; generated children carry the parent's registry type key), "url" (string or null), "target" ("_self" or "_blank" or null), "css_class", "icon", "code", "items":[Item]}. Unknown or inactive code: 404 {"error":"Menu not found"}. Rules as in this plan's D-16 truth (published only, sort_order then id, scheme-filtered URLs, registry resolution with the requested locale's alternate, list types keep stored URL and title, unresolvable entries left out).
|
||||
|
||||
FAQ: GET /_faq/api/v1/faqs/{slug}?locale=xx (slug lowercase letters, digits and dashes). 200: {"data":{"name","slug","locale","description","content":[{"question","answer"}]}}; description and answers sanitized HTML strings (description may be null); unknown slug, inactive row or unknown or disabled locale: 404 {"error":"FAQ not found"}.
|
||||
|
||||
Both: anonymous, throttled per client IP (menu-public-api and faq-public-api, 120 per minute, 429 body as surf), JSON written with wire.WriteJSON, no CORS in the plugin (the host's cors.paths adds _menu/api/* and _faq/api/* in plan 05).</action>
|
||||
<decision>Publish the two API shapes as proposed?</decision>
|
||||
<context>D-16 and D-17 are one-way: the Nuxt layers (plan 05) and every later site consume these paths and field names; a change after release means a new version path and layer releases. The rest of this plan is reversible.</context>
|
||||
<options>
|
||||
<option id="approve">
|
||||
<name>Approve as proposed</name>
|
||||
<pros>Mirrors the Journal envelope and error bodies; covers every field D-16 and D-17 list</pros>
|
||||
<cons>Field names are fixed from now on</cons>
|
||||
</option>
|
||||
<option id="adjust">
|
||||
<name>Approve with named changes</name>
|
||||
<pros>The human renames or adds fields before anything consumes them</pros>
|
||||
<cons>The executor applies the named changes to Task 5 and the layer plan inherits them</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the APIs</name>
|
||||
<pros>Nothing is published</pros>
|
||||
<cons>Plan 05 cannot build the layers; the site keeps its hardcoded menu and FAQ</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "approve", "adjust: …" listing the changes, or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go -C ../sm-faq-plugin test -count=1 -short ./... && go -C ../sm-menu-plugin test -count=1 -short ./... && echo shapes-ready</automated>
|
||||
<fails_when>Non-zero exit or "shapes-ready" absent: a plugin's Docker-free suite fails, so the proposal would rest on broken fixtures.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the printed proposal, the example bodies and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human approved (possibly with named changes) or held the two published shapes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: The public menu and FAQ APIs with registry resolution, output sanitizing and throttling, and both READMEs</name>
|
||||
<reversibility rating="one-way">D-16 and D-17: once the layers and sites consume /_menu/api/v1 and /_faq/api/v1, their paths and field names cannot change without a new version path.</reversibility>
|
||||
<precondition>Task 4 was answered "approve" or "adjust: …" in this session; the approved shape (with any named changes) is recorded in the SUMMARY.</precondition>
|
||||
<files>../sm-faq-plugin/plugin.go, ../sm-faq-plugin/routes.go, ../sm-faq-plugin/controllers/api/faqs.go, ../sm-faq-plugin/faq_smoke_test.go, ../sm-faq-plugin/README.md, ../sm-menu-plugin/plugin.go, ../sm-menu-plugin/routes.go, ../sm-menu-plugin/classes/tree.go, ../sm-menu-plugin/controllers/api/menus.go, ../sm-menu-plugin/menu_smoke_test.go, ../sm-menu-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/routes.go, plugin.go (Buckets :97-130), controllers/api/posts.go (Categories :158-173, writeAPIError, gdb), summercms modules/seaglass/README.md, summercms modules/signpost/README.md (Registry.Resolve, Pattern), ../sm-translate-plugin/models/locale.go (code, is_enabled, is_default, sort_order), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/plugins/golem15/menu/components/Menu.php and themes/arsha/partials/g15_menu/default.htm (what the site renders), ../sm-journal-plugin/README.md (plugin README structure), this plan's Task 4 SUMMARY (approved shape)</read_first>
|
||||
<action>Per D-16 and D-17 with the shape approved in Task 4 (apply any named changes).
|
||||
|
||||
Menu: routes.go mounts r.Group("/_menu/api/v1", surf.Use("throttle:menu-public-api"), …) with GET /menus/{code} and a Where on code; never the backend middleware. Buckets() adds menu-public-api (Max 120, Decay one minute, key "menu-public|" plus surf.ClientIP with the configured trusted proxies). controllers/api/menus.go loads the active menu by code (404 {"error":"Menu not found"} otherwise), loads its published entries ordered by sort_order then id, and classes/tree.go builds the nested tree: url entries return the stored URL when classes.AllowedURL accepts it (else null); dividers return null; registry entries call the signpost Registry.Resolve with the entry type, reference, url_pattern (item override, D-02) and the type's Nesting; a single type's URL is the alternate whose locale equals ?locale when given, else the resolved URL; a list type keeps the stored URL and title and its resolved Items (their URL picked the same way) follow the stored children; a resolve error or an unregistered type leaves the entry out and logs it; target outside _self and _blank becomes null. Response through wire.WriteJSON with the data envelope.
|
||||
|
||||
FAQ: routes.go mounts /_faq/api/v1 with throttle:faq-public-api and GET /faqs/{slug} (Where slug [a-z0-9][a-z0-9\-]*); Buckets() adds faq-public-api. controllers/api/faqs.go resolves the locale (by ?locale code among enabled locales, else the default locale; an unknown or disabled code is a 404), selects the active row by slug and locale_id ordered by id, and returns name, slug, locale code, description and content as [{question, answer}] with description and each answer passed through seaglass.Sanitize (questions are plain strings); 404 {"error":"FAQ not found"}.
|
||||
|
||||
READMEs (standard plugin structure, neutral examples, no consuming application or site names): overview, tables, admin screens and permissions, the API with example request and response and the error bodies, throttle buckets and the host CORS paths to add, the import command and export SQL workflow, the Winter adaptations (FAQ: tabs flattened, select to nameFrom, locale column added; menu: SiteManager navigation replaced, Children tab replaced by the reorder page, url_pattern and target added, published filter in the API), dependencies (summercms, sm-translate-plugin for FAQ) and testing.
|
||||
|
||||
Smoke (Postgres): TestFaqAPISmoke (default locale when ?locale is absent; ?locale=en picks the en row; an inactive row and an unknown locale are 404; a row inserted with raw SQL containing a script element in an answer and an onerror attribute in the description is served sanitized; duplicate slugs serve the lowest id; anonymous access works). TestMenuAPISmoke (unknown and inactive codes are 404; unpublished entries and their subtrees are absent; order by sort_order then id; a javascript: URL stored with raw SQL comes back null; the acme-list entry keeps its stored title and URL and carries generated children; an entry with a url_pattern override resolves with it; ?locale=en picks the en alternate).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-faq-plugin vet ./... && go -C ../sm-menu-plugin vet ./... && go -C ../sm-faq-plugin test -count=1 -short ./... && go -C ../sm-menu-plugin test -count=1 -short ./... && grep -q "_faq/api/v1" ../sm-faq-plugin/README.md && grep -q "_menu/api/v1" ../sm-menu-plugin/README.md</automated>
|
||||
<fails_when>Non-zero exit: vet or a Docker-free test fails, or a README does not document its API path.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go -C ../sm-faq-plugin test -count=1 -run '^(TestFaqAdminSmoke|TestFaqImportSmoke|TestFaqAPISmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3 && go -C ../sm-menu-plugin test -count=1 -run '^(TestMenuAdminSmoke|TestMenuImportSmoke|TestMenuAPISmoke)$' -v . 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than three passes in a plugin: unsanitized HTML served, an inactive or unpublished record exposed, a disallowed URL returned, the wrong locale picked, or the tree order differs.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n '"menu-public-api"' ../sm-menu-plugin/plugin.go` and `grep -n '"faq-public-api"' ../sm-faq-plugin/plugin.go` each print at least one line.
|
||||
- `grep -n "seaglass.Sanitize" ../sm-faq-plugin/controllers/api/faqs.go` prints at least one line.
|
||||
- `grep -ci "grzyby\|horoskopia\|fonoteka" ../sm-menu-plugin/README.md ../sm-faq-plugin/README.md` reports 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>A frontend can read a published menu tree and an FAQ by slug and locale as safe JSON, and both plugins are documented.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| internet → /_menu/api/v1, /_faq/api/v1 | anonymous GETs |
|
||||
| database rows → public JSON | rows copied from PHP bypass the admin save gate |
|
||||
| admin → menu and FAQ records | authenticated editors |
|
||||
| Winter export files → import commands | operator-supplied JSONL |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-23 | Tampering (stored XSS) | FAQ description and answers | high | mitigate | richeditor gate on save (plan 01) and seaglass.Sanitize on API output for every row, including raw-SQL copies; TestFaqAPISmoke inserts unsafe HTML directly |
|
||||
| T-17-24 | Tampering (XSS through links) | menu entry url | high | mitigate | scheme allowlist on save (422 errors.url_scheme) and again in the API (null URL); target enum; the layer refuses non-allowlisted schemes too (plan 05) |
|
||||
| T-17-25 | Information Disclosure | unpublished entries, inactive menus and FAQs | medium | mitigate | API filters is_active and is_published; 404 for inactive; smoke tests cover each |
|
||||
| T-17-26 | Denial of Service | public APIs | medium | mitigate | surf throttle buckets menu-public-api and faq-public-api, 120 per minute per trusted client IP |
|
||||
| T-17-27 | Tampering (mass assignment) | FAQ, Menu, MenuEntry | medium | mitigate | explicit Fillable lists; nest columns and ids not admin-writable |
|
||||
| T-17-28 | Elevation of Privilege (IDOR) | entry reorder across menus | high | mitigate | config_reorder.yaml partition on menu_id with the framework's locked exact-set rule (plan 01); smoke test posts a foreign entry and expects 409 |
|
||||
| T-17-29 | Tampering | dependent reference | medium | mitigate | provider recheck on save through signpost.ReferenceOptions (plan 01) |
|
||||
| T-17-30 | Tampering | import commands | medium | mitigate | refuse non-empty tables without --force; unknown locales abort; unmapped types and stale references dropped with a report; one transaction; SELECT-only export SQL |
|
||||
| T-17-31 | Repudiation / Tampering | published API shape | medium | mitigate | blocking-human checkpoint before the one-way shape is written; approved shape recorded in the SUMMARY |
|
||||
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package installs; Go dependencies are the framework's and translate's, pinned in go.sum |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: FAQ vet and short suite; FAQ admin smoke on a Docker host.
|
||||
- Task 2: menu vet, short suite and permission; menu admin smoke on a Docker host.
|
||||
- Task 3: both short suites and the SELECT-only check; import smokes on a Docker host.
|
||||
- Task 4: shapes-ready and a human decision.
|
||||
- Task 5: short suites and READMEs; all six smokes on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1: the FAQ and menu schema, models, admin screens and import commands are ported 1:1 in their own repositories with READMEs.
|
||||
- SC-3 (API half): menu and FAQ expose anonymous read APIs a Nuxt layer can consume, with the shape a human approved.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-04-SUMMARY.md` when done, with the commits per repository, the approved API shape and the human's reply, Docker smoke tests run or NOT RUN, and any framework gap found (reported, not patched).
|
||||
</output>
|
||||
398
.planning/phases/17-blog-companions/17-05-PLAN.md
Normal file
398
.planning/phases/17-blog-companions/17-05-PLAN.md
Normal file
@@ -0,0 +1,398 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["17-02", "17-03", "17-04"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-sitemap-plugin/go.mod
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/.gitmodules
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/sitemap
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/go.work
|
||||
- ../sm-grzybyfunkcjonalne-app/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/summer.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins.gen.go
|
||||
- ../sm-grzybyfunkcjonalne-app/main.go
|
||||
- ../sm-grzybyfunkcjonalne-app/config/http.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/config/menu_items.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/boot_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/companions_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/menus.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/entries.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/faqs.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/sitemap-definitions.jsonl
|
||||
- ../sm-grzybyfunkcjonalne-app/testdata/companions/sitemap-settings.json
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/import-companions.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/urls.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
|
||||
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md
|
||||
- ../vue-menu-layer/package.json
|
||||
- ../vue-menu-layer/pnpm-lock.yaml
|
||||
- ../vue-menu-layer/pnpm-workspace.yaml
|
||||
- ../vue-menu-layer/nuxt.config.ts
|
||||
- ../vue-menu-layer/tsconfig.json
|
||||
- ../vue-menu-layer/.gitignore
|
||||
- ../vue-menu-layer/.nvmrc
|
||||
- ../vue-menu-layer/README.md
|
||||
- ../vue-menu-layer/.playground/nuxt.config.ts
|
||||
- ../vue-menu-layer/.playground/app/app.vue
|
||||
- ../vue-menu-layer/app/components/Menu/Nav.vue
|
||||
- ../vue-menu-layer/app/components/Menu/NavItem.vue
|
||||
- ../vue-menu-layer/app/composables/useMenu.ts
|
||||
- ../vue-menu-layer/app/composables/useMenuApi.ts
|
||||
- ../vue-menu-layer/app/utils/client.ts
|
||||
- ../vue-menu-layer/app/utils/types.ts
|
||||
- ../vue-menu-layer/app/utils/menuLinks.ts
|
||||
- ../vue-menu-layer/app/assets/css/menu-tokens.css
|
||||
- ../vue-menu-layer/app/assets/css/menu-base.css
|
||||
- ../vue-menu-layer/tests/menuLinks.test.ts
|
||||
- ../vue-menu-layer/tests/client.test.ts
|
||||
- ../vue-menu-layer/testing/fixture-api.mjs
|
||||
- ../vue-menu-layer/testing/smoke-ssr.mjs
|
||||
- ../vue-menu-layer/testing/fixtures/menus/main.json
|
||||
- ../vue-menu-layer/testing/expect.txt
|
||||
- ../vue-faq-layer/package.json
|
||||
- ../vue-faq-layer/pnpm-lock.yaml
|
||||
- ../vue-faq-layer/pnpm-workspace.yaml
|
||||
- ../vue-faq-layer/nuxt.config.ts
|
||||
- ../vue-faq-layer/tsconfig.json
|
||||
- ../vue-faq-layer/.gitignore
|
||||
- ../vue-faq-layer/.nvmrc
|
||||
- ../vue-faq-layer/README.md
|
||||
- ../vue-faq-layer/.playground/nuxt.config.ts
|
||||
- ../vue-faq-layer/.playground/app/app.vue
|
||||
- ../vue-faq-layer/app/components/Faq/Block.vue
|
||||
- ../vue-faq-layer/app/components/Faq/List.vue
|
||||
- ../vue-faq-layer/app/components/Faq/Item.vue
|
||||
- ../vue-faq-layer/app/composables/useFaq.ts
|
||||
- ../vue-faq-layer/app/composables/useFaqApi.ts
|
||||
- ../vue-faq-layer/app/utils/client.ts
|
||||
- ../vue-faq-layer/app/utils/types.ts
|
||||
- ../vue-faq-layer/app/assets/css/faq-tokens.css
|
||||
- ../vue-faq-layer/app/assets/css/faq-base.css
|
||||
- ../vue-faq-layer/tests/client.test.ts
|
||||
- ../vue-faq-layer/testing/fixture-api.mjs
|
||||
- ../vue-faq-layer/testing/smoke-ssr.mjs
|
||||
- ../vue-faq-layer/testing/fixtures/faqs/acme.json
|
||||
- ../vue-faq-layer/testing/expect.txt
|
||||
- .planning/todos/pending/site-cache-purge-for-menu-and-faq.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-3, SC-4, D-05, D-15, D-18, D-19]
|
||||
user_setup:
|
||||
- service: gitea (git.golem15.com)
|
||||
why: "Five new repositories must exist before the first push"
|
||||
dashboard_config:
|
||||
- task: "Create golem15/sm-sitemap-plugin, golem15/sm-menu-plugin and golem15/sm-faq-plugin (same visibility as sm-journal-plugin) and golem15/vue-menu-layer and golem15/vue-faq-layer (public, installed by pnpm over https without credentials), all empty"
|
||||
location: "https://git.golem15.com -> New repository"
|
||||
- service: grzybyfunkcjonalne.pl PHP database (live server)
|
||||
why: "D-19 copies menu, FAQ and sitemap rows from the Winter database; only the operator has its credentials"
|
||||
dashboard_config:
|
||||
- task: "Run the read-only discovery queries and the five plugin export SQL files with mysql -N -B --raw; place the outputs in /srv/grzybyfunkcjonalne/import/companions/"
|
||||
location: "PHP server shell (STAGING_SSH host)"
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-4: on UAT (https://uat.grzybyfunkcjonalne.pl) the site header renders the main menu from GET /_menu/api/v1/menus/{code} through vue-menu-layer's MenuNav and the homepage FAQ section renders the FAQ what-mushrooms-can-give-you from GET /_faq/api/v1/faqs/{slug} through vue-faq-layer's FaqBlock; app/data/menu.ts and app/data/faq.pl.json are gone from vue-grzybyfunkcjonalne-app."
|
||||
- "D-19 and D-05: the PHP menu, FAQ and sitemap rows are copied from the live Winter database by read-only export SQL into JSONL and imported by the plugin import commands as an ordered scripted step (scripts/import-companions.sh, run by migrate.sh after the Journal and locale steps and runnable alone on an existing database), with menu type values mapped (link to url, blog_categories to all-journal-categories, page dropped, extra pairs from the recorded discovery), FAQ locale_id remapped by code, every dropped or remapped item reported in the SUMMARY."
|
||||
- "D-15: nginx routes exactly /sitemap.xml and /sitemap.xsl, plus the prefixes /_menu/ and /_faq/, to the Go binary (Host and X-Forwarded-Proto forwarded), the @nuxtjs/seo sitemap module is disabled in the site app (sitemap enabled false) while robots, meta, OG and canonical stay on @nuxtjs/seo, robots points at /sitemap.xml, and editors manage the sitemap at /gfadmin; this reverses Phase 16 D-13 for the sitemap only."
|
||||
- "SC-3 (live): /sitemap.xml on UAT answers 200 application/xml in the PHP format with the frontend_url rewrite applied; its loc set equals the live PHP sitemap's loc set modulo the host, and the remaining differences (lastmod omitted for URL items, PHP's older plugin faked it; category and post order under Postgres collation) are listed in the SUMMARY."
|
||||
- "D-18: vue-menu-layer and vue-faq-layer are new public repositories under git.golem15.com/golem15 with the vue-journal-layer distribution (Nuxt 4.4.8 layer, absolute currentDir paths, tokens CSS then base CSS, runtimeConfig server apiBase plus public same-origin apiBase, node --test tests and a .playground SSR smoke, no modules declared, tag v0.1.0), each mirroring one plugin with a typed client and render components; the site app extends both pinned as git+https dependencies at #v0.1.0."
|
||||
- "D-18 and UI-SPEC: MenuNav renders the WAI disclosure navigation (links through NuxtLink for relative paths, plain anchors for absolute, mailto and tel, a span with no href for a URL outside the allowlist, rel noopener noreferrer on _blank, dividers aria-hidden, active link aria-current page with the trail class, mobile toggle 44x44, Esc and route change close) and renders nothing (or the fallback slot) on 404, error or no entries; FaqBlock renders the title, the server-sanitized description and native details items (exclusive and openFirst props) and nothing on 404, error or no items; neither layer ships a component named FaqSection."
|
||||
- "UI-SPEC backstop: {statement: \"a layer node test asserts that javascript: and data: URLs render as a span with no href and that _blank links carry rel noopener noreferrer\", verification: backstop}"
|
||||
- "SC-2 in the host: the grzyby host config menu_items.patterns sets journal-post to /blog/:slug and journal-category to /:slug (P16 D-15 URLs) and no list-type key; the list types all-journal-categories, all-journal-posts and category-journal-posts follow those two through PatternFrom (17-01, 17-03), and the host test proves that all-journal-categories children reach the menu API and the sitemap at /<slug> (not the Journal default /blog/category/<slug>) and all-journal-posts children reach the sitemap at /blog/<slug>."
|
||||
- "Release discipline: the four plugin repositories (Journal, sitemap, menu, FAQ) require the plan 02 summercms tag in go.mod and are pushed before the host commits their gitlinks; every gitlink bump is its own host commit; nothing is pushed or deployed before the human approves at Task 1."
|
||||
artifacts:
|
||||
- path: "../sm-grzybyfunkcjonalne-app/companions_test.go"
|
||||
provides: "host test: imports fixture exports and serves menu, FAQ and sitemap"
|
||||
contains: "_menu/api/v1"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/scripts/import-companions.sh"
|
||||
provides: "ordered D-19 import step"
|
||||
contains: "menu:import-winter"
|
||||
- path: "../vue-menu-layer/app/components/Menu/Nav.vue"
|
||||
provides: "MenuNav component"
|
||||
- path: "../vue-faq-layer/app/components/Faq/Block.vue"
|
||||
provides: "FaqBlock component"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf"
|
||||
provides: "nginx routes for sitemap, menu and FAQ to Go"
|
||||
contains: "location = /sitemap.xml"
|
||||
key_links:
|
||||
- from: "vue-grzybyfunkcjonalne-app SiteHeader.vue"
|
||||
to: "vue-menu-layer MenuNav"
|
||||
via: "extends plus the MenuNav component with the recorded menu code"
|
||||
pattern: "MenuNav"
|
||||
- from: "vue-grzybyfunkcjonalne-app FaqSection.vue"
|
||||
to: "vue-faq-layer FaqBlock"
|
||||
via: "FaqBlock with slug what-mushrooms-can-give-you"
|
||||
pattern: "FaqBlock"
|
||||
- from: "nginx /sitemap.xml"
|
||||
to: "sm-sitemap-plugin route"
|
||||
via: "exact location proxied to 127.0.0.1:8096"
|
||||
pattern: "sitemap.xml"
|
||||
prohibitions:
|
||||
- statement: "The live PHP site and its database are only read: the export SQL files are SELECT-only, the Winter vhost and php-fpm configuration are untouched, and no secret is printed, copied back or committed."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No repository is pushed, no tag is created and nothing is deployed before the human approves at Task 1; no pushed tag is moved; no git add -A, and no file inside a submodule is staged from the host repository."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The layers are module-free (no @nuxtjs/i18n, @nuxtjs/seo or fonts) and ship no site copy and no consuming application name; per-post meta, OG, canonical and robots stay on @nuxtjs/seo."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 5 of 6, wave 3): grzybyfunkcjonalne.pl adopts the three plugins and two new layers, its PHP rows are copied in, and UAT serves the menu, FAQ and sitemap from the plugins (SC-4).
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
SC-4 is unclassified by the edge probe; it is surfaced as an explicit flagged assumption: the deployed rendering is verified by the human UAT check (end-of-phase `<human-check>`), not by an automated predicate alone. SC-3 ordering backstop from plan 03 is checked here on live data (set-equal loc sets). Prohibitions recalled in-prompt, descriptor-less.
|
||||
|
||||
## UI Considerations (lifted from 17-UI-SPEC.md, layers)
|
||||
|
||||
Resolved explicit items for the menu nav and FAQ block (loading through SSR, error and empty render nothing or the fallback slot, populated, zero-one-many, overflow, long text) are truths above. Backstop: the URL-scheme node test (truth above). No unresolved items.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- Menu code: the PHP theme selects menu id 1; its code is read from the live database at Task 1 (MENU_CODE) and used in SiteHeader and the smoke lines.
|
||||
- Extra type mappings (PHP Blog-plugin keys, RESEARCH A9) come from Task 1's DISTINCT type query and are passed as `--map old=new` to the import commands; recorded in DEPLOY.md and the SUMMARY.
|
||||
- `scripts/import-companions.sh` is a separate script that migrate.sh calls with `--companions DIR`, so UAT (already migrated, migrate.sh refuses a database with posts) runs the same step alone.
|
||||
- The menu and FAQ edits show on the site after the swr TTL (at most 300 s); a follow-up todo records a framework-level purge (RESEARCH Open Question 4).
|
||||
- Repository visibility: plugins as sm-journal-plugin; layers public (pnpm installs them over https).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Pushed: sm-journal-plugin, sm-sitemap-plugin, sm-menu-plugin, sm-faq-plugin (main, requiring the plan 02 tag); vue-menu-layer and vue-faq-layer (main and tag v0.1.0); vue-grzybyfunkcjonalne-app (main); sm-grzybyfunkcjonalne-app (master).
|
||||
- Host: three submodules at `plugins/golem15/{sitemap,menu,faq}`, go.work, go.mod, summer.yaml, regenerated `plugins.gen.go` and `main.go`, `config/menu_items.yaml`, CORS paths, `companions_test.go` with fixture exports, `scripts/import-companions.sh`, migrate.sh step, nginx locations, smoke lines, DEPLOY.md "Blog companions" section.
|
||||
- Layers: `MenuNav`, `MenuNavItem`, `useMenu`, `useMenuApi`, `menuLinks`; `FaqBlock`, `FaqList`, `FaqItem`, `useFaq`, `useFaqApi`; tokens and base CSS; tests and SSR smokes.
|
||||
- UAT: plugin tables migrated, PHP rows imported, both programs restarted, smoke green.
|
||||
|
||||
<objective>
|
||||
Move grzybyfunkcjonalne.pl's menu, FAQ and sitemap from app code onto the plugins and layers, with its PHP data, and prove it on UAT.
|
||||
|
||||
Purpose: SC-4, the live half of SC-3, and D-15, D-18, D-19.
|
||||
Output: pushed plugin and layer repositories, the integrated host and site app, an updated UAT.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-CONTEXT.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-UI-SPEC.md
|
||||
@.planning/phases/17-blog-companions/17-02-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-03-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-04-SUMMARY.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
|
||||
Session values: RELEASE_TAG from 17-02-SUMMARY (the summercms tag), the approved API shape from 17-04-SUMMARY, STAGING_SSH and STAGING_URL as in Phase 16 (DEPLOY.md Server facts). Workflow per core-plugins-own-repos.md: commit and push a plugin first, then the pointer bump in the host as a separate commit; never stage files inside a submodule from the host. vue-grzybyfunkcjonalne-app is itself a submodule of the host (branch main); it is committed and pushed in its own checkout, then its gitlink is bumped in the host. Host branch master. No co-author tags. Deploy steps follow ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Release, Smoke, Rollback).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 1: Create the five repositories, export the Winter rows read-only, record the menu code and type values, and approve pushes and the UAT redeploy</name>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Server facts, Release, Rollback), ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, ../sm-sitemap-plugin/scripts/winter-export-definitions.sql and winter-export-settings.sql, ../sm-menu-plugin/scripts/winter-export-menus.sql and winter-export-entries.sql, ../sm-faq-plugin/scripts/winter-export.sql, 17-02-SUMMARY.md (RELEASE_TAG), 17-03-SUMMARY.md and 17-04-SUMMARY.md (local commits per repository, NOT RUN suites), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/themes/arsha/layouts/default.htm (menu = 1)</read_first>
|
||||
<action>Print a checklist and a release sheet, then stop.
|
||||
|
||||
Release sheet: RELEASE_TAG; the commits to push per repository (sm-journal-plugin, sm-sitemap-plugin, sm-menu-plugin, sm-faq-plugin, then the layers, the site app and the host); the one-way surfaces (/_menu/api/v1 and /_faq/api/v1 shapes as approved in 17-04, layer tags v0.1.0, plugin table and migration IDs, the menu built-in key url); the Postgres suites recorded NOT RUN in plans 03 and 04; open high findings in 17-REVIEW.md or 17-SECURITY.md if present.
|
||||
|
||||
Checklist for the human: (1) create the five empty repositories named in user_setup; (2) on the PHP server run the read-only discovery queries SELECT id, code, name, is_active FROM golem15_menu_menus; SELECT DISTINCT type FROM golem15_menu_menu_entries; SELECT id, theme FROM golem15_sitemap_definitions; SELECT id, slug, locale_id, is_active FROM golem15_faq_faqs; SHOW TABLES LIKE '%translate_locales'; and report the results (no secret values); (3) run the five export SQL files with mysql -N -B --raw after checking the translate table prefix, writing menus.jsonl, entries.jsonl, faqs.jsonl, sitemap-definitions.jsonl and sitemap-settings.json to /srv/grzybyfunkcjonalne/import/companions/ readable by the deploy user; (4) approve the pushes and the UAT redeploy. Record in the SUMMARY: MENU_CODE (the code of menu id 1), the DISTINCT menu types and sitemap item types with the mapping decided for each (old=new or drop), the FAQ slugs and locales, and the reply.</action>
|
||||
<instructions>
|
||||
Claude built and tested the plugins locally (plans 03 and 04) and printed the release sheet and checklist.
|
||||
The human creates the repositories, runs the read-only queries and exports on the PHP server (only the operator holds those credentials), and approves.
|
||||
</instructions>
|
||||
<verification>The verify command prints companions-ready.</verification>
|
||||
<resume-signal>Reply "repos created, exports placed, menu code X, maps a=b c=d, push approved" (maps may be "none"), or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>for r in sm-sitemap-plugin sm-menu-plugin sm-faq-plugin vue-menu-layer vue-faq-layer; do git ls-remote "git@git.golem15.com:golem15/$r.git" >/dev/null || exit 1; done; test -n "$STAGING_SSH" && ssh "$STAGING_SSH" 'cd /srv/grzybyfunkcjonalne/import/companions && test -s menus.jsonl && test -s entries.jsonl && test -s faqs.jsonl && test -s sitemap-definitions.jsonl && test -s sitemap-settings.json && for f in *.jsonl; do jq -c . "$f" >/dev/null || exit 1; done && jq -e type sitemap-settings.json >/dev/null' && echo companions-ready</automated>
|
||||
<fails_when>Non-zero exit or "companions-ready" absent: a repository does not exist or is not reachable over SSH, STAGING_SSH is unset, an export file is missing or empty, or a line does not parse as JSON.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The five repositories answer git ls-remote and the five export files exist and parse on the server.
|
||||
- The SUMMARY records MENU_CODE, every DISTINCT type with its mapping decision, the FAQ rows and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>Repositories exist, the Winter rows are exported read-only, the mappings are decided and the human approved pushes and the redeploy.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: the host boots with the three plugins from their pushed repositories, imports fixture exports, and serves the menu with Journal category children, the FAQ and the sitemap</name>
|
||||
<reversibility rating="costly">D-19 and SC-1: the submodule layout, plugin IDs and table names become what UAT and the production cutover run; moving them later means data and deploy changes.</reversibility>
|
||||
<precondition>Task 1 printed companions-ready and the human approved pushes; RELEASE_TAG from 17-02-SUMMARY exists on origin (`git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` in summercms.go prints a line).</precondition>
|
||||
<files>../sm-journal-plugin/go.mod, ../sm-sitemap-plugin/go.mod, ../sm-menu-plugin/go.mod, ../sm-faq-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-grzybyfunkcjonalne-app/plugins/golem15/sitemap, ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu, ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/go.work, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/go.sum, ../sm-grzybyfunkcjonalne-app/summer.yaml, ../sm-grzybyfunkcjonalne-app/plugins.gen.go, ../sm-grzybyfunkcjonalne-app/main.go, ../sm-grzybyfunkcjonalne-app/config/http.yaml, ../sm-grzybyfunkcjonalne-app/config/menu_items.yaml, ../sm-grzybyfunkcjonalne-app/boot_test.go, ../sm-grzybyfunkcjonalne-app/companions_test.go, ../sm-grzybyfunkcjonalne-app/testdata/companions/menus.jsonl, ../sm-grzybyfunkcjonalne-app/testdata/companions/entries.jsonl, ../sm-grzybyfunkcjonalne-app/testdata/companions/faqs.jsonl, ../sm-grzybyfunkcjonalne-app/testdata/companions/sitemap-definitions.jsonl, ../sm-grzybyfunkcjonalne-app/testdata/companions/sitemap-settings.json</files>
|
||||
<read_first>.planning/notes/core-plugins-own-repos.md, ../sm-grzybyfunkcjonalne-app/.gitmodules, go.work, go.mod, summer.yaml, config/http.yaml, boot_test.go (TestBootUserTranslateJournal :19, TestCORS :265), plugins/grzyby/console/testmain_test.go (Postgres harness with GRZYBY_TEST_ADMIN_DSN or testcontainers), plugins/grzyby/console/import_categories_test.go, ../sm-journal-plugin/go.mod, 17-02-SUMMARY.md, 17-03-SUMMARY.md, 17-04-SUMMARY.md, this plan's Task 1 SUMMARY (MENU_CODE, mappings)</read_first>
|
||||
<action>Per D-19, D-05, SC-2 and SC-4, end to end in the host.
|
||||
|
||||
Plugins: in each of ../sm-journal-plugin, ../sm-sitemap-plugin, ../sm-menu-plugin and ../sm-faq-plugin run `go mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG` (the go.work replace stays), `go mod tidy` only where the require graph changed, vet, and commit go.mod and go.sum alone as "chore(deps): require summercms $RELEASE_TAG". Add origin git@git.golem15.com:golem15/<repo>.git to the three new repositories and push main for all four (Journal's push also publishes plan 03's commits).
|
||||
|
||||
Host: `git submodule add -b main git@git.golem15.com:golem15/sm-<name>-plugin.git plugins/golem15/<name>` for sitemap, menu and faq, and move plugins/golem15/journal to the pushed Journal main; commit each submodule change alone (chore: add <name> plugin submodule; chore: bump journal plugin (menu item types)). Then in one host commit: go.work use lines for the three; go.mod require plus replace lines (sm-<name>-plugin => ./plugins/golem15/<name>) like Journal's; summer.yaml entries golem15.sitemap, golem15.menu, golem15.faq with their modules; regenerate plugins.gen.go and main.go with the summer CLI build (never hand-edit them); config/menu_items.yaml with patterns journal-post /blog/:slug and journal-category /:slug (P16 D-15) and no list-type keys, because the three Journal list types inherit these through PatternFrom (a comment in the file says so); config/http.yaml cors.paths gains _menu/api/* and _faq/api/* after _journal/api/*. boot_test.go: the activated plugin list now has seven entries including golem15.sitemap, golem15.menu and golem15.faq, the migrating set includes them, and TestCORS covers the two new paths.
|
||||
|
||||
Fixtures: testdata/companions/*.jsonl and sitemap-settings.json shaped exactly like the export SQL output, small and synthetic (no live content needed): menu MENU_CODE with a link entry /blog, a blog_categories entry with title Grzyby Funkcjonalne, a divider and a page entry; one FAQ what-mushrooms-can-give-you in pl with two questions, one answer containing a script element; a sitemap definition with url /, an all-journal-categories item and an all-journal-posts item (both with an empty urlPattern, so the host patterns apply) and settings with frontend_url. companions_test.go with TestCompanionsHost (Postgres, the console harness approach): boot the host plugins, migrate, seed the pl locale and two Journal categories and one published post, run faq:import-winter, menu:import-winter (with the Task 1 maps) and sitemap:import-winter through their bonfire commands, then through the assembled router GET /_menu/api/v1/menus/MENU_CODE (the blog_categories entry became all-journal-categories and carries the two categories with URLs exactly /<slug>; the page entry is gone), GET /_faq/api/v1/faqs/what-mushrooms-can-give-you (200, the script element absent), and GET /sitemap.xml (200 application/xml; with the host part removed, the loc paths include exactly /<category slug> for both categories and /blog/<post slug> for the post, and no loc path starts with /blog/category/).</action>
|
||||
<verify>
|
||||
<automated>cd ../sm-grzybyfunkcjonalne-app && go vet ./... && go test -count=1 -short ./... && for r in sm-journal-plugin sm-sitemap-plugin sm-menu-plugin sm-faq-plugin; do test "$(git -C ../$r rev-parse main)" = "$(git -C ../$r rev-parse origin/main)" || exit 1; grep -q "git.golem15.com/golem15/summercms $RELEASE_TAG" ../$r/go.mod || exit 1; done; for s in sitemap menu faq; do L=$(git submodule status plugins/golem15/$s) || exit 1; case "$L" in " "*) ;; *) exit 1 ;; esac; done; echo host-wired</automated>
|
||||
<fails_when>Non-zero exit or "host-wired" absent: the host fails vet or a Docker-free test (boot list, CORS paths, regenerated main), a plugin's main is not pushed or does not require RELEASE_TAG, or a submodule is missing or not checked out at its recorded commit.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && cd ../sm-grzybyfunkcjonalne-app && go test -count=1 -run '^TestCompanionsHost$' -v . 2>&1 | grep -- '^--- PASS: TestCompanionsHost'</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or an import failed, the menu lacks the resolved Journal categories at /<slug>, the page entry survived, the FAQ is unsanitized, or the sitemap lacks the Journal URLs or carries a list child at the Journal default pattern instead of the host pattern.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- For each of sm-sitemap-plugin, sm-menu-plugin and sm-faq-plugin, `grep -n "golem15/<name>.git" ../sm-grzybyfunkcjonalne-app/.gitmodules` prints a line, and for each of golem15.sitemap, golem15.menu and golem15.faq `grep -n "id: <id>$" ../sm-grzybyfunkcjonalne-app/summer.yaml` prints a line.
|
||||
- `grep -n "_menu/api/\*" ../sm-grzybyfunkcjonalne-app/config/http.yaml` and `grep -n "_faq/api/\*" ../sm-grzybyfunkcjonalne-app/config/http.yaml` each print a line.
|
||||
- The host overrides exactly the two record types: `grep -v '^[[:space:]]*#' ../sm-grzybyfunkcjonalne-app/config/menu_items.yaml | grep -oE "^[[:space:]]+[a-z0-9-]+:[[:space:]]+[\"']?/" | sed -E 's/^[[:space:]]+//; s/:.*//' | sort | paste -sd, -` prints `journal-category,journal-post`.
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app log --format=%s -5` shows the submodule additions and the Journal bump as separate commits.
|
||||
</acceptance_criteria>
|
||||
<done>The host runs the three plugins from their own repositories and serves a Journal-resolved menu, a sanitized FAQ and the sitemap from imported Winter rows.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: vue-menu-layer and vue-faq-layer: typed clients, MenuNav and FaqBlock per the UI-SPEC, tests, SSR smokes, tag v0.1.0 and push</name>
|
||||
<reversibility rating="costly">D-18: the two repositories, their tag-pinned git distribution and their component names become what sites extend and override; restructuring means coordinated layer and site releases.</reversibility>
|
||||
<precondition>Task 1's human reply approved pushes; the two empty layer repositories answer git ls-remote.</precondition>
|
||||
<files>../vue-menu-layer/package.json, ../vue-menu-layer/pnpm-lock.yaml, ../vue-menu-layer/pnpm-workspace.yaml, ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/tsconfig.json, ../vue-menu-layer/.gitignore, ../vue-menu-layer/.nvmrc, ../vue-menu-layer/README.md, ../vue-menu-layer/.playground/nuxt.config.ts, ../vue-menu-layer/.playground/app/app.vue, ../vue-menu-layer/app/components/Menu/Nav.vue, ../vue-menu-layer/app/components/Menu/NavItem.vue, ../vue-menu-layer/app/composables/useMenu.ts, ../vue-menu-layer/app/composables/useMenuApi.ts, ../vue-menu-layer/app/utils/client.ts, ../vue-menu-layer/app/utils/types.ts, ../vue-menu-layer/app/utils/menuLinks.ts, ../vue-menu-layer/app/assets/css/menu-tokens.css, ../vue-menu-layer/app/assets/css/menu-base.css, ../vue-menu-layer/tests/menuLinks.test.ts, ../vue-menu-layer/tests/client.test.ts, ../vue-menu-layer/testing/fixture-api.mjs, ../vue-menu-layer/testing/smoke-ssr.mjs, ../vue-menu-layer/testing/fixtures/menus/main.json, ../vue-menu-layer/testing/expect.txt, ../vue-faq-layer/package.json, ../vue-faq-layer/pnpm-lock.yaml, ../vue-faq-layer/pnpm-workspace.yaml, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/tsconfig.json, ../vue-faq-layer/.gitignore, ../vue-faq-layer/.nvmrc, ../vue-faq-layer/README.md, ../vue-faq-layer/.playground/nuxt.config.ts, ../vue-faq-layer/.playground/app/app.vue, ../vue-faq-layer/app/components/Faq/Block.vue, ../vue-faq-layer/app/components/Faq/List.vue, ../vue-faq-layer/app/components/Faq/Item.vue, ../vue-faq-layer/app/composables/useFaq.ts, ../vue-faq-layer/app/composables/useFaqApi.ts, ../vue-faq-layer/app/utils/client.ts, ../vue-faq-layer/app/utils/types.ts, ../vue-faq-layer/app/assets/css/faq-tokens.css, ../vue-faq-layer/app/assets/css/faq-base.css, ../vue-faq-layer/tests/client.test.ts, ../vue-faq-layer/testing/fixture-api.mjs, ../vue-faq-layer/testing/smoke-ssr.mjs, ../vue-faq-layer/testing/fixtures/faqs/acme.json, ../vue-faq-layer/testing/expect.txt</files>
|
||||
<read_first>../vue-journal-layer/nuxt.config.ts, package.json, pnpm-workspace.yaml, tsconfig.json, .gitignore, .nvmrc, README.md, .playground/, app/utils/client.ts and types.ts, app/composables/useJournalApi.ts, app/assets/css/journal-tokens.css and journal-base.css, app/components/Journal/PostList.vue, tests/client.test.ts, testing/fixture-api.mjs, testing/nuxt-server.mjs, testing/smoke-ssr.mjs, scripts/check-git-install.sh; ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue and FaqSection.vue (markup the layers replace); .planning/phases/17-blog-companions/17-UI-SPEC.md (§7 vue-menu-layer, §8 vue-faq-layer, Color and Typography for layers, layer accessibility props); 17-04-SUMMARY.md (approved API shape)</read_first>
|
||||
<action>Per D-18 and the UI-SPEC §7 and §8, with the API shape approved in 17-04.
|
||||
|
||||
Both layers copy vue-journal-layer's skeleton: package.json (private, type module, main nuxt.config.ts, engines node at least 22.18, peer nuxt 4.4.8, dev nuxt 4.4.8, vue 3.5.35 and typescript 5.9.3 only, scripts dev, build:playground, test "node --test --test-reporter=tap tests/*.test.ts" and smoke:ssr), nuxt.config.ts with an absolute currentDir, no modules key, css tokens then base, runtimeConfig menu.apiBase http://127.0.0.1:8096/_menu/api/v1 with public.menu.apiBase /_menu/api/v1 (faq: the /_faq/api/v1 pair), a .playground app, README (install as a pinned git+https dependency, tokens to override on :root:root, components and props, overriding a component by a same-name file, the API it expects, no site names).
|
||||
|
||||
vue-menu-layer: utils/types.ts (the approved menu shape), utils/client.ts (fetch with an injected Fetcher so node tests can stub; 404 resolves to null), utils/menuLinks.ts (allowlist http, https, mailto, tel, a leading slash or #; linkKind for NuxtLink, anchor or text; rel noopener noreferrer for _blank; path normalisation and active and trail matching against useRoute().path and the request host), composables useMenuApi and useMenu (useAsyncData key menu:code:locale, fetched during SSR, server base on the server and public base in the browser). Components Menu/Nav.vue (MenuNav: props code, locale, label, openLabel, closeLabel, submenuLabel; nav with the menu css_class; mobile toggle; #item and #fallback slots; renders nothing on 404, error or no items) and Menu/NavItem.vue (MenuNavItem, recursive, every row of the UI-SPEC entry table including dividers aria-hidden, disclosure toggles with aria-expanded and aria-controls, Esc, focus-out, outside pointer and route change closing, hover opening only for hover-capable pointers, sibling closing). menu-tokens.css and menu-base.css with the UI-SPEC tokens and the 767px breakpoint. Tests: menuLinks.test.ts (javascript: and data: become text with no href; _blank gets rel noopener noreferrer; active and trail matching; trailing slash and query ignored) and client.test.ts (URL built from base, code and locale; 404 gives null).
|
||||
|
||||
vue-faq-layer: utils/types.ts and client.ts (same pattern), useFaqApi and useFaq (key faq:slug:locale), Faq/Block.vue (FaqBlock: slug, locale, headingLevel 2 to 4, showHeading, showDescription, exclusive; section labelled by the heading; description v-html of the server-sanitized HTML; renders nothing on 404, error or no items unless #fallback), Faq/List.vue (FaqList: items, exclusive, openFirst), Faq/Item.vue (FaqItem: native details with a shared name when exclusive, summary with the CSS chevron that respects prefers-reduced-motion, answer v-html). faq-tokens.css and faq-base.css with the .faq-html rules. No component named FaqSection. client.test.ts as for the menu layer.
|
||||
|
||||
SSR smoke per layer: testing/fixture-api.mjs (node:http serving testing/fixtures), smoke-ssr.mjs reusing the journal layer's approach, expect.txt lines that the playground page renders the menu titles and an aria-current link (menu) and the FAQ title and a details element without the fixture's unsafe markup (faq fixture already sanitized, as the server sends it).
|
||||
|
||||
Install with pnpm (lockfile committed), run tests and the smoke, commit, git init -b main earlier, add origin, create annotated tag v0.1.0, push main and the tag for both layers.</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test && pnpm -C ../vue-menu-layer run smoke:ssr && pnpm -C ../vue-faq-layer run smoke:ssr && for r in vue-menu-layer vue-faq-layer; do test -n "$(git -C ../$r ls-remote --tags origin refs/tags/v0.1.0)" || exit 1; done; echo layers-ok</automated>
|
||||
<fails_when>Non-zero exit or "layers-ok" absent: a node test fails (an unsafe URL got an href, a _blank link lacks rel, a client builds the wrong URL), an SSR smoke expectation fails, or a v0.1.0 tag is not on origin.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Neither layer declares Nuxt modules: `cat ../vue-menu-layer/nuxt.config.ts ../vue-faq-layer/nuxt.config.ts | grep -v '^[[:space:]]*//' | grep -c 'modules:'` prints 0.
|
||||
- `test ! -e ../vue-faq-layer/app/components/FaqSection.vue` succeeds.
|
||||
- `grep -ci "grzyby" ../vue-menu-layer/README.md ../vue-faq-layer/README.md` reports 0 for each file.
|
||||
</acceptance_criteria>
|
||||
<done>Any Nuxt site can render a plugin menu and FAQ by extending two tagged layers.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The site app adopts both layers: MenuNav in the header, FaqBlock on the homepage, app menu and FAQ data removed, the Nuxt sitemap disabled</name>
|
||||
<precondition>Task 3 printed layers-ok (both v0.1.0 tags on origin); MENU_CODE is recorded in this plan's SUMMARY.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/SiteHeader.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/components/FaqSection.vue, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/menu.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/data/faq.pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/app/assets/css/tokens.css, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/i18n/locales/pl.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/content.test.ts, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/fixtures/expect.txt, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts, package.json, app/components/SiteHeader.vue, FaqSection.vue, app/data/menu.ts, app/data/faq.pl.json, app/assets/css/tokens.css, i18n/locales/pl.json, tests/content.test.ts, tests/ssr.test.ts, tests/fixtures/expect.txt, README.md; ../vue-menu-layer/README.md and ../vue-faq-layer/README.md (Task 3); .planning/phases/17-blog-companions/17-UI-SPEC.md (§7 and §8 Overrides); .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md (D-13, D-15)</read_first>
|
||||
<action>Per D-15, D-18 and D-19 (site half) and SC-4.
|
||||
|
||||
package.json: add vue-menu-layer and vue-faq-layer as git+https://git.golem15.com/golem15/<layer>.git#v0.1.0 dependencies; pnpm install (lockfile committed). nuxt.config.ts: extends vue-journal-layer, vue-menu-layer and vue-faq-layer (still no modules key); runtimeConfig menu.apiBase and faq.apiBase for SSR on 127.0.0.1:8096 (NUXT_MENU_API_BASE, NUXT_FAQ_API_BASE); sitemap enabled false with a comment that the Go plugin serves /sitemap.xml and nginx routes it (D-15, reverses P16 D-13 for the sitemap only); robots sitemap /sitemap.xml and disallow /_menu and /_faq next to /_journal; linkChecker excludes /_menu/**, /_faq/** and /sitemap.xsl. SiteHeader.vue keeps its bar, logo and skip link and replaces the MAIN_MENU loop with MenuNav code MENU_CODE and the i18n labels (menu.label, menu.open, menu.close, submenu label); tokens.css maps the --menu-* and --faq-* tokens to the site's --site-* palette. FaqSection.vue keeps its section wrapper and heading and renders FaqBlock slug what-mushrooms-can-give-you with show-heading false (its own title and intro stay in pl.json only if still used). Delete app/data/menu.ts and app/data/faq.pl.json, remove i18n keys that nothing uses any more, update tests/content.test.ts (no longer reads those files; asserts that SiteHeader uses MenuNav and FaqSection uses FaqBlock), and keep tests/fixtures/expect.txt green (the SSR smoke has no menu or FAQ API, so those regions render nothing and every existing page still answers its status). README: the menu and FAQ now come from the plugins through the layers, edited in /gfadmin; edits show after the swr TTL.
|
||||
|
||||
Build (pnpm build), pnpm test and pnpm smoke:ssr; commit in the site app checkout and push its main; then bump the vue-grzybyfunkcjonalne-app gitlink in the host as its own commit and push host master.</action>
|
||||
<verify>
|
||||
<automated>cd ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app && pnpm test && pnpm build && pnpm smoke:ssr && test ! -e app/data/menu.ts && test ! -e app/data/faq.pl.json && grep -q "MenuNav" app/components/SiteHeader.vue && grep -q "FaqBlock" app/components/FaqSection.vue && test "$(git rev-parse main)" = "$(git rev-parse origin/main)" && echo site-ok</automated>
|
||||
<fails_when>Non-zero exit or "site-ok" absent: a site test or the build fails, an SSR smoke line fails, the old data files still exist, the header or FAQ section does not use the layer components, or main is not pushed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "enabled: false" ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts` prints a line inside the sitemap block.
|
||||
- `grep -n "vue-menu-layer.git#v0.1.0" ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` and `grep -n "vue-faq-layer.git#v0.1.0" ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` each print a line.
|
||||
- The host commit bumping vue-grzybyfunkcjonalne-app touches only that gitlink.
|
||||
</acceptance_criteria>
|
||||
<done>The site renders its menu and FAQ through the layers and no longer carries them as app data or generates its own sitemap.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Ordered import step, nginx routes, deploy guide and smoke lines; redeploy UAT, import the Winter rows, smoke, and compare the sitemap with PHP</name>
|
||||
<precondition>Tasks 2 and 4 are committed and pushed; STAGING_SSH and STAGING_URL are set and `ssh "$STAGING_SSH" true` succeeds.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/scripts/import-companions.sh, ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh, ../sm-grzybyfunkcjonalne-app/scripts/php-export/README.md, ../sm-grzybyfunkcjonalne-app/scripts/urls.txt, ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/DEPLOY.md, ../sm-grzybyfunkcjonalne-app/README.md, .planning/todos/pending/site-cache-purge-for-menu-and-faq.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/scripts/migrate.sh (step list and flag parsing), scripts/php-export/README.md, scripts/urls.txt (grammar), scripts/smoke.sh (--url mode, routing of /gfadmin and /_journal), scripts/check-deploy.sh, deploy/nginx/grzybyfunkcjonalne-staging.conf (location ^~ /_journal/ :132 and the header comments about add_header), DEPLOY.md (Release, Smoke, Rollback), this plan's Task 1 SUMMARY (MENU_CODE, maps), .planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-04-PLAN.md (how the last redeploy ran)</read_first>
|
||||
<action>Per D-15 and D-19 and SC-3 and SC-4.
|
||||
|
||||
scripts/import-companions.sh --bin PATH --workdir DIR --dir EXPORT_DIR [--map old=new ...] [--force] [--dry-run]: runs, in order, faq:import-winter (after the translate locales exist), menu:import-winter (after Journal so registry references resolve), sitemap:import-winter, passing the maps to the menu and sitemap commands, and prints each command's report; set -euo pipefail, absolute paths, refuses a missing file. migrate.sh gains --companions DIR (and repeated --companions-map) and calls import-companions.sh after its Journal, category and author steps; its help text lists the new step. scripts/php-export/README.md points at the plugin export SQL files and the discovery queries.
|
||||
|
||||
nginx (staging vhost, before location /): location = /sitemap.xml and location = /sitemap.xsl, and location ^~ /_menu/ and ^~ /_faq/, each proxy_pass to the Go upstream with Host $host and X-Forwarded-Proto $scheme exactly like the /_journal/ block and GET and HEAD only; keep the server-level headers rule the file documents. check-deploy.sh asserts the four locations. urls.txt: remote lines for 200 /sitemap.xml type=application/xml contains=<urlset, 200 /sitemap.xsl type=text/xsl, 200 /_menu/api/v1/menus/MENU_CODE type=application/json, 200 /_faq/api/v1/faqs/what-mushrooms-can-give-you type=application/json, and the homepage containing the first menu title and the first FAQ question; smoke.sh routes /_menu, /_faq and /sitemap.* to Go in the local rehearsal. DEPLOY.md: a "Blog companions" section (export, import-companions.sh on an existing database, the recorded maps, nginx locations, the sitemap now from Go with frontend_url and the tier settings, edits visible after the swr TTL, rollback: remove the four locations and re-enable the Nuxt sitemap). README points to it. Commit the host changes and push master.
|
||||
|
||||
Todo (planning doc commit in summercms.go): .planning/todos/pending/site-cache-purge-for-menu-and-faq.md records that menu and FAQ admin saves do not purge the Nuxt swr cache yet (RESEARCH Open Question 4) and proposes a framework-level purge service.
|
||||
|
||||
Redeploy UAT per DEPLOY.md Release: scripts/build.sh; upload the binary, config and scripts, and the Nuxt output; on the server run bin/grzybyfunkcjonalne migrate (creates the plugin tables), then scripts/import-companions.sh --workdir /srv/grzybyfunkcjonalne/go --dir /srv/grzybyfunkcjonalne/import/companions with the recorded maps (dry run first, then real), keeping both reports for the SUMMARY; install the updated vhost (sudo nginx -t before reload); restart both supervisor programs. Run scripts/smoke.sh --url "$STAGING_URL". Fetch https://grzybyfunkcjonalne.pl/sitemap.xml and $STAGING_URL/sitemap.xml, compare their loc sets with the host part removed, and record the differences and their reasons in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh && bash ../sm-grzybyfunkcjonalne-app/scripts/smoke.sh --url "$STAGING_URL" && curl -fsS -D - -o /dev/null "$STAGING_URL/sitemap.xml" | grep -qi '^content-type: application/xml' && curl -fsS "$STAGING_URL/_faq/api/v1/faqs/what-mushrooms-can-give-you" | grep -q '"data"' && echo uat-companions-ok</automated>
|
||||
<fails_when>Non-zero exit or "uat-companions-ok" absent: check-deploy finds a missing location, smoke.sh prints "smoke: FAIL" or lacks its ok line, /sitemap.xml is not served as application/xml through nginx, or the FAQ API does not answer the data envelope.</fails_when>
|
||||
<automated>LIVE=$(curl -fsS https://grzybyfunkcjonalne.pl/sitemap.xml) && UAT=$(curl -fsS "$STAGING_URL/sitemap.xml") && diff <(printf '%s' "$LIVE" | grep -o '<loc>[^<]*' | sed -E 's#<loc>https?://[^/]+##' | sort -u) <(printf '%s' "$UAT" | grep -o '<loc>[^<]*' | sed -E 's#<loc>https?://[^/]+##' | sort -u) && echo loc-sets-equal</automated>
|
||||
<fails_when>Non-zero exit or "loc-sets-equal" absent: the UAT sitemap's path set differs from the live PHP sitemap's (a URL missing or extra); record the diff and fix the source (definition import, maps or patterns) unless the SUMMARY explains it as a live-site change since the export.</fails_when>
|
||||
<human-check>Open $STAGING_URL/: the header menu shows Blog and the Grzyby Funkcjonalne dropdown with every category (desktop hover and click, mobile toggle, keyboard Esc), the active link is marked on a category page, and the FAQ section lists the questions with answers opening. In $STAGING_URL/gfadmin: Menus and Menu entries (edit an entry title, reorder by drag and by keyboard, the reference dropdown follows the type), FAQ (edit an answer in the richeditor, add a question row, save and see the change on the site after the cache TTL), Settings, Sitemap (the definition form with its rows) and Sitemap settings. Open $STAGING_URL/sitemap.xml in a browser (XSL view renders). Reply "uat approved" or list issues.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Each of `location = /sitemap.xml`, `location = /sitemap.xsl`, `location ^~ /_menu/` and `location ^~ /_faq/` is found by `grep -nF` in ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf.
|
||||
- `grep -n "import-companions.sh" ../sm-grzybyfunkcjonalne-app/scripts/migrate.sh` prints at least one line.
|
||||
- The SUMMARY records both import reports, the smoke output, the loc-set comparison and the human-check reply; no secret value appears in it.
|
||||
</acceptance_criteria>
|
||||
<done>UAT serves the menu, FAQ and sitemap from the plugins with the copied Winter data, behind nginx, and the deploy is documented.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| executor → PHP production server | redeploy next to the live Winter site |
|
||||
| live PHP database → export files | read-only queries run by the operator |
|
||||
| internet → nginx → Go | new public locations |
|
||||
| Nuxt SSR → plugin APIs | server-side fetches on loopback |
|
||||
| executor → git origins | five new repositories, tags, gitlinks |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-32 | Tampering (XSS) | layer v-html sinks and links | high | mitigate | FAQ HTML is sanitized by the server on output (plan 04); MenuNav renders a span for any URL outside the allowlist and adds rel noopener noreferrer on _blank; node test pins it |
|
||||
| T-17-33 | Tampering / Information Disclosure | export from the live PHP database | high | mitigate | SELECT-only export SQL run by the operator; outputs only on the server; no credentials in repositories or the SUMMARY |
|
||||
| T-17-34 | Spoofing | sitemap base URL behind nginx | medium | mitigate | nginx forwards Host and X-Forwarded-Proto; Go trusts them only from configured trusted proxies (127.0.0.1); the loc-set comparison and smoke check the result |
|
||||
| T-17-35 | Denial of Service | /_menu and /_faq public routes | medium | mitigate | plugin throttle buckets keyed by the trusted client IP; nginx GET and HEAD only on the new locations |
|
||||
| T-17-36 | Tampering | host CORS for the new APIs | low | accept | the public read APIs carry no credentials; the host's existing cors config (allowed origins *) applies only to the listed paths, like _journal/api |
|
||||
| T-17-37 | Tampering / Repudiation | pushes, tags and gitlinks | high | mitigate | blocking-human approval at Task 1; plugins pushed before gitlinks; every push verified against origin; heads and tags recorded; no tag moved |
|
||||
| T-17-38 | Denial of Service | UAT redeploy | medium | mitigate | nginx -t before reload; DEPLOY.md rollback (remove the four locations, re-enable the Nuxt sitemap, previous binary kept); the Winter vhost untouched |
|
||||
| T-17-SC | Tampering | npm installs (layer dev dependencies, layer git dependencies) | high | mitigate | only the already-pinned nuxt 4.4.8, vue 3.5.35 and typescript 5.9.3 (the vue-journal-layer set, no new package names); the layers are first-party repositories pinned by tag; lockfiles committed |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: companions-ready and a human approval with the recorded discovery facts.
|
||||
- Task 2: host vet and short suite, plugins pushed with the tag require, submodules present; the host companions test on a Docker host.
|
||||
- Task 3: layer tests, SSR smokes and tags on origin.
|
||||
- Task 4: site tests, build, SSR smoke and push.
|
||||
- Task 5: check-deploy, UAT smoke, sitemap content type and FAQ API through nginx, loc-set comparison with the live PHP sitemap, and the end-of-phase human check.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-4: grzybyfunkcjonalne.pl (UAT) reads its menu and FAQ from the plugins instead of app config.
|
||||
- SC-3 (live): the Go sitemap answers through nginx and lists the same paths as the PHP sitemap.
|
||||
- D-18: two tagged, public, module-free layers exist and the site consumes them.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-05-SUMMARY.md` when done, with the discovery facts, the maps, commits and pushed heads per repository, the layer tags, the import reports, the smoke output, the loc-set comparison and the human-check reply.
|
||||
</output>
|
||||
279
.planning/phases/17-blog-companions/17-06-PLAN.md
Normal file
279
.planning/phases/17-blog-companions/17-06-PLAN.md
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
phase: 17-blog-companions
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["17-01", "17-02", "17-03", "17-04", "17-05"]
|
||||
files_modified:
|
||||
- scripts/check-phase17.sh
|
||||
- .planning/phases/17-blog-companions/17-VALIDATION.md
|
||||
- modules/signpost/signpost_test.go
|
||||
- modules/seaglass/seaglass_test.go
|
||||
- modules/pact/menu_item_types_test.go
|
||||
- modules/party/registry_test.go
|
||||
- modules/cabana/repeater_test.go
|
||||
- modules/cabana/dependent_test.go
|
||||
- modules/cabana/richeditor_test.go
|
||||
- modules/cabana/reorder_test.go
|
||||
- modules/cabana/singleton_test.go
|
||||
- modules/cabana/companions_postgres_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- admin/tests/form/RichEditorField.test.ts
|
||||
- admin/tests/form/RepeaterField.test.ts
|
||||
- admin/tests/form/DependentDropdown.test.ts
|
||||
- admin/tests/form/SingletonForm.test.ts
|
||||
- admin/tests/views/ReorderView.test.ts
|
||||
- ../sm-journal-plugin/classes/menu_item_types_test.go
|
||||
- ../sm-journal-plugin/menu_item_types_test.go
|
||||
- ../sm-sitemap-plugin/classes/urlto_test.go
|
||||
- ../sm-sitemap-plugin/classes/tiers_test.go
|
||||
- ../sm-sitemap-plugin/classes/rewrite_test.go
|
||||
- ../sm-sitemap-plugin/classes/serialize_test.go
|
||||
- ../sm-sitemap-plugin/classes/generator_test.go
|
||||
- ../sm-sitemap-plugin/classes/manifest_test.go
|
||||
- ../sm-sitemap-plugin/models/definition_test.go
|
||||
- ../sm-sitemap-plugin/import_test.go
|
||||
- ../sm-menu-plugin/classes/links_test.go
|
||||
- ../sm-menu-plugin/classes/tree_test.go
|
||||
- ../sm-menu-plugin/menu_api_test.go
|
||||
- ../sm-menu-plugin/models/menu_entry_test.go
|
||||
- ../sm-faq-plugin/models/faq_test.go
|
||||
- ../sm-faq-plugin/faq_api_test.go
|
||||
- ../sm-grzybyfunkcjonalne-app/companions_test.go
|
||||
- ../vue-menu-layer/tests/menuLinks.test.ts
|
||||
- ../vue-menu-layer/tests/client.test.ts
|
||||
- ../vue-faq-layer/tests/client.test.ts
|
||||
autonomous: true
|
||||
requirements: [SC-5, SC-1, SC-2, SC-3, SC-4]
|
||||
estimate:
|
||||
tokens: 230000
|
||||
raw_tokens: 230000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-5 (CLAUDE.md lean rule 2): every piece of Phase 17 code has unit tests in this last plan: pact contracts, signpost, seaglass, the party hook, cabana repeater, dependent dropdown, richeditor, reorder and singleton, the four SPA features, the five Journal types, the sitemap serializer, Url::to, tiers, rewrite, manifest, hooks and imports, the menu and FAQ models, APIs and imports, the host wiring and both layers."
|
||||
- "D-01 and SC-2 pinned: TestRegistryDuplicateKey fails Activate with both plugin IDs; TestRegistryActivate covers empty key, nil Resolve, an empty or relative DefaultPattern, a PatternFrom naming an unregistered type, the type itself or a type with its own PatternFrom, and an unknown host override; TestRegistryPatternLayers covers item override over host config over default, and for a PatternFrom list type item override over the list key's host override over the record type's host override over the list's DefaultPattern; TestActivateMenuItemTypesOrder proves the registry exists before the first Boot; TestReferenceOptions covers depth flattening and applies false."
|
||||
- "D-10 pinned: TestSanitizeByteStable (clean HTML returned byte-identical), TestSanitizeReport (element, attribute and comment names), TestSanitizeBypassCorpus (the regex-gate bypasses from the bluemonday decision notes: event handlers without a space, entity-encoded javascript scheme, svg onload, data URLs) and TestRichEditor (save stores the clean value with notices; settings and relation forms refuse the type at boot)."
|
||||
- "D-07, D-08, D-09 and D-11 pinned: TestRepeaterCompile (every refused key and nested type), TestRepeaterDecode and TestRepeaterSave (row keys, coercion, per-row required paths, min and max, nested richeditor notices, nested dependsOn), TestDependentDropdownCompile and TestDependentRecheck (options route, null when applies is false, 422 outside the options), TestReorderPostgres (exact set, 409 stale, 409 foreign partition, 422 cycle, duplicate and maxDepth, both columns written, nothing written on refusal) and TestSingleton (first-or-create once, 404 on list, create and delete)."
|
||||
- "D-04 and D-02 pinned: TestMenuItemTypes covers all five Journal types on Postgres (a valid DefaultPattern on each and PatternFrom on the three list types, list children following a host override set only on journal-category or journal-post and an item override on the list item, references, nesting with default URLs only, list ordering, published filters, descendant categories, x-default, alternates in sort_order with ties by id, date placeholders only for published posts) and TestMenuItemTypesPatterns covers the ordered pattern fill without a database."
|
||||
- "SC-3 and D-12 to D-14 pinned: TestURLTo (the PHP golden table), TestXMLEscaping (PHP XmlEscapingTest as behaviour: every value escaped in text and attributes), TestFillable (PHP MassAssignmentTest: exactly theme and data), TestTiers (each default rule, first match wins, disabled tiers, host replacement, bad pattern fails boot), TestRewrite (loc, xhtml:link href and stylesheet href; equal URLs unchanged; trusted versus untrusted forwarded proto), TestHooks (Skip drops, Extra appended before the url closing tag), TestManifest (missing, oversized, non-object and non-regular files omit lastmod), TestDefinitionItemJSON (PHP-shaped strings, numbers, bools and unknown keys), TestURLCap (50,000th in, 50,001st out) and TestImportWinter (maps, drops with report, --force, --dry-run)."
|
||||
- "D-05, D-16 and D-17 pinned: TestAllowedURL and TestMenuEntryBeforeSave (PHP save rules), TestMenuTree (order and expansion without a database), TestMenuAPI and TestFaqAPI on Postgres (404 bodies, published and active filters, locale choice, duplicate slug lowest id, raw-SQL unsafe HTML sanitized, URL scheme filter, url_pattern override), and the import edge cases (empty file, unknown locale, page subtree dropped)."
|
||||
- "UI-SPEC backstops completed in vitest: untouched richeditor value byte-identical; repeater remove asks only for a non-empty row, arrow keys and buttons reorder and announce repeater.moved, nothing is sent before Save; three quick tree moves send one debounced POST with the full node set, a failed POST restores the tree, a 409 reloads with reorder.stale, each keyboard move announces; dependent dropdown states and last-request-wins; singleton form without Delete. Layer node tests: javascript: and data: URLs render as text without href, _blank gets rel noopener noreferrer, active and trail matching, clients build the right URLs and map 404 to null."
|
||||
- "scripts/check-phase17.sh runs every runnable suite fail-closed across summercms.go, the four plugin repositories, the host and both layers (stages go, plugins, admin, layers, docs, postgres; --self-test proves the detectors fail on planted output; --all ends with \"Phase 17 gate passed\" and says NOT RUN postgres loudly without Docker); 17-VALIDATION.md has every row filled with task IDs, commands and status, and nyquist_compliant true."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase17.sh"
|
||||
provides: "phase gate"
|
||||
contains: "Phase 17 gate passed"
|
||||
- path: "modules/signpost/signpost_test.go"
|
||||
provides: "registry unit tests"
|
||||
contains: "TestRegistryDuplicateKey"
|
||||
- path: "modules/seaglass/seaglass_test.go"
|
||||
provides: "sanitizer unit tests including the bypass corpus"
|
||||
contains: "TestSanitizeBypassCorpus"
|
||||
- path: "modules/cabana/reorder_test.go"
|
||||
provides: "reorder Postgres tests"
|
||||
contains: "TestReorderPostgres"
|
||||
- path: "../sm-sitemap-plugin/classes/urlto_test.go"
|
||||
provides: "Url::to golden table test"
|
||||
contains: "TestURLTo"
|
||||
- path: "../sm-menu-plugin/menu_api_test.go"
|
||||
provides: "menu API Postgres tests"
|
||||
contains: "TestMenuAPI"
|
||||
- path: ".planning/phases/17-blog-companions/17-VALIDATION.md"
|
||||
provides: "filled validation map"
|
||||
contains: "nyquist_compliant: true"
|
||||
key_links:
|
||||
- from: "scripts/check-phase17.sh"
|
||||
to: "every suite named in 17-VALIDATION.md"
|
||||
via: "fail-closed stages with required test name lists"
|
||||
pattern: "check-phase17"
|
||||
- from: "modules/cabana/openapi_conformance_test.go"
|
||||
to: "dependent-options, reorder and singleton routes"
|
||||
via: "conformance cases against admin/openapi/admin.json"
|
||||
pattern: "reorder"
|
||||
prohibitions:
|
||||
- statement: "No test touches a shared database, a server, git origin or the network: pure tests use stubs and in-memory stores, Postgres tests use throwaway testcontainers databases, layer tests stub fetch."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Tests are not weakened to pass: existing assertions stay; a production defect found here is fixed in a separate fix(17-06) commit with its own test and named in the SUMMARY."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged in this plan; test commits in the plugin, host and layer repositories stay local and are listed in the SUMMARY for the next release."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): the sitemap, menu and FAQ plugins run on SummerCMS as shared `sm-*-plugin` repos, so a blog site can manage its menus, FAQs and sitemap in the admin again instead of hardcoding them in the Nuxt app.
|
||||
|
||||
This plan's slice (plan 6 of 6, wave 4, CLAUDE.md lean rule 2): the unit tests for all new code (SC-5), the fail-closed phase gate and the filled validation map.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
SC-5 is unclassified by the edge probe; it is surfaced as a flagged assumption: coverage is judged by the filled 17-VALIDATION.md map and the gate's required test lists, not by an edge predicate. Every explicit edge truth of plans 01 to 05 gets a named test here. Prohibitions recalled in-prompt, descriptor-less.
|
||||
|
||||
## Decisions taken in this plan
|
||||
|
||||
- Test names are fixed by this plan (truths above and the task behaviours) so the gate's required lists are deterministic; a smoke test from an earlier plan keeps its name and joins the lists.
|
||||
- The gate follows scripts/check-phase16.3.sh: stages, go test -json parsing, vitest JSON reports, NOT RUN postgres without Docker, PHASE17_REQUIRE_POSTGRES=1 to fail instead, environment overrides for every sibling repository path.
|
||||
- Plugin, host and layer test commits stay local (no push in this plan).
|
||||
- Plan 02 tagged the framework and plan 05 deployed UAT before this plan runs, so a fix(17-06) commit that changes production code ships in neither. The SUMMARY flags each such commit as needing a follow-up release, naming the route: a framework patch tag, a plugin push plus a host pointer bump, or a layer tag.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `scripts/check-phase17.sh` (stages go, plugins, admin, layers, docs, postgres; `--self-test`, `--all`; overrides `PHASE17_ROOT`, `JOURNAL`, `SITEMAP`, `MENU`, `FAQ`, `HOST`, `MENU_LAYER`, `FAQ_LAYER`).
|
||||
- Unit test files listed in files_modified; `17-VALIDATION.md` filled with `nyquist_compliant: true`.
|
||||
|
||||
<objective>
|
||||
Pin every Phase 17 behaviour with unit tests, wire them into one fail-closed gate, and fill the validation map.
|
||||
|
||||
Purpose: SC-5 and the Nyquist validation contract.
|
||||
Output: tests across eight repositories, scripts/check-phase17.sh, 17-VALIDATION.md.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/17-blog-companions/17-VALIDATION.md
|
||||
@.planning/phases/17-blog-companions/17-RESEARCH.md
|
||||
@.planning/phases/17-blog-companions/17-01-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-02-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-03-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-04-SUMMARY.md
|
||||
@.planning/phases/17-blog-companions/17-05-SUMMARY.md
|
||||
|
||||
Repositories: summercms.go (this repo), ../sm-journal-plugin, ../sm-sitemap-plugin, ../sm-menu-plugin, ../sm-faq-plugin, ../sm-grzybyfunkcjonalne-app, ../vue-menu-layer, ../vue-faq-layer. Docker: Postgres tests skip under -short through a TestMain with flag.Parse() and testing.Short(); run them on a Docker host (sudo -u golem when the socket is denied) and record NOT RUN otherwise. Commits: tests and fixes in separate commits, no co-author tags, stage by explicit path per repository, nothing pushed.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the phase gate runs one contract test per layer end to end, fail-closed (registry, sanitizer, sitemap bytes, menu and FAQ APIs, SPA untouched value, layer link safety)</name>
|
||||
<files>scripts/check-phase17.sh, modules/signpost/signpost_test.go, modules/seaglass/seaglass_test.go</files>
|
||||
<read_first>scripts/check-phase16.3.sh (whole file: stage layout, go test -json detectors, vitest JSON detectors, --self-test, --all, NOT RUN handling, env overrides), scripts/check-phase16.2.sh (multi-repository stages), .planning/phases/17-blog-companions/17-VALIDATION.md, the smoke test names recorded in 17-01 to 17-05 SUMMARY files</read_first>
|
||||
<action>Create scripts/check-phase17.sh modelled on check-phase16.3.sh with stages go (vet in every Go repository; Docker-free framework suites by required name), plugins (vet and -short suites in Journal, sitemap, menu, FAQ and the host by required name), admin (typecheck and the vitest JSON report with required files and names), layers (pnpm test in both layers with TAP parsing: any not ok, zero tests or a missing required file fails), docs (TestDocsTree, summer docs:build --check, check-admin-openapi.sh --check, check-admin-dist.sh), and postgres (Docker host only: the Postgres suites by required name in every Go repository). Detectors fail on a non-zero exit, a failed, skipped or zero-match go test -json run, "no tests to run", a missing required name, a vitest failure, skip, todo or missing file, and a layer TAP failure. --self-test plants failing output for each detector and proves it fails. --all runs self-test, go, plugins, admin, layers and docs, then postgres when Docker answers, else prints NOT RUN postgres; PHASE17_REQUIRE_POSTGRES=1 makes that a failure. Paths default to the sibling checkouts and are overridable (PHASE17_ROOT, JOURNAL, SITEMAP, MENU, FAQ, HOST, MENU_LAYER, FAQ_LAYER). The final line on success is "Phase 17 gate passed".
|
||||
|
||||
Start the required lists with one contract test per layer, writing the two new framework ones now: TestRegistryDuplicateKey in modules/signpost/signpost_test.go (two fake plugins with the same key fail Activate, the error names both IDs) and TestSanitizeByteStable in modules/seaglass/seaglass_test.go (a set of clean editor HTML snippets come back byte-identical, including attributes in their original order and entities as written). The other contract tests come from earlier plans: TestSitemapGolden (sitemap), TestFaqAPISmoke and TestMenuAPISmoke (postgres stage), the RichEditorField tracer spec (admin) and the menu layer menuLinks test (layers).</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase17.sh --self-test && bash scripts/check-phase17.sh go && bash scripts/check-phase17.sh plugins && bash scripts/check-phase17.sh layers</automated>
|
||||
<fails_when>Non-zero exit: a detector accepts planted failing output in --self-test, or a stage reports a failed, skipped or missing required test.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Phase 17 gate passed" scripts/check-phase17.sh` prints one line and `grep -n "NOT RUN postgres" scripts/check-phase17.sh` prints at least one line.
|
||||
- `grep -n "PHASE17_REQUIRE_POSTGRES" scripts/check-phase17.sh` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>One command proves the phase's contracts at every layer and fails closed when any is missing or broken.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Full framework coverage: pact, signpost, seaglass, party hook, cabana repeater, dependent dropdown, richeditor, reorder, singleton and conformance; admin SPA specs</name>
|
||||
<files>modules/signpost/signpost_test.go, modules/seaglass/seaglass_test.go, modules/pact/menu_item_types_test.go, modules/party/registry_test.go, modules/cabana/repeater_test.go, modules/cabana/dependent_test.go, modules/cabana/richeditor_test.go, modules/cabana/reorder_test.go, modules/cabana/singleton_test.go, modules/cabana/companions_postgres_test.go, modules/cabana/openapi_conformance_test.go, admin/tests/form/RichEditorField.test.ts, admin/tests/form/RepeaterField.test.ts, admin/tests/form/DependentDropdown.test.ts, admin/tests/form/SingletonForm.test.ts, admin/tests/views/ReorderView.test.ts, scripts/check-phase17.sh</files>
|
||||
<read_first>modules/signpost/signpost.go, modules/seaglass/seaglass.go, modules/cabana/field_repeater.go, field_dependent.go, field_richeditor.go, reorder.go, singleton.go, modules/cabana/companions_smoke_test.go, field_type_test.go (table style), openapi_conformance_test.go, modules/party/registry_test.go, .planning/notes/journal-html-sanitizing-bluemonday.md and 2026-10-11-framework-html-sanitizer-bluemonday.md (bypass list), admin components and smoke specs from plan 02, .planning/phases/17-blog-companions/17-UI-SPEC.md (UI Considerations backstops), admin/tests/form/MarkdownField.test.ts and tests/helpers.ts</read_first>
|
||||
<behavior>
|
||||
- TestRegistryActivate, TestRegistryPatternLayers, TestReferenceOptions, TestRegistryHostOverrideUnknown, TestActivateMenuItemTypesOrder, TestOptionDepthOmitEmpty
|
||||
- TestSanitizeReport, TestSanitizeBypassCorpus, TestCheck
|
||||
- TestRepeaterCompile, TestRepeaterDecode, TestDependentDropdownCompile, TestRichEditorCompile, TestReorderConfigCompile, TestSingletonCompile (Docker-free)
|
||||
- TestRepeaterSave, TestDependentRecheck, TestRichEditor, TestReorderPostgres, TestSingleton, TestPhase10OpenAPIConformance with the new routes (Postgres)
|
||||
- vitest: RichEditorField (untouched byte-identical, edit emits HTML, empty paragraph emits "", lossy note, link allowlist, lazy-load failure fallback, notices callout), RepeaterField (remove confirm only when non-empty, keyboard and button reorder with repeater.moved, path errors expand and focus, min and max, collapsed rows do not mount richeditor), DependentDropdown (every UI-SPEC state, cache, last request wins, clear and announce), ReorderView (debounced full-set POST, revert on failure, 409 reload, keyboard moves announce, subtree drop refused, partition switch), SingletonForm (opens through /singleton, no Delete, back to settings)
|
||||
</behavior>
|
||||
<action>Per D-01 to D-03, D-06 to D-11: write the tests named in the behaviour block as tables where the framework already uses tables (field_type_test.go style), against the real compile and route code (no mocks of cabana internals), with Postgres tests in companions_postgres_test.go using the conform-style environment. TestRegistryPatternLayers is a table over fake types acme-post and acme-posts (PatternFrom acme-post) with every combination of item override, list-key host override and record-key host override, asserting the D-02 truth above; TestRegistryActivate rows include the three bad PatternFrom cases. TestSanitizeBypassCorpus takes its cases from the two bluemonday decision notes and asserts each comes back without the dangerous construct and with Changed true. TestReorderPostgres asserts row values before and after every refused request to prove nothing was written. Add conformance cases for every new route response (200, 404, 409, 422 where defined). Add the admin specs by the UI-SPEC backstop wording. Append every new name to the required lists in scripts/check-phase17.sh (Docker-free ones to the go stage, Postgres ones to the postgres stage, spec files and names to the admin stage). A production defect found while writing a test is fixed in its own fix(17-06) commit with that test.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/signpost ./modules/seaglass ./modules/pact ./modules/party ./modules/cabana && npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-phase17.sh go && bash scripts/check-phase17.sh admin</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a Docker-free framework test fails, an admin spec fails, or the gate reports a missing or skipped required name.</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && go test -count=1 -run '^(TestRepeaterSave|TestDependentRecheck|TestRichEditor|TestReorderPostgres|TestSingleton|TestPhase10OpenAPIConformance)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or fewer than six passes: a framework save, recheck, reorder or singleton rule regressed, or a new route response does not match the OpenAPI schema.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestSanitizeBypassCorpus" modules/seaglass/seaglass_test.go` and `grep -n "func TestReorderPostgres" modules/cabana/reorder_test.go` each print one line.
|
||||
- `grep -n "TestRepeaterSave" scripts/check-phase17.sh` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>Every framework behaviour added in plans 01 and 02 has a named, gated test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Full plugin and host coverage: Journal types, sitemap parity units and imports, menu and FAQ models, APIs and imports, host wiring</name>
|
||||
<files>../sm-journal-plugin/classes/menu_item_types_test.go, ../sm-journal-plugin/menu_item_types_test.go, ../sm-sitemap-plugin/classes/urlto_test.go, ../sm-sitemap-plugin/classes/tiers_test.go, ../sm-sitemap-plugin/classes/rewrite_test.go, ../sm-sitemap-plugin/classes/serialize_test.go, ../sm-sitemap-plugin/classes/generator_test.go, ../sm-sitemap-plugin/classes/manifest_test.go, ../sm-sitemap-plugin/models/definition_test.go, ../sm-sitemap-plugin/import_test.go, ../sm-menu-plugin/classes/links_test.go, ../sm-menu-plugin/classes/tree_test.go, ../sm-menu-plugin/menu_api_test.go, ../sm-menu-plugin/models/menu_entry_test.go, ../sm-faq-plugin/models/faq_test.go, ../sm-faq-plugin/faq_api_test.go, ../sm-grzybyfunkcjonalne-app/companions_test.go, scripts/check-phase17.sh</files>
|
||||
<read_first>../sm-journal-plugin/classes/menu_item_types.go and menu_item_types_smoke_test.go, ../sm-sitemap-plugin/classes/*.go, classes/testdata/*.json and *.golden, tests/php/gen-goldens.php, models/definition_item.go, console/import_winter.go, sitemap_smoke_test.go; /media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/tests/security/XmlEscapingTest.php and MassAssignmentTest.php; ../sm-menu-plugin/classes/links.go and tree.go, models/menu_entry.go, controllers/api/menus.go, menu_smoke_test.go; ../sm-faq-plugin/controllers/api/faqs.go, models/faq.go, faq_smoke_test.go; ../sm-grzybyfunkcjonalne-app/companions_test.go and boot_test.go</read_first>
|
||||
<behavior>
|
||||
- Journal: TestMenuItemTypesPatterns (Docker-free), TestMenuItemTypes (Postgres) per the D-04 truth
|
||||
- Sitemap Docker-free: TestURLTo, TestXMLEscaping, TestFillable, TestTiers, TestRewrite, TestHooks, TestManifest, TestDefinitionItemJSON, TestURLCap, TestSitemapGolden
|
||||
- Sitemap Postgres: TestImportWinter (maps, drops with report, --force, --dry-run, empty file, settings upsert)
|
||||
- Menu: TestAllowedURL, TestMenuTree (Docker-free); TestMenuEntryBeforeSave, TestMenuAPI, TestMenuImport (Postgres)
|
||||
- FAQ: TestFaqContentDecode (Docker-free); TestFaqAPI, TestFaqImport (Postgres)
|
||||
- Host: TestCompanionsHost extended with the CORS preflight for _menu/api and _faq/api and the sitemap Content-Type (its plan 05 assertions that list children use the host patterns /<slug> and /blog/<slug> stay)
|
||||
</behavior>
|
||||
<action>Per D-02 to D-05, D-12 to D-14, D-16, D-17 and SC-3: write the tests named in the behaviour block in each repository's existing style. TestURLTo iterates every case of classes/testdata/urlto.json against urlto.golden. TestXMLEscaping ports the PHP XmlEscapingTest as behaviour (ampersand, angle brackets, quotes, carriage return, tab and newline in loc, changefreq, priority and hreflang href produce exactly the libxml escapes; no raw value reaches the output). TestFillable ports MassAssignmentTest (Definition.Fillable equals theme and data; an admin save with extra keys writes only those). TestURLCap feeds 50,001 items and counts 50,000 url elements. TestTiers covers each default rule with paths taken from the rule list, trailing slashes, a disabled setting, a replaced host list and a bad RE2 pattern failing Boot. TestMenuAPI and TestFaqAPI cover every D-16 and D-17 rule in the truths, including raw-SQL unsafe rows. Imports cover empty files, refusals and sequences. Append every name to the plugins and postgres stage lists of scripts/check-phase17.sh. A production defect found is fixed in its own fix(17-06) commit in that repository with its test. Commit tests per repository; push nothing.</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase17.sh plugins && go -C ../sm-sitemap-plugin test -count=1 -run '^(TestURLTo|TestXMLEscaping|TestFillable|TestTiers|TestRewrite|TestHooks|TestManifest|TestDefinitionItemJSON|TestURLCap|TestSitemapGolden)$' -v ./... 2>&1 | grep -c '^--- PASS: Test' | grep -qx 10</automated>
|
||||
<fails_when>Non-zero exit: the plugins stage reports a failure or a missing required name, or fewer than ten sitemap parity tests pass (a golden, escaping, tier, rewrite, hook, manifest, JSON or cap rule regressed).</fails_when>
|
||||
<automated>docker info >/dev/null 2>&1 && bash scripts/check-phase17.sh postgres</automated>
|
||||
<fails_when>Non-zero exit: no Docker daemon (record NOT RUN), or a Postgres suite in any repository fails, skips or misses a required name.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestURLTo" ../sm-sitemap-plugin/classes/urlto_test.go`, `grep -n "func TestMenuAPI" ../sm-menu-plugin/menu_api_test.go` and `grep -n "func TestFaqAPI" ../sm-faq-plugin/faq_api_test.go` each print one line.
|
||||
- `git -C ../sm-sitemap-plugin status --porcelain` prints nothing after the commits, and the SUMMARY lists the unpushed commit range per repository.
|
||||
</acceptance_criteria>
|
||||
<done>Every plugin and host behaviour of plans 03 to 05 has a named, gated test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Layer coverage, the complete gate run and the filled validation map</name>
|
||||
<files>../vue-menu-layer/tests/menuLinks.test.ts, ../vue-menu-layer/tests/client.test.ts, ../vue-faq-layer/tests/client.test.ts, scripts/check-phase17.sh, .planning/phases/17-blog-companions/17-VALIDATION.md</files>
|
||||
<read_first>../vue-menu-layer/app/utils/menuLinks.ts and client.ts, ../vue-faq-layer/app/utils/client.ts, ../vue-journal-layer/tests/client.test.ts, .planning/phases/17-blog-companions/17-VALIDATION.md, every 17-0N-SUMMARY.md (task IDs, smoke names, NOT RUN suites)</read_first>
|
||||
<behavior>
|
||||
- menuLinks: every allowlisted scheme and relative form gives a link; javascript:, JAVASCRIPT:, data:, vbscript: and protocol-relative to a foreign host give text without href; _blank adds rel noopener noreferrer; active matching ignores query, hash and a trailing slash except on /; trail marks ancestors only; an absolute URL is active only on the same host
|
||||
- clients (both layers): base plus encoded code or slug plus locale; 404 resolves null; other errors reject; the server base is used on the server and the public base in the browser
|
||||
</behavior>
|
||||
<action>Per D-18 and the UI-SPEC layer backstop: complete the layer node tests named in the behaviour block, commit them in each layer (no push, no new tag). Append the layer test files to the layers stage. Run `bash scripts/check-phase17.sh --all` (with Docker when available) and record its full output. Fill 17-VALIDATION.md: every row of the Per-Task Verification Map gets the task ID (17-0N-TT), the exact automated command from the gate, File Exists ✅ and the status from the run (✅ green, or NOT RUN for Postgres rows without Docker); add rows for the API, import, layer and host suites; tick the Wave 0 items now delivered (check-phase17.sh, the PHP golden generator, go.work per plugin, the test harnesses); keep the two manual-only rows with the plan 05 human-check reference; set status validated, wave_0_complete true and nyquist_compliant true; Approval with the date. Commit the gate and the map in summercms.go (tests and planning docs in separate commits).</action>
|
||||
<verify>
|
||||
<automated>pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test && bash scripts/check-phase17.sh --all 2>&1 | tail -n 1 | grep -qx 'Phase 17 gate passed' && grep -q '^nyquist_compliant: true' .planning/phases/17-blog-companions/17-VALIDATION.md</automated>
|
||||
<fails_when>Non-zero exit: a layer test fails, the gate's last line is not "Phase 17 gate passed", or the validation map is not marked nyquist_compliant true.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "17-0[1-6]-" .planning/phases/17-blog-companions/17-VALIDATION.md` is at least 12 (every map row carries a task ID).
|
||||
- `grep -n "wave_0_complete: true" .planning/phases/17-blog-companions/17-VALIDATION.md` prints one line.
|
||||
- The SUMMARY carries the full --all output, including NOT RUN postgres when no Docker daemon answered.
|
||||
</acceptance_criteria>
|
||||
<done>The phase has one green gate, a filled Nyquist map and tests for everything it built.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test process → Docker | throwaway Postgres containers |
|
||||
| gate script → sibling repositories | reads and runs tests in local checkouts |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-17-39 | Repudiation (false green) | scripts/check-phase17.sh | high | mitigate | fail-closed detectors for zero-match, skipped and missing required tests; --self-test plants failing output; NOT RUN printed loudly; PHASE17_REQUIRE_POSTGRES for strict runs |
|
||||
| T-17-40 | Tampering | test fixtures with unsafe HTML and URLs | low | accept | fixtures exist only inside test processes and testcontainers databases; never rendered outside tests |
|
||||
| T-17-41 | Information Disclosure | test databases and secrets | low | mitigate | tests use testcontainers or GRZYBY_TEST_ADMIN_DSN throwaway databases and the existing test-only secrets; nothing touches shared databases, servers or origins |
|
||||
| T-17-SC | Tampering | npm/pip/cargo installs | high | mitigate | no new packages; tests run on the lockfiles committed in plans 02 and 05 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: gate self-test and the go, plugins and layers stages.
|
||||
- Task 2: framework Docker-free suites, admin specs, gate go and admin stages; framework Postgres suites on a Docker host.
|
||||
- Task 3: plugins stage and the sitemap parity tests; postgres stage on a Docker host.
|
||||
- Task 4: layer tests, the full gate ending in "Phase 17 gate passed", the validation map marked compliant.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-5: the new code has unit tests, delivered in the phase's last plan.
|
||||
- `bash scripts/check-phase17.sh --all` passes; 17-VALIDATION.md is filled and nyquist_compliant true.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/17-blog-companions/17-06-SUMMARY.md` when done, with the tests per repository, the unpushed commit ranges, any fix(17-06) commits (each one that changes production code flagged "needs follow-up release" with its repository and release route, because the plan 02 tag and the plan 05 UAT deploy predate it), the full gate output and the Postgres suites run or NOT RUN.
|
||||
</output>
|
||||
504
.planning/phases/17-blog-companions/17-PATTERNS.md
Normal file
504
.planning/phases/17-blog-companions/17-PATTERNS.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# Phase 17: Blog companions - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-11
|
||||
**Files analyzed:** ~60 new or modified files across 8 repos (grouped by the confirmed 6-plan split)
|
||||
**Analogs found:** 52 / ~60 (the rest have no analog and follow RESEARCH.md patterns)
|
||||
|
||||
Paths are relative to `/media/nvme/dev/golem15/summercms.io/summercms/`. Every analog named here is git-tracked source (checked with `git ls-files`). The new repos (`sm-sitemap-plugin`, `sm-menu-plugin`, `sm-faq-plugin`, `vue-menu-layer`, `vue-faq-layer`) copy their structure from `sm-journal-plugin` and `vue-journal-layer`.
|
||||
|
||||
## File Classification
|
||||
|
||||
### Plan 01: framework Go (summercms.go)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `summercms.go/modules/pact/capabilities.go` (+`HasMenuItemTypes`, `MenuItemType`, `MenuItemResolve`, `ResolvedMenuItem`, `MenuItemAlternate`, `DependentOptionsProvider`, optional `Option.Depth`) | contract | n/a | same file: `HasFormFieldTypes` (l.491), `DropdownOptionsProvider` (l.574) | exact |
|
||||
| `summercms.go/modules/<registry>/` (new beach module, e.g. `signpost`): `registry.go`, `README.md` | service/registry | boot-time collect + request-response lookup | `modules/phrasebook/translator.go` `Activate` (l.84-120) | exact |
|
||||
| `summercms.go/modules/party/registry.go` (call `<registry>.Activate`) | config/wiring | boot | same file, l.122-133 (`phrasebook.Activate`, `postcard.Activate`) | exact |
|
||||
| `summercms.go/modules/<sanitizer>/` (new, e.g. `seaglass`): bluemonday policy + `SanitizeReport` | utility | transform | `sm-journal-plugin/classes/format_html.go` (`SanitizeHTMLReport` l.164, `CheckHTML` l.335, `sanitizeHTML` l.348) | exact (port) |
|
||||
| `summercms.go/modules/cabana/form_schema.go` (add `repeater`, `richeditor` to `formFieldTypes`; keys `dependsOn`, `form`, `prompt`, `maxItems`, `minItems`, `titleFrom`) | config/schema | transform | same file l.24-40 | exact |
|
||||
| `summercms.go/modules/cabana/field_repeater.go` | service (field type) | CRUD decode/project | `modules/cabana/field_markdown.go`, `field_ml.go`; nested-value rule in `crud.go` l.159-175, l.1272 | role-match |
|
||||
| `summercms.go/modules/cabana/field_richeditor.go` | service (field type) | transform (save gate) | `sm-journal-plugin/classes/blog_editor.go` (`decodeBlogHTML` l.154, `removalNotices` l.171) + `cabana/field_markdown.go` | exact |
|
||||
| `summercms.go/modules/cabana/field_dependent.go` (options route + save recheck) | controller | request-response | `cabana/field_markdown.go` `markdownPreview` (l.71-100) for route shape | role-match |
|
||||
| `summercms.go/modules/cabana/reorder.go` (config_reorder.yaml, GET/POST) | controller | CRUD (tx write) | `cabana/field_file.go` file reorder ("exactly the visible files") + `crud.go` | role-match |
|
||||
| `summercms.go/modules/cabana/admin_openapi.go` (+swag ops) | config/docs | n/a | same file l.394-409 (`AdminMarkdownPreview`) | exact |
|
||||
| `summercms.go/modules/cabana/README.md`, `docs/…`, `.planning/notes/<date>-framework-html-sanitizer-bluemonday.md` | docs | n/a | `.planning/notes/journal-html-sanitizing-bluemonday.md` | exact |
|
||||
|
||||
### Plan 02: admin SPA + framework tag
|
||||
|
||||
| File | Role | Data Flow | Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `summercms.go/admin/src/components/form/fields/RichEditorField.vue` | component | event-driven | `sm-journal-plugin` TipTap editor (vite build, `package.json` l.14-18) + `admin/src/components/form/fields/MarkdownField.vue` | role-match |
|
||||
| `summercms.go/admin/src/components/form/fields/RepeaterField.vue` | component | CRUD (list of rows) | `admin/src/components/form/fields/MLField.vue` (wrapper rendering inner fields) | partial |
|
||||
| `summercms.go/admin/src/components/form/fields/DropdownField.vue` (dependsOn fetch) | component | request-response | same file | exact |
|
||||
| `summercms.go/admin/src/views/ReorderView.vue` | view | CRUD | `admin/src/components/form/fields/FileuploadField.vue` native DnD (l.904-910) + `views/ListView.vue` | partial |
|
||||
| `summercms.go/admin/src/components/form/registry.ts` | config | n/a | same file l.64-82, `valueless` set l.96 | exact |
|
||||
| `summercms.go/admin/openapi/admin.json`, `admin/src/api/schema.d.ts`, `modules/boardwalk/dist` | generated | n/a | `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` | exact |
|
||||
|
||||
### Plan 03: Journal types + sm-sitemap-plugin
|
||||
|
||||
| File | Role | Data Flow | Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `sm-journal-plugin/menu_item_types.go` | wiring | n/a | `sm-journal-plugin/field_types.go` (whole file) | exact |
|
||||
| `sm-journal-plugin/classes/menu_item_types.go` | service | request-response (resolve) | `sm-journal-plugin/classes/blog_editor.go` `BlogEditorTypes(app func() *backpack.App)` l.80; tree from `controllers/api/posts.go` `serializeCategoryTree` | exact |
|
||||
| `sm-sitemap-plugin/plugin.go`, `go.mod`, `go.work`, `README.md` | plugin wiring | n/a | `sm-journal-plugin/plugin.go` l.1-120 | exact |
|
||||
| `sm-sitemap-plugin/routes.go` (`/sitemap.xml`, `/sitemap.xsl`) | route | request-response | `sm-journal-plugin/routes.go` | exact |
|
||||
| `sm-sitemap-plugin/classes/{generator,serialize,urlto,tiers}.go` | utility | transform | none (hand-rolled per RESEARCH Pattern 4/4b) | none |
|
||||
| `sm-sitemap-plugin/models/{definition,settings}.go` + `fields.yaml` | model | CRUD | `sm-journal-plugin/models/tag.go`, `models/settings.go` | exact |
|
||||
| `sm-sitemap-plugin/controllers/definitions.go` + `config_form.yaml` | controller | CRUD | `sm-journal-plugin/controllers/tags.go` + `controllers/tags/config_form.yaml` | exact |
|
||||
| `sm-sitemap-plugin/admin.go` (Settings link, permissions) | config | n/a | `sm-journal-plugin/admin.go` l.38-50, `admin_permissions.go` | exact |
|
||||
| `sm-sitemap-plugin/updates/*.go` | migration | n/a | `sm-journal-plugin/updates/202610060003_create_golem15_journal_tags.go` | exact |
|
||||
| `sm-sitemap-plugin/console/import.go` (`sitemap:import`) | command | file-I/O | `sm-journal-plugin/console/import_posts.go` l.39-64 | exact |
|
||||
| `sm-sitemap-plugin/events.go` (BeforeAddItem/AddItem) | event | event-driven | `summercms.go/modules/festival/bus.go` `Handleable` l.18, `UntilHandled` l.100 | exact |
|
||||
| `sm-sitemap-plugin/assets/sitemap.xsl` | asset | static | copy byte-identical from `/media/nvme/dev/golem15/fonoteka/plugins/golem15/sitemap/assets/sitemap.xsl`; embed like `langFS` in journal plugin.go | exact |
|
||||
|
||||
### Plan 04: sm-menu-plugin + sm-faq-plugin
|
||||
|
||||
| File | Role | Data Flow | Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `sm-{menu,faq}-plugin/plugin.go`, `routes.go`, `admin*.go`, `go.mod`, `README.md`, `lang/{en,pl}` | plugin wiring | n/a | `sm-journal-plugin/plugin.go`, `routes.go`, `admin_navigation.go`, `admin_permissions.go` | exact |
|
||||
| `sm-menu-plugin/models/{menu,menu_entry}.go` | model | CRUD | `sm-journal-plugin/models/category.go` (parent_id, nest_*) + `models/tag.go` | exact |
|
||||
| `sm-faq-plugin/models/faq.go` (Jsonable content, Locale relation) | model | CRUD | `sm-journal-plugin/models/tag.go`; `summercms.go/modules/lagoon/jsonable.go` l.21; `sm-translate-plugin/models/locale.go` | role-match |
|
||||
| `sm-{menu,faq}-plugin/controllers/*.go` + `config_{list,form,reorder}.yaml` | controller | CRUD | `sm-journal-plugin/controllers/tags.go` + `controllers/tags/*.yaml` | exact |
|
||||
| `sm-menu-plugin/controllers/api/menus.go`, `sm-faq-plugin/controllers/api/faqs.go` | controller | request-response | `sm-journal-plugin/controllers/api/posts.go` `Categories` l.158-173 | exact |
|
||||
| `sm-{menu,faq}-plugin/updates/*.go` | migration | n/a | journal `updates/2026100600*_create_*.go` | exact |
|
||||
| `sm-{menu,faq}-plugin/console/import.go` | command | file-I/O | `sm-journal-plugin/console/import_posts.go`; DB lookup from `sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go` l.50-69 | exact |
|
||||
|
||||
### Plan 05: site integration
|
||||
|
||||
| File | Role | Data Flow | Analog | Match |
|
||||
|---|---|---|---|---|
|
||||
| `vue-{menu,faq}-layer/nuxt.config.ts` | config | n/a | `vue-journal-layer/nuxt.config.ts` l.1-40 (minus modules list) | exact |
|
||||
| `vue-{menu,faq}-layer/package.json` | config | n/a | `vue-journal-layer/package.json` (drop i18n/seo/fonts deps) | exact |
|
||||
| `vue-{menu,faq}-layer/app/utils/{client,types}.ts` | utility | request-response | `vue-journal-layer/app/utils/client.ts` l.1-30, `types.ts` | exact |
|
||||
| `vue-{menu,faq}-layer/app/composables/use{Menu,Faq}Api.ts` | hook | request-response | `vue-journal-layer/app/composables/useJournalApi.ts` | exact |
|
||||
| `vue-{menu,faq}-layer/app/components/{Menu,Faq}/*.vue`, `app/assets/css/*-tokens.css` | component | render | `vue-journal-layer/app/components/Journal/PostList.vue`, `app/assets/css/journal-tokens.css` | role-match |
|
||||
| `vue-{menu,faq}-layer/tests/*.test.ts`, `testing/*` | test | n/a | `vue-journal-layer/tests/client.test.ts`, `testing/smoke-ssr.mjs`, `testing/fixture-api.mjs` | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/{.gitmodules,go.work,go.mod,summer.yaml}` | config | n/a | existing journal entries in same files | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/config/http.yaml` (`cors.paths`) | config | n/a | same file l.2-4 | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/deploy/nginx/*.conf` (4 locations) | config | n/a | same file `location ^~ /_journal/` (l.132-) | exact |
|
||||
| `sm-grzybyfunkcjonalne-app/scripts/php-export/{menus,faqs,sitemap}.sql`, `scripts/migrate.sh` | script | batch | `scripts/php-export/categories.sql` header + `JSON_OBJECT` | exact |
|
||||
| `vue-grzybyfunkcjonalne-app/nuxt.config.ts`, removed menu config/static FAQ | config | n/a | same file (`extends`) | exact |
|
||||
|
||||
### Plan 06: unit tests + check-phase17.sh
|
||||
|
||||
| File | Role | Analog |
|
||||
|---|---|---|
|
||||
| `summercms.go/scripts/check-phase17.sh` | gate script | `summercms.go/scripts/check-phase16.3.sh` (stages go/admin/docs/postgres, `--self-test`, `--all`, env overrides) |
|
||||
| framework tests (`modules/<registry>/*_test.go`, `cabana/{repeater,richeditor,dependent,reorder}_test.go`) | test | `cabana/markdown_preview_route_test.go`, `cabana/field_type_test.go`, `cabana/example_field_type_test.go` |
|
||||
| plugin tests incl. PHP goldens (`classes/testdata/*.golden`) | test | `sm-journal-plugin/classes/format_html_test.go`, `updates/migrations_test.go`, `sm-grzybyfunkcjonalne-app/plugins/grzyby/console/import_categories_test.go`, `testmain_test.go` |
|
||||
| admin vitest specs | test | existing `admin/src/**/*.spec.ts` next to `MarkdownField.vue` |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### Capability interface (pact) — `modules/pact/capabilities.go`
|
||||
|
||||
**Analog:** same file, l.491-493 and l.567-576:
|
||||
```go
|
||||
type HasFormFieldTypes interface {
|
||||
FormFieldTypes() []FormFieldType
|
||||
}
|
||||
|
||||
// Option is one dropdown choice. Label may be a phrase key until request time.
|
||||
type Option struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// DropdownOptionsProvider serves method-backed dropdown options.
|
||||
type DropdownOptionsProvider interface {
|
||||
DropdownOptions(field string) []Option
|
||||
}
|
||||
```
|
||||
Add `HasMenuItemTypes { MenuItemTypes() []MenuItemType }` beside it; add a ctx-taking `DependentOptionsProvider` (the existing one has no ctx/DB). Doc comment on every exported identifier; docs checker requires README/docs mentions to exist.
|
||||
|
||||
### Registry module — `modules/<registry>/registry.go`
|
||||
|
||||
**Analog:** `modules/phrasebook/translator.go` l.84-105:
|
||||
```go
|
||||
// Activate loads HasLang catalogs in plugin order, then publishes one
|
||||
// Translator on app before Boot.
|
||||
func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("phrasebook: app is nil")
|
||||
}
|
||||
...
|
||||
for _, p := range plugins {
|
||||
hl, ok := any(p).(pact.HasLang)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := cat.Load(p.ID(), hl.LangFS()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
```
|
||||
Same shape: iterate plugins, type-assert `pact.HasMenuItemTypes`, fail loud on duplicate `Key` (error naming both plugin IDs), read host pattern overrides from `app.Config`, `app.Publish(reg)`. Consumers: `app.Lookup[*registry.Registry]()`.
|
||||
|
||||
**Wiring:** `modules/party/registry.go` l.128-133:
|
||||
```go
|
||||
if err := phrasebook.Activate(app, ordered); err != nil {
|
||||
return nil, fmt.Errorf("party: lang: %w", err)
|
||||
}
|
||||
if err := postcard.Activate(app, ordered); err != nil {
|
||||
return nil, fmt.Errorf("party: mail: %w", err)
|
||||
}
|
||||
```
|
||||
Insert `<registry>.Activate` after these, before the Boot loop, with `fmt.Errorf("party: menu item types: %w", err)`.
|
||||
|
||||
### Sanitizer module
|
||||
|
||||
**Analog:** `sm-journal-plugin/classes/format_html.go` l.164-174, 335-350:
|
||||
```go
|
||||
func SanitizeHTMLReport(doc string) (string, HTMLRemovals) {
|
||||
clean, changed := sanitizeHTML(doc)
|
||||
if !changed || clean == doc {
|
||||
return doc, HTMLRemovals{}
|
||||
}
|
||||
report := removalReport(doc, clean)
|
||||
...
|
||||
}
|
||||
func CheckHTML(doc string) error {
|
||||
if _, changed := sanitizeHTML(doc); changed {
|
||||
return errors.New("journal: HTML contains elements, attributes or URLs that are not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// sanitizeHTML: policy output compared by sameTokens (golang.org/x/net/html), so re-escaping alone is not a change.
|
||||
```
|
||||
Port the policy + token comparison into the framework (framework must not import the plugin). Journal keeps its own copy (no breaking change).
|
||||
|
||||
### cabana field types
|
||||
|
||||
**Schema registration** `modules/cabana/form_schema.go` l.24-30:
|
||||
```go
|
||||
formFieldTypes = map[string]struct{}{
|
||||
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
|
||||
...
|
||||
"markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},
|
||||
}
|
||||
```
|
||||
Add `"repeater"`, `"richeditor"`; add new keys to `formFieldKeys` (l.37-) and fail-loud checks (unknown nested type, missing `dependsOn` sibling, `groups`/`style` refused).
|
||||
|
||||
**Nested-value bypass** `modules/cabana/crud.go` l.1272:
|
||||
```go
|
||||
func nestedValue(val any) bool {
|
||||
switch val.(type) {
|
||||
case map[string]any, []any:
|
||||
return true
|
||||
...
|
||||
```
|
||||
Used at l.173 in `ProjectWritableFields` and l.1135. Add a schema-driven exemption for `repeater` fields only; do not loosen `nestedValue`.
|
||||
|
||||
**richeditor save gate:** copy `decodeBlogHTML` (l.154) + `removalNotices` (l.171) from `sm-journal-plugin/classes/blog_editor.go`, calling the new framework sanitizer; notices to `meta.notices`.
|
||||
|
||||
**New admin route shape** (dependent options, reorder) — `modules/cabana/field_markdown.go` l.71-100:
|
||||
```go
|
||||
func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := bouncer.User(r.Context())
|
||||
if !ok || principal == nil || !principal.Backend {
|
||||
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||
return
|
||||
}
|
||||
if !tokenRouteGate(principal, w) {
|
||||
return
|
||||
}
|
||||
var body AdminMarkdownPreviewRequest
|
||||
if err := s.decodeStrictBody(w, r, &body); err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
...
|
||||
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed",
|
||||
map[string]any{"markdown": []string{msgMarkdownPreviewRefused}})
|
||||
...
|
||||
WriteData(w, http.StatusOK, AdminMarkdownPreviewResult{HTML: html}, nil)
|
||||
}
|
||||
```
|
||||
Plus a swag block like `admin_openapi.go` l.394-409 and a conformance inventory case (`openapi_conformance_test.go`).
|
||||
|
||||
### Admin SPA — `admin/src/components/form/registry.ts`
|
||||
|
||||
l.64-82 renderer map; l.96 `valueless` set:
|
||||
```ts
|
||||
const renderers = new Map<string, Component>([
|
||||
['text', TextField],
|
||||
...
|
||||
['markdown', MarkdownField],
|
||||
['mlmarkdown', MLMarkdownField],
|
||||
])
|
||||
const valueless = new Set<string>([RELATION_MANAGER, 'widget', 'partial', 'fileupload'])
|
||||
```
|
||||
Add `['repeater', RepeaterField]`, `['richeditor', RichEditorField]`. Repeater is group-labelled (add to `groupLabelledTypes` l.101). TipTap pins `3.31.4` exact (`--save-exact`), human-verify checkpoint before `npm install`.
|
||||
|
||||
### Plugin wiring — every new `sm-*-plugin/plugin.go`
|
||||
|
||||
**Analog:** `sm-journal-plugin/plugin.go` l.1-56:
|
||||
```go
|
||||
var (
|
||||
_ party.Plugin = (*Plugin)(nil)
|
||||
_ pact.HasMigrations = (*Plugin)(nil)
|
||||
_ pact.HasModels = (*Plugin)(nil)
|
||||
_ pact.HasLang = (*Plugin)(nil)
|
||||
_ pact.HasCommands = (*Plugin)(nil)
|
||||
_ pact.HasRoutes = (*Plugin)(nil)
|
||||
_ surf.BucketProvider = (*Plugin)(nil)
|
||||
)
|
||||
|
||||
//go:embed lang
|
||||
var langFS embed.FS
|
||||
|
||||
type Plugin struct {
|
||||
app *backpack.App
|
||||
}
|
||||
|
||||
func (p *Plugin) ID() string { return "golem15.journal" }
|
||||
func (p *Plugin) Requires() []string { return []string{"golem15.translate"} }
|
||||
func (p *Plugin) Register(app *backpack.App) error { p.app = app; return nil }
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
func (p *Plugin) Models() []any { return models.All() }
|
||||
```
|
||||
Buckets (l.97-): copy `"journal-public-api"` entry → `"menu-public-api"` / `"faq-public-api"` (Max 120, Decay time.Minute, key `"<x>-public|" + surf.ClientIP(r, trusted)`). IDs: `golem15.sitemap`, `golem15.menu`, `golem15.faq`; FAQ `Requires()` translate; sitemap/menu do not hard-require journal (they read the registry).
|
||||
|
||||
**Lazy app for type closures** — `sm-journal-plugin/field_types.go`:
|
||||
```go
|
||||
func (p *Plugin) FormFieldTypes() []pact.FormFieldType {
|
||||
return classes.BlogEditorTypes(func() *backpack.App {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.app
|
||||
})
|
||||
}
|
||||
var _ pact.HasFormFieldTypes = (*Plugin)(nil)
|
||||
```
|
||||
→ `menu_item_types.go`: `func (p *Plugin) MenuItemTypes() []pact.MenuItemType { return classes.MenuItemTypes(func() *backpack.App {...}) }`.
|
||||
|
||||
### Public routes — `routes.go`
|
||||
|
||||
**Analog:** `sm-journal-plugin/routes.go`:
|
||||
```go
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
r.Group("/_journal/api/v1", surf.Use("throttle:journal-public-api"), func(g pact.Router) {
|
||||
g.Get("/categories", apicontrollers.Categories(p.app))
|
||||
g.Get("/posts/{slug}", apicontrollers.Show(p.app))
|
||||
g.Where("slug", `[a-z0-9][a-z0-9\-/]*`)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
Never attach the cabana `backend` middleware to public groups. Sitemap mounts `/sitemap.xml`, `/sitemap.xsl` at root (no throttle group needed, planner's call).
|
||||
|
||||
### Public API handler
|
||||
|
||||
**Analog:** `sm-journal-plugin/controllers/api/posts.go` l.158-173:
|
||||
```go
|
||||
func Categories(app *backpack.App) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
db, err := gdb(app)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": data})
|
||||
}
|
||||
}
|
||||
```
|
||||
Menu/FAQ: same, 404 JSON on unknown/inactive code or slug; FAQ sanitizes `description`/`answer` on output.
|
||||
|
||||
### Admin controller
|
||||
|
||||
**Analog:** `sm-journal-plugin/controllers/tags.go` (whole file):
|
||||
```go
|
||||
const (
|
||||
TagsControllerID = "golem15.journal.tags"
|
||||
PermissionAccessTags = "golem15.journal.access_tags"
|
||||
)
|
||||
type tagsAdminController struct{ app func() *backpack.App }
|
||||
func (tagsAdminController) ID() string { return TagsControllerID }
|
||||
func (tagsAdminController) ModelName() string { return `Golem15\Journal\Models\Tag` }
|
||||
func (tagsAdminController) ConfigDir() string { return "controllers/tags" }
|
||||
func (tagsAdminController) RequiredPermissions() []string { return []string{PermissionAccessTags} }
|
||||
func (tagsAdminController) NewRecord() any { return &models.Tag{} }
|
||||
var (
|
||||
_ pact.AdminController = tagsAdminController{}
|
||||
_ pact.AdminPermissioned = tagsAdminController{}
|
||||
_ pact.AdminRecordSource = tagsAdminController{}
|
||||
)
|
||||
```
|
||||
YAML: copy `controllers/tags/config_form.yaml` / `config_list.yaml` (PHP `modelClass`, `~/plugins/golem15/<x>/models/...` form paths, messages keys). Menu entries add `ListExtendQuery` scoped to `menu_id` and `config_reorder.yaml`; menu-entry and definition controllers implement `DependentOptions` calling the registry's `ReferenceOptions`. PHP permission codes: `golem15.sitemap.access_definitions`, `golem15.menu.manage_menu`, `golem15.faq.faqs`.
|
||||
|
||||
**Settings link** — `sm-journal-plugin/admin.go` l.38-50:
|
||||
```go
|
||||
func (p *Plugin) Settings() []pact.SettingsItem {
|
||||
return []pact.SettingsItem{{
|
||||
Code: "journal",
|
||||
Label: "golem15.journal::lang.journal.menu_label",
|
||||
Category: "golem15.journal::lang.journal.menu_label",
|
||||
Icon: "icon-pencil",
|
||||
Permissions: []string{"golem15.journal.manage_settings"},
|
||||
Form: "models/settings/fields.yaml",
|
||||
NewModel: func() any { return &models.Settings{} },
|
||||
```
|
||||
Sitemap: one `HasSettings` form (settings) + one `SettingsItem{Controller: ...}` linking the definitions controller (singleton form).
|
||||
|
||||
### Model
|
||||
|
||||
**Analog:** `sm-journal-plugin/models/tag.go`:
|
||||
```go
|
||||
type Tag struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Description *string `gorm:"column:description"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
func (Tag) TableName() string { return "golem15_journal_tags" }
|
||||
func (Tag) MorphName() string { return `Golem15\Journal\Models\Tag` }
|
||||
func (Tag) Fillable() []string { return []string{"name", "slug", "description"} }
|
||||
func (Tag) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
func init() { Register(Tag{}) }
|
||||
```
|
||||
JSON columns: `lagoon.Jsonable[T]` (`modules/lagoon/jsonable.go` l.21) with tolerant `UnmarshalJSON` for PHP-shaped data (Pitfall 7). Menu entry `BeforeSave` hook sets `nest_*` = 0, `sort_order = max+1`, inherits `menu_id`.
|
||||
|
||||
### Migration
|
||||
|
||||
**Analog:** `sm-journal-plugin/updates/202610060001_create_golem15_journal_posts.go`:
|
||||
```go
|
||||
var createPosts = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202610060001_create_golem15_journal_posts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE golem15_journal_posts ( id SERIAL PRIMARY KEY, ... )`,
|
||||
`CREATE UNIQUE INDEX ... ON ... (slug)`,
|
||||
} {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_posts`).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
func init() { Register(createPosts...) }
|
||||
```
|
||||
Plus `updates/registry.go` (`Register`/`All`). Tables/columns from RESEARCH "PHP port inventory" verbatim.
|
||||
|
||||
### Console import
|
||||
|
||||
**Analog:** `sm-journal-plugin/console/import_posts.go` l.39-64:
|
||||
```go
|
||||
func ImportPosts(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "journal:import-posts",
|
||||
Description: "Import journal posts with translations from JSON files.",
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "path", Description: "Import directory (default: journal-export)"},
|
||||
{Name: "force", Description: "Update existing posts (default: skip existing)", Bare: true},
|
||||
{Name: "dry-run", Description: "...", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { ... },
|
||||
}
|
||||
}
|
||||
```
|
||||
DB access via lookup-or-open — `sm-grzybyfunkcjonalne-app/plugins/grzyby/console/helpers.go` l.50-69:
|
||||
```go
|
||||
func lookupDB(ctx context.Context, app *backpack.App) (*gorm.DB, func(), error) {
|
||||
if db, ok := app.Lookup[*gorm.DB](); ok && db != nil {
|
||||
return db, func() {}, nil
|
||||
}
|
||||
if lagoon.DSN(app.Config) == "" { return nil, nil, fmt.Errorf("...") }
|
||||
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
|
||||
...
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil { _ = sqlDB.Close(); return nil, nil, err }
|
||||
return gdb, func() { _ = sqlDB.Close() }, nil
|
||||
}
|
||||
```
|
||||
Imports: `sitemap:import {file} --theme --append` (PHP JSON 1:1), plus JSONL importers for menu/FAQ (remap `locale_id` by code, `setval` sequences, refuse non-empty without `--force`).
|
||||
|
||||
### Typed sitemap events
|
||||
|
||||
**Analog:** `modules/festival/bus.go` l.18-20, 100-110:
|
||||
```go
|
||||
type Handleable interface {
|
||||
IsHandled() bool
|
||||
}
|
||||
func (b *Bus) UntilHandled[T any](ctx context.Context, event T) (bool, error) { ... }
|
||||
```
|
||||
`BeforeAddItem` struct implements `IsHandled()` → skip; `AddItem` via `Fire`.
|
||||
|
||||
### Nuxt layer
|
||||
|
||||
**Analog:** `vue-journal-layer/nuxt.config.ts` l.1-40:
|
||||
```ts
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const currentDir = fileURLToPath(new URL('.', import.meta.url))
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
css: [`${currentDir}app/assets/css/journal-tokens.css`, `${currentDir}app/assets/css/journal-base.css`],
|
||||
runtimeConfig: {
|
||||
journal: { apiBase: 'http://127.0.0.1:8096/_journal/api/v1' },
|
||||
public: { journal: { apiBase: '/_journal/api/v1' } },
|
||||
},
|
||||
})
|
||||
```
|
||||
Drop `modules: [...]` (layers stay module-free) and `pages: true` unless pages are added. Client: `vue-journal-layer/app/utils/client.ts` l.1-30 (injected `Fetcher` type so `node --test` can stub). `package.json`: same scripts (`"test": "node --test --test-reporter=tap tests/*.test.ts"`), engines, peer `nuxt 4.4.8`, no runtime deps.
|
||||
|
||||
### App integration
|
||||
|
||||
- `summer.yaml`: add `- id: golem15.sitemap / module: git.golem15.com/golem15/sm-sitemap-plugin` (same for menu, faq), mirroring journal entry.
|
||||
- `go.work` `use (...)`: add `./plugins/golem15/{sitemap,menu,faq}`.
|
||||
- `.gitmodules`: copy the journal block (`url = git@git.golem15.com:golem15/sm-<x>-plugin.git`, `branch = main`).
|
||||
- `config/http.yaml` `cors.paths`: add `_menu/api/*`, `_faq/api/*` after `_journal/api/*`.
|
||||
- nginx: copy `location ^~ /_journal/ { proxy_pass http://127.0.0.1:8096; ... }` for `/_menu/`, `/_faq/`, and `location = /sitemap.xml`, `location = /sitemap.xsl` (before `location /`).
|
||||
- `scripts/php-export/*.sql`: copy the `categories.sql` header (read-only, `mysql -N -B --raw <db> < x.sql > x.jsonl`, `SELECT JSON_OBJECT(...)`) and add README rows; order in `scripts/migrate.sh`.
|
||||
- Commit plugin repos first, then submodule pointer bumps as separate app commits.
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Fail-loud boot / schema validation
|
||||
**Source:** `modules/phrasebook/translator.go` `Activate`, `modules/cabana/form_schema.go` key sets. **Apply to:** registry, repeater, dependsOn, reorder config. Errors prefixed with the module name (`"<module>: ..."`).
|
||||
|
||||
### Public API errors and JSON
|
||||
**Source:** `sm-journal-plugin/controllers/api/posts.go` (`writeAPIError`, `wire.WriteJSON(w, 200, map[string]any{"data": ...})`). **Apply to:** menu and FAQ APIs.
|
||||
|
||||
### Rate limiting
|
||||
**Source:** `sm-journal-plugin/plugin.go` `Buckets()` + `surf.Use("throttle:<bucket>")` in routes. **Apply to:** `/_menu`, `/_faq`.
|
||||
|
||||
### HTML sanitizing
|
||||
**Source:** `sm-journal-plugin/classes/format_html.go` → new framework sanitizer. **Apply to:** cabana `richeditor` save, repeater nested richeditor, FAQ API output, optional FAQ import report.
|
||||
|
||||
### Admin write route
|
||||
**Source:** `cabana/field_markdown.go` `markdownPreview` (bouncer principal, `tokenRouteGate`, `decodeStrictBody`, `WriteErrorDetails` 422). **Apply to:** dependent-options and reorder routes; plus swag annotation + OpenAPI regen + conformance case.
|
||||
|
||||
### Docs-in-same-commit
|
||||
Any exported API/config/dependency change updates module README + `docs/`; run `summer docs:build --check`. New module = beach name + root README table row. No consumer names (grzyby) in framework/plugin READMEs.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `sm-sitemap-plugin/classes/serialize.go` | utility | transform | libxml-exact XML writer; follow RESEARCH Pattern 4 + PHP goldens |
|
||||
| `sm-sitemap-plugin/classes/urlto.go` | utility | transform | Laravel/Winter `Url::to` + `rawurlencode` port; RESEARCH Pattern 4b |
|
||||
| `sm-sitemap-plugin/classes/tiers.go` | utility | transform | Priority tiers from config; port from fonoteka `Definition.php` |
|
||||
| `summercms.go/modules/cabana/reorder.go` (tree validation) | controller | CRUD | No tree reorder exists; nearest is file reorder in `field_file.go` (set-equality rule only) |
|
||||
| `summercms.go/admin/src/views/ReorderView.vue` | view | CRUD | No tree view; reuse native DnD from `FileuploadField.vue` l.904-910 |
|
||||
| `summercms.go/admin/src/components/form/fields/RepeaterField.vue` | component | CRUD | No nested-row field; `MLField.vue` is the closest wrapper |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `summercms.go/modules/{pact,party,phrasebook,festival,cabana,lagoon}`, `summercms.go/admin/src`, `summercms.go/scripts`, `sm-journal-plugin/**`, `vue-journal-layer/**`, `sm-grzybyfunkcjonalne-app/{plugins/grzyby,scripts,config,deploy,summer.yaml,go.work,.gitmodules}`
|
||||
**Files scanned:** ~45
|
||||
**Pattern extraction date:** 2026-10-11
|
||||
**Note (RESEARCH conflict):** do not switch Journal category content to `richeditor`; it is already `blogeditor` (Phase 16.2).
|
||||
@@ -661,15 +661,24 @@ These patterns are copied from fonoteka `Definition.php` `getPriorityForUrl` [VE
|
||||
| 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
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
Every question below is settled by the phase plans; the resolution line names the plan that carries it.
|
||||
|
||||
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.
|
||||
**RESOLVED:** the clause is dropped as a recorded deviation: Journal category content stays `blogeditor`. See the 17-01 truth "D-10 resolved deviation", the decision note in 17-01 Task 2, and the 17-03 truth. The core `richeditor` serves FAQ `description` and the repeater `answer` (17-01 Task 2, 17-02, 17-04).
|
||||
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).
|
||||
**RESOLVED:** 17-04 adds the nullable `url_pattern` column in the additive migration `202610110003_add_url_pattern_to_golem15_menu_menu_entries`. The form gains a `url_pattern` field, and the API passes the column to signpost as the item override.
|
||||
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.
|
||||
**RESOLVED:** the 17-05 Task 1 blocking human checkpoint runs the read-only discovery queries and the plugin export SQL. It records the menu code and each DISTINCT type with its mapping, and the import commands receive those mappings as `--map old=new`.
|
||||
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.
|
||||
**RESOLVED:** the TTL is accepted. 17-05 Task 5 writes the todo `site-cache-purge-for-menu-and-faq.md` and documents the delay in DEPLOY.md and the site README.
|
||||
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.
|
||||
**RESOLVED:** the commands live in the plugins, each with its export SQL: `sitemap:import-winter` in 17-03, `menu:import-winter` and `faq:import-winter` in 17-04. In 17-05, `scripts/import-companions.sh` runs them in order (FAQ, menu, sitemap), and `migrate.sh` calls it after the Journal and locale steps.
|
||||
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).
|
||||
**RESOLVED:** both behaviors are kept, per the 17-03 SC-3 edge truth: a definition with no items answers 200 `application/xml` with an empty body, and a missing definition answers 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.
|
||||
**RESOLVED:** 17-04 adds the `target` dropdown (`_self` by default, `_blank`), validates it on save and documents it in the README's Winter adaptations.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
|
||||
@@ -38,23 +38,28 @@ created: "2026-10-11"
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled in by the planner once task IDs exist. Planned coverage by decision:
|
||||
Task IDs filled by the planner (2026-10-11; module names fixed: registry `signpost`, sanitizer `seaglass`). Smoke tests land with the feature task; the full unit tests land in plan 06 (lean rule 2). Plan 06 Task 4 updates File Exists and Status from the gate run.
|
||||
|
||||
| Ref | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|-----|----------|-----------|-------------------|-------------|--------|
|
||||
| SC-2/D-01 | Duplicate type key fails boot; registry lookup; pattern layers | unit | `go test -short ./modules/<registry> -run 'TestRegistry'` | ❌ W0 | ⬜ pending |
|
||||
| D-04 | Journal 5 types: references, resolve, alternates order, published filters | unit+pg | `cd ../sm-journal-plugin && go test ./... -run TestMenuItemTypes` | ❌ W0 | ⬜ pending |
|
||||
| D-07 | Repeater compile refusals, decode, row errors | unit | `go test -short ./modules/cabana -run TestRepeater` | ❌ W0 | ⬜ pending |
|
||||
| D-08 | dependsOn compile, options route, save recheck 422 | unit+conformance | `go test -short ./modules/cabana -run 'TestDependentDropdown\|TestPhase10ConformanceInventory'` | ❌ W0 | ⬜ pending |
|
||||
| D-09 | Reorder: exact-set rule, cycle/foreign parent refusal, both columns written | pg | `go test ./modules/cabana -run TestReorder` | ❌ W0 | ⬜ pending |
|
||||
| D-10 | richeditor sanitizes and reports; byte-stable when clean | unit | `go test -short ./modules/<sanitizer> ./modules/cabana -run 'TestSanitize\|TestRichEditor'` | ❌ W0 | ⬜ pending |
|
||||
| SC-3 | Sitemap golden parity with PHP output | unit (goldens) | `cd ../sm-sitemap-plugin && go test -short ./... -run 'TestSitemapGolden\|TestURLTo\|TestTiers\|TestRewrite'` | ❌ W0 | ⬜ pending |
|
||||
| D-14 | XmlEscaping/MassAssignment spec ports; add-item hooks | unit | `cd ../sm-sitemap-plugin && go test -short ./... -run 'TestXMLEscaping\|TestFillable\|TestHooks'` | ❌ W0 | ⬜ pending |
|
||||
| D-16 | Menu API tree, published filter, URL scheme filter, expansion | pg | `cd ../sm-menu-plugin && go test ./...` | ❌ W0 | ⬜ pending |
|
||||
| D-17 | FAQ API locale default, active only, sanitized output | pg | `cd ../sm-faq-plugin && go test ./...` | ❌ W0 | ⬜ pending |
|
||||
| Admin SPA | Repeater, RichEditor, dependent dropdown, ReorderView | vitest | `npm --prefix admin run test` | ❌ W0 | ⬜ pending |
|
||||
| D-18 | Layer clients and components, SSR smoke | node test | `pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test` | ❌ W0 | ⬜ pending |
|
||||
| SC-4 | Host boot with three plugins; import commands; `/sitemap.xml` from host | pg/host | `cd ../sm-grzybyfunkcjonalne-app && go test ./...` | partial | ⬜ pending |
|
||||
| Task ID | Ref | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|-----|----------|-----------|-------------------|-------------|--------|
|
||||
| 17-01-01 / 17-06-01, 17-06-02 | SC-2/D-01..D-03 | Duplicate type key fails boot; DefaultPattern and PatternFrom boot checks; host override checks; pattern layers incl. PatternFrom inheritance; registry before Boot; ReferenceOptions | unit | `go test -count=1 -short ./modules/signpost ./modules/party -run 'TestSignpostSmoke\|TestRegistry\|TestReferenceOptions\|TestActivateMenuItemTypesOrder'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-01 / 17-06-02 | D-08 | dependsOn compile, dependent-options route, save recheck 422 | unit+pg+conformance | `go test -count=1 ./modules/cabana -run 'TestDependentDropdownCompile\|TestCompanionsDependentSmoke\|TestDependentRecheck\|TestPhase10ConformanceInventory'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-02 / 17-06-01, 17-06-02 | D-10 | seaglass byte-stable, removal report, bypass corpus; richeditor save gate; settings/relation refusal | unit+pg | `go test -count=1 ./modules/seaglass ./modules/cabana -run 'TestSeaglassSmoke\|TestSanitize\|TestCheck\|TestRichEditor\|TestCompanionsRichEditorSmoke'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-03 / 17-06-02 | D-07 | Repeater compile refusals, decode, per-row errors, min/max, nested notices and recheck | unit+pg | `go test -count=1 ./modules/cabana -run 'TestRepeater\|TestCompanionsRepeaterSmoke'` | ❌ W0 | ⬜ pending |
|
||||
| 17-01-04 / 17-06-02 | D-09, D-11 | Reorder exact set, 409 stale/foreign, 422 cycle/depth, both columns; singleton first-or-create, 404 list/delete | pg | `go test -count=1 ./modules/cabana -run 'TestReorder\|TestSingleton\|TestCompanionsReorderSmoke\|TestCompanionsSingletonSmoke\|TestPhase10OpenAPIConformance'` | ❌ W0 | ⬜ pending |
|
||||
| 17-02-02..04 / 17-06-02 | D-06..D-11 (SPA) | RichEditorField (untouched byte-identical), RepeaterField, dependent dropdown states, ReorderView, singleton form | vitest | `npm --prefix admin run typecheck && npm --prefix admin test` | ❌ W0 | ⬜ pending |
|
||||
| 17-02-05 | D-06 | dist and OpenAPI in sync before the tag | script | `bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check` | ✅ | ⬜ pending |
|
||||
| 17-03-01, 17-03-02 / 17-06-03 | D-04 | Journal 5 types: DefaultPattern on each, list children follow the record type host pattern and the item override, references, nesting, list order, published filters, alternates order, x-default | unit+pg | `go -C ../sm-journal-plugin test -count=1 -run 'TestMenuItemTypes' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-03-01, 17-03-03 / 17-06-03 | SC-3 | Sitemap golden parity, Url::to table, tiers, rewrite, 50k cap | unit (goldens) | `go -C ../sm-sitemap-plugin test -count=1 -short -run 'TestSitemapGolden\|TestURLTo\|TestTiers\|TestRewrite\|TestURLCap' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-03-03 / 17-06-03 | D-14 | XmlEscaping and MassAssignment spec ports; BeforeAddItem skip, AddItem extra; manifest; tolerant item JSON | unit | `go -C ../sm-sitemap-plugin test -count=1 -short -run 'TestXMLEscaping\|TestFillable\|TestHooks\|TestManifest\|TestDefinitionItemJSON' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-03-03, 17-03-04 / 17-06-03 | SC-1, D-11..D-13 | sitemap:import and sitemap:import-winter; singleton admin; settings | pg | `go -C ../sm-sitemap-plugin test -count=1 -run 'TestSitemapImportSmoke\|TestSitemapAdminSmoke\|TestImportWinter' .` | ❌ W0 | ⬜ pending |
|
||||
| 17-04-01..03 / 17-06-03 | SC-1, D-05, D-07..D-10 | FAQ and menu admin, PHP save rules, URL scheme, reorder per menu, Winter imports | pg | `go -C ../sm-faq-plugin test -count=1 ./... && go -C ../sm-menu-plugin test -count=1 ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-04-05 / 17-06-03 | D-16 | Menu API tree, published filter, URL scheme filter, expansion, locale, url_pattern | pg | `go -C ../sm-menu-plugin test -count=1 -run 'TestMenuAPI\|TestMenuTree\|TestAllowedURL' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-04-05 / 17-06-03 | D-17 | FAQ API locale default, active only, lowest id, sanitized output (raw-SQL rows) | pg | `go -C ../sm-faq-plugin test -count=1 -run 'TestFaqAPI' ./...` | ❌ W0 | ⬜ pending |
|
||||
| 17-05-03 / 17-06-04 | D-18 | Layer clients, link safety, active matching, SSR smoke | node test | `pnpm -C ../vue-menu-layer test && pnpm -C ../vue-faq-layer test` | ❌ W0 | ⬜ pending |
|
||||
| 17-05-02 / 17-06-03 | SC-4, D-19 | Host boot with three plugins; imports of export-shaped fixtures; menu, FAQ and sitemap from the host; CORS | pg/host | `go -C ../sm-grzybyfunkcjonalne-app test -count=1 ./...` | partial | ⬜ pending |
|
||||
| 17-05-04 | SC-4, D-15 | Site app uses MenuNav and FaqBlock, data files removed, Nuxt sitemap off | node test + SSR smoke | `pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app test && pnpm -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app smoke:ssr` | ✅ | ⬜ pending |
|
||||
| 17-06-01..04 | SC-5 | Phase gate, fail-closed | script | `bash scripts/check-phase17.sh --all` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
|
||||
1
.planning/phases/17-blog-companions/COVERAGE.md
Normal file
1
.planning/phases/17-blog-companions/COVERAGE.md
Normal file
@@ -0,0 +1 @@
|
||||
No external API integration: this phase ports internal WinterCMS plugins (sitemap, menu, FAQ) to SummerCMS and publishes its own read APIs (/_menu/api/v1, /_faq/api/v1, /sitemap.xml) consumed by first-party Nuxt layers; no third-party service, SDK or remote API is called.
|
||||
Reference in New Issue
Block a user