mats 33bd5269fd Single panel: outline tree with slots, node search with type filter
The outline and blocks are no longer tabs: one panel shows the
outline tree, the new-block palette and a search over existing
nodes. Outline rows drag to reorder (same snap engine, explicit
gap targets), palette blocks and searched nodes drop onto the
outline gaps as well as the page.

Named slots (x-graph.connections, e.g. a pdp's info-section /
3d-accessories / model) render as groups under their block with
allowed types, live counts and a slot-limited insert picker.
Drops that fit no plain-children rule resolve to the first
accepting slot; existing nodes link via graft tree.link.

Adds Api.searchNodes, schema connections helpers, handle-aware
controller ops (linkExisting, insertIntoSlot, dropExisting) and
mock support for /api/nodes listing and graft links.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-11-06 20:38:31 +01:00

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:

  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)

// 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.children does 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 (graft with tree.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's info-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 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.

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

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-written cms-client in every instance. With the cms_preview=1 cookie it imports onpage-editor.js from 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'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.
S
Description
No description provided
Readme
804 KiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 93.5%
CSS 6.5%