Injectable React editor that is side-loaded onto the statically rendered page and edits through the CMS HTTP API (POST /api/edit). - Shadow-root UI over server HTML; patches morphed in, undo/redo from server inverses (reuses @k6n/cms-editor EditClient/morph/markdown editor) - Sign-in via go-auth device grant as the CMS client (aud matches), token refresh + 401 retry; live preview swapped in on load and on tab focus - Schema-driven property form (arrays, maps, variants, $ref, validation) - Every string property editable where it is rendered (value-bound inline fields and attribute chips), markdown WYSIWYG for marked fields - Block palette + drag & drop with ghost drop targets, insertion slots, sort snapping between siblings (Alt to nest) - x-style annotations: on-page padding/margin/gap handles, Style panel, wrap-in-layout; x-graph label/group for titles and grouping - Dev workflow: Vite proxy to the live site (token injected server-side) or an offline in-memory mock CMS; jsdom test suite Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
159 lines
12 KiB
Markdown
159 lines
12 KiB
Markdown
# 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="<id>"` 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=<path>`, then the same with `&locale=<html lang>`, 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.
|