# cms-onpageeditor Injectable on-page editor for the go-graph-cms (`docs/onpage-editor.md` in that repo). It is side-loaded onto the statically rendered page, mounts its UI in a shadow root, and edits **only through the HTTP API** (`POST /api/edit`, with undo/redo from server inverses). The page DOM stays server HTML: edits come back as `patches` and are morphed in. Reused from `@k6n/cms-editor` (aliased to the sources in the ecommerce repo, see `CMS_EDITOR_SRC`): `EditClient`, `applyPatches`/`morphChildren`, and the Tiptap `mountMarkdownEditor`. ## Develop ```sh npm install npm run dev:mock # offline: in-memory CMS + fixture page, no login -> http://localhost:5173 cp .env.example .env # then set CMS_TOKEN (or CMS_API_KEY / CMS_COOKIE) npm run dev # live: proxies https://k6n.net, injects the editor from source (HMR) npm test # editor + mock CMS in jsdom npm run build # dist/onpage-editor.js (what the site serves at /editor/onpage-editor.js) ``` **Live mode.** The proxy fetches page HTML server-side, strips the CSP, injects `/src/dev.ts`, and forwards cookies. `dev.ts` sets `cms_preview=1`, so the edge renders `/api/preview` - the page you see is the live store, not the published file. The dev server must run on **port 5173** (`strictPort`): go-auth's CORS allowlist names `localhost:5173`. ## Signing in The editor has its own login (`src/auth.ts`, UI in `src/ui/LoginPanel.tsx`) using go-auth's RFC 8628 device grant, as described in go-auth's `/dev-auth.md`: 1. The page loads as a normal visitor page; the editor probes `GET /api/auth/me`, gets 401 and shows **Sign in**. 2. *Sign in* starts a grant (`POST {issuer}/api/auth/device`), opens the approval page and shows the user code. 3. You approve on the issuer (admin-gated); the editor polls (honouring `interval` / `slow_down`), stores the tokens, re-fetches the page in preview mode and unlocks editing. 4. Access tokens (~15 min) are renewed with the rotating refresh token; a 401 triggers one refresh + retry, and a failed refresh returns to the login panel. *Sign out* (⎋) clears everything. The issuer and client come from `GET /api/auth/config`. The grant is run **as that client** (`k6n-admin` on k6n.net): the CMS verifies `aud` against its `OIDC_CLIENT_ID` (`cms.go: auth.NewOIDC(issuer, clientID)`), while go-auth's clientless fallback (`dev-console`) mints `aud=https://otel.k6n.net` and gets `401 oidc: wrong audience`. Override with `__CMS_EDITOR_CONFIG__.clientId` / `.audience`. A rejected token's reason is shown in the login panel. Storage: in **dev** the refresh token is kept in `localStorage`; in **production builds** it is only held in `sessionStorage` (go-auth's rule: never persist it there). An already valid backoffice login (`auth.tokens`) is picked up too. Fallbacks: *Use an access token instead* in the panel, or `window.__CMS_EDITOR_CONFIG__ = { token | apiKey | issuer }`. In dev the access token is also POSTed to the dev server (`/__dev/token`) so its server-side page fetches are authenticated as you. To skip the browser login entirely, put `CMS_TOKEN` / `CMS_API_KEY` / `CMS_COOKIE` in `.env` (they win), e.g. `CMS_TOKEN=$(node ../ecommerce/go-auth/scripts/dev-token.mjs token)`. `CMS_MOCK_AUTH=1 npm run dev:mock` makes the mock require the same flow (approve on the mock `/device` page), so the login UI can be developed offline. **Mock mode.** `mock/` is a small in-memory implementation of `/api/edit` (insert, set, move, delete, restore, duplicate; inverse ops; 409 on stale `version`; atomic rollback), `/api/nodes`, `/api/schemas`, `/api/types`. Fixture schemas/templates live in `mock/fixtures.ts` - add a node type there to try a new editor. `POST /__mock/reset` resets it. When the real server behaves differently, fix the mock first, then the editor. **Dev panel** (`` ` `` or the gear): every request with bodies/timings, the selected node + schema, and everything registered. `window.__cmsEditor` exposes `ctrl`, the registries and `destroy()`. ## Adding editors (the extension points, `src/registry.ts`) ```ts // A dialog for a node type (shows up as a toolbar button; same id replaces a built-in) registerEditor({ id: 'hero', label: 'Hero', icon: '★', match: 'hero', component: ({ node, schema, ctrl, close }) => ... }) // Ad-hoc: attach to any block by selector / node id / element attachDialog('.pricing-table', MyPricingDialog, { label: 'Prices' }) // A form widget for matching schema fields (x-editor, format, path...) registerFieldEditor({ id: 'colour', match: (s) => s['x-editor'] === 'colour', component: ColourField }) // Inline editing for a `data-cms-editor=""` field (built in: text, markdown) registerInlineEditor({ id: 'richtext', start({ el, node, field, ctrl }) { ...; return { stop() {} } } }) ``` Components get the `Controller`: `ctrl.setData / insert / remove / duplicate / nudge / apply(ops)`, `ctrl.openDialog(...)`, `ctrl.node(id)`, `ctrl.schema(type)`. ## The form (`src/form/`) Covers what the CMS schemas use: nested objects, `$ref`/`$defs`, `allOf`, nullable unions, `oneOf`/`anyOf` variant pickers (discriminator `const`s are set for you), **arrays** (primitive rows; object cards with a summary title, drag/up/down reorder, duplicate, remove, `minItems`/`maxItems`; `uniqueItems`+`enum` as checkboxes), **maps** (`additionalProperties`, free keys with rename/add/remove, or a fixed vocabulary via `x-dict-keys` / `x-editor: dictionary`), `x-group`/`x-groups` sections, `x-advanced` toggle, and the `x-editor` hints `hidden`, `readonly`, `json`, `markdown`/`textarea`/`monaco`/`html`/`richtext`, `asset` (thumbnail), `node`. Unknown `x-editor` values fall back to the type's normal widget; tuples and unmodelled shapes get a JSON box that keeps invalid text instead of losing it. Optional fields that are cleared (`''`, `[]`, `{}`) remove their key. Client-side validation (required, lengths, pattern, bounds, item counts, email) marks fields inline and disables Save; the server's schema validation stays authoritative. Not covered: `if/then/else`, `patternProperties`, `dependentSchemas` (they show up under "not in the schema" / JSON). Specialised backoffice editors (finder-item, tracking-event, layout-settings, style-class, sizes) are not ported - register one with `registerFieldEditor` where needed. ## Seeing the latest content (preview) The preview cookie makes the *edge* route page URLs to `/api/preview`, but a plain navigation can't carry the editor's bearer token, so the edge may still serve the published file. The editor therefore fetches the live render itself and morphs it into the page (no reload; scroll, focus and open dialogs stay): on load when signed in, after signing in, and whenever the tab becomes visible again (throttled to 5 s). Source order: `GET /api/preview?path=`, then the same with `&locale=`, then the page URL with `X-CMS-Preview: 1`. Visitors who are not signed in are never touched. ## Editing on the page - **Every string property is editable where it is rendered.** Templates only mark a few fields (`data-cms-field` + `data-cms-editor`); for the rest, selecting a block binds its data to its DOM *by value* (`src/page/infer.ts`): a property whose text is exactly one leaf element becomes an inline text field (nested arrays/objects included: `links.0.text`), and one whose value is exactly one attribute (`href`, `src`, `alt`, `title`…) gets a chip that edits it. Click a block to select it, click a field again to edit. Ambiguous values (same text twice, one value on two properties) are skipped rather than guessed, and enums, markdown/html fields and read-only fields are never bound as text - those stay in Properties (or need a template marker, e.g. `data-cms-editor="markdown"` for the WYSIWYG editor). - **Block palette** (dock ▸ Blocks): every type that fits somewhere on the page, plus blueprints. Drag onto the page - a ghost "virtual element" and a drop line show where it lands, and containers whose `x-graph.children` does not accept the type refuse it - or click to add to the selection. Existing blocks drag the same way from the toolbar grip, and **sort by snapping between their siblings** (root nodes included): faint markers show every position, the line snaps to the nearest, and the block is only nested into a container under the pointer while **Alt** is held. - **Insertion slots**: a selected or hovered container shows "+" between its children (and a big "Add block" when empty). - **One dialog per thing**: opening Properties/Style/an attribute editor/an insert picker that is already open raises it, pulses it and focuses its first field (unsaved edits are kept) instead of opening a duplicate; clicking a dialog brings it forward and Escape closes the topmost. Custom dialogs opt in with `ctrl.openDialog({ id, ... })`. - The toolbar keeps the everyday actions; **JSON** and other developer tools live under **…** (`placement: 'menu'` on a `registerEditor`). Icons are inline SVG (Material Design Icons via `@mdi/js`, tree-shaken, no font/CSP issues); `registerTypeIcon` gives a node type its own. ## Titles and groups (`x-graph`) A schema's `x-graph: { "group": "Commerce", "label": "title" }` drives the UI: **`label`** names the property whose value is the node's title, shown in the outline, hover tag, toolbar chip, dialog titles and drag chip (types without a label fall back to the type name); **`group`** categorises the type in the block palette and the insert picker (groups are collapsible; types without one land in "Other") and is shown next to the type on hover. The block list shows each type's schema `title` with the type id underneath, and skips page-root (`staticRoot`) types. One `GET /api/types` request (which carries every type's schema) supplies titles, groups, labels and acceptance rules for the whole list. Node data for the titles arrives in one `GET /api/tree/{root}` request, stays live as you edit, and is fetched lazily for new nodes. ## Styling (spacing, flow, gap) Schema-driven via the `x-style` annotation, with on-page drag handles, a Style dialog and wrap-in-layout. Design and the schema contract: [docs/styling.md](docs/styling.md). ## How it behaves - Drag the ⠿ grip on the selected block to reorder / re-parent (green line = drop position); deleting shows an Undo toast. - Click selects (links don't navigate in *Editing*; use *Browsing* to follow them); double-click a field with `data-cms-editor` edits in place (`text`: contenteditable, `markdown`: Tiptap), otherwise opens Properties. Typing is debounced and coalesced into one undo step. - Properties = schema-driven form (`src/form/`), saves only the diff with the node `version` (409 -> conflict toast). - Insert menu = `x-graph.children` of the parent schema + blueprints. - `data-cms-edge` isn't emitted by the templates yet, so the edge is resolved via `GET /api/nodes/{id}/edges` (and used automatically once the attribute appears). - Patches with `error` (e.g. the page node itself) trigger a soft re-fetch + morph of the page. ## Deploying Serve `dist/onpage-editor.js` at `/editor/onpage-editor.js` and let the existing loader do `if (document.cookie.includes('cms_preview=1')) import('/editor/onpage-editor.js')`. The bundle re-checks the cookie, so loading it for a visitor is a no-op. ## Not done / next - Real-browser e2e (Playwright against `dev:mock`) - only jsdom is covered so far; CSS/shadow-DOM look and Tiptap have not been checked in a real browser. - Drag reorder is vertical-flow only, and assumes `move`'s `index` counts siblings excluding the moved block (as the mock does) - verify against the real server. - Bundle is ~295 kB gzipped, mostly Tiptap; split the markdown editor into a lazy chunk if that matters. - The mock doesn't implement `graft`/`blueprint`; the picker lists blueprints but they only work on the real server.