Outline chevron buttons were matched by a generic `.cms-outline li
button { width: 100% }` rule, so each expand/collapse arrow took a full
row and the tree looked stacked and broken. Scope row styling to
`.cms-orow__main`, fix the chevron to 22px, add rotating chevrons to the
section headers, and turn the blocks palette from centered narrow cards
into left-aligned rows.
Add a split toggle to the panel header: outline stays visible in a left
column while Blocks/Nodes switch as tabs in a right column, so a drag
source and its outline drop gaps are on screen together. Choice and
library tab persist per profile; stacked mode is unchanged.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.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). - Panel (dock ▸ Page): outline, blocks and nodes in one place, no tabs. Drag palette blocks, searched nodes and
outline rows 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 drop them onto the outline's gaps and slots instead. Existing blocks drag the same way from the toolbar grip or the outline 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. Reordering inside the outline uses the same engine: drop a row onto another row's gap (before / after / into). - Nodes: search existing nodes (
GET /api/nodes?type=&q=) with a type filter, and link a shared block into the page (graftwithtree.link: no copy, one node in many places) - drag the row onto the page or the outline, or click + to add it next to the selection. - Slots / dropzones (
x-graph.connections, e.g. a pdp'sinfo-section,3d-accessories,model): the outline shows each named slot under its block with its allowed types, a count and an add button (the insert picker is limited to the slot's types). Drops that fit no plain-children rule resolve to the first accepting slot automatically. - 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
Part of the ecommerce monorepo as the cms-onpageeditor submodule. Each instance image (tornberg, bygglagret,
k6n) and Dockerfile.nginx build it in an onpage stage (named context onpageeditor) and ship two files in /editor/:
cms-client.js- the tiny loader (src/loader-core.ts, ~0.5 kB gz) that page templates already reference. It replaces the old hand-writtencms-clientin every instance. With thecms_preview=1cookie it importsonpage-editor.jsfrom the same directory; otherwise it does nothing.onpage-editor.js- the editor.
Entering edit mode on a deployed site: open /edit or /edit/<path> (the edge, deploy/nginx.conf, sets the cookie and redirects
to the page). Entering/leaving edit mode: open any page with ?cms_preview=1 (sets the cookie, strips the param, reloads so the edge serves the
live render) and ?cms_preview=0 to leave. Dev: make dev rebuilds both into the instance's public/ (npm run build:watch,
CMS_ONPAGE_OUT). The shared @k6n/cms-editor sources are found at ../backoffice/web/packages/cms-editor/src (submodule layout),
../ecommerce/... (standalone checkout), .cms-editor/ (Docker) or CMS_EDITOR_SRC.
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.