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>
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
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:
- The page loads as a normal visitor page; the editor probes
GET /api/auth/me, gets 401 and shows Sign in. - Sign in starts a grant (
POST {issuer}/api/auth/device), opens the approval page and shows the user code. - 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. - 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)
// 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 consts 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.childrendoes 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 aregisterEditor). Icons are inline SVG (Material Design Icons via@mdi/js, tree-shaken, no font/CSP issues);registerTypeIcongives 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.
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-editoredits 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 nodeversion(409 -> conflict toast). - Insert menu =
x-graph.childrenof the parent schema + blueprints. data-cms-edgeisn't emitted by the templates yet, so the edge is resolved viaGET /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'sindexcounts 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.