Add on-page editor for the go-graph-cms

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>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-31 22:34:48 +01:00
commit 1af9ec3172
67 files changed
+12322

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
// Framework-neutral request handler for the mock CMS, used by the Vite plugin
// (dev:mock) and directly by tests (as an injectable `fetch`).
import { Graph, HttpError } from './graph'
import { blueprints, render, renderPage, schemas, seedGraph, types } from './fixtures'
export interface MockOptions {
/** Require this bearer token (to test the 401 path). Default: open. */
token?: string
/** Require sign-in through the mock device flow (GET/POST /api/auth/device*). Default: open. */
auth?: boolean
/** Auto-approve a device grant this long after it starts (dev:mock). Tests call approve(). */
approveAfterMs?: number
}
export function createMockCms(opts: MockOptions = {}) {
let graph = seedGraph()
const published: string[] = []
const requests: string[] = []
let previewApi = true
// --- mock go-auth (RFC 8628 device grant) ---
const access = new Set<string>()
let refreshTok: string | null = null
let approved = false
let n = 0
const issue = () => {
const a = `mock-access-${++n}`
access.add(a)
refreshTok = `mock-refresh-${n}`
return { access_token: a, token_type: 'Bearer', expires_in: 900, refresh_token: refreshTok }
}
const needsAuth = () => !!(opts.token || opts.auth)
const authorised = (req: Request) => {
const h = req.headers.get('authorization')
return !!h && (h === `Bearer ${opts.token}` || access.has(h.replace(/^Bearer /, '')))
}
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'x-cms-mock': '1' } })
async function handle(req: Request): Promise<Response> {
const url = new URL(req.url, 'http://mock')
const path = url.pathname
const m = req.method
requests.push(`${m} ${path}${url.search}`)
try {
// The mock is its own issuer (same origin), so the browser can complete the flow with no network.
if (path === '/device') {
return new Response(
`<!doctype html><meta charset="utf-8"><title>Mock approval</title><body style="font:16px system-ui;max-width:420px;margin:15vh auto;text-align:center"><h1>Approve sign-in?</h1><p>Code <b>MOCK-1234</b></p><button style="font:inherit;padding:10px 24px" onclick="fetch('/__mock/approve',{method:'POST'}).then(()=>{document.body.innerHTML='<h1>Approved</h1><p>You can close this tab.</p>';setTimeout(close,800)})">Approve</button>`,
{ headers: { 'content-type': 'text/html; charset=utf-8' } },
)
}
if (path === '/__mock/approve' && m === 'POST') {
approved = true
return new Response(null, { status: 204 })
}
if (path === '/api/auth/config') return json(200, needsAuth() ? { mode: 'oidc', issuer: url.origin, clientId: 'mock' } : { mode: 'none' })
if (path === '/api/auth/device' && m === 'POST') {
approved = false
if (opts.approveAfterMs !== undefined) setTimeout(() => (approved = true), opts.approveAfterMs)
return json(200, { device_code: 'mock-device', user_code: 'MOCK-1234', verification_uri: `${url.origin}/device`, verification_uri_complete: `${url.origin}/device?user_code=MOCK-1234`, expires_in: 600, interval: 1 })
}
if (path === '/api/auth/device/token' && m === 'POST') return approved ? json(200, issue()) : json(400, { error: 'authorization_pending' })
if (path === '/api/auth/device/refresh' && m === 'POST') {
const b = (await req.json()) as { refresh_token?: string }
return b.refresh_token && b.refresh_token === refreshTok ? json(200, issue()) : json(400, { error: 'invalid_grant' })
}
if (needsAuth() && path.startsWith('/api/') && !authorised(req)) return json(401, { error: 'unauthorized: auth: no credentials' })
if (path === '/api/auth/me') return json(200, { sub: 'mock-user', scopes: ['admin'] })
if (path === '/api/types') return json(200, { types, locales: [] })
if (path === '/api/blueprints') return json(200, blueprints)
let r: RegExpExecArray | null
if ((r = /^\/api\/schemas\/([^/]+)$/.exec(path))) {
const s = schemas[decodeURIComponent(r[1])]
return s ? json(200, s) : json(404, { error: 'unknown type' })
}
if ((r = /^\/api\/nodes\/([^/]+)\/edges$/.exec(path))) {
graph.node(r[1])
return json(200, graph.edgesOf(r[1]))
}
if ((r = /^\/api\/nodes\/([^/]+)$/.exec(path)) && m === 'GET') {
const n = graph.node(r[1])
return json(200, { id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version })
}
if ((r = /^\/api\/tree\/([^/]+)$/.exec(path)) && m === 'GET') {
// TreeNode { node, edge, children[] } - one request for the whole page's data
const walk = (id: string, edge?: unknown): unknown => {
const n = graph.node(id)
return { node: { id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version }, edge, children: graph.children(id).map((e) => walk(e.to, e)) }
}
return json(200, walk(r[1]))
}
if (path === '/api/edit' && m === 'POST') {
const body = (await req.json()) as { ops: any[] }
const res = graph.edit(body.ops)
const patches = res.touched.map((id) => {
if (graph.parents(id).length === 0) return { id, error: 'root cannot render standalone' } // like a real page node
return { id, html: render(graph, id) }
})
return json(200, { ...res, patches })
}
if (path === '/api/publish-roots' && m === 'POST') {
const body = (await req.json()) as { roots: string[] }
published.push(...body.roots)
return json(200, { published: body.roots })
}
if (path === '/api/preview' && !previewApi) return json(404, { error: 'no such route' })
if (path === '/api/preview' || (m === 'GET' && req.headers.get('accept')?.includes('text/html'))) {
return new Response(renderPage(graph), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-cms-page': '1' } })
}
return json(404, { error: `mock: no route for ${m} ${path}` })
} catch (e) {
if (e instanceof HttpError) return json(e.status, { error: e.message })
return json(500, { error: (e as Error).message })
}
}
return {
handle,
/** Back to the seed data (also exposed at POST /__mock/reset in dev). */
reset: () => void (graph = seedGraph()),
/** Approve the pending device grant (what the user does on the issuer's /device page). */
approve: () => void (approved = true),
/** Simulate access-token expiry: every issued access token starts returning 401. */
expireAccess: () => access.clear(),
/** Simulate revocation: the refresh token stops working too. */
revoke: () => {
access.clear()
refreshTok = null
},
get graph() {
return graph
},
published,
requests,
/** Simulate a CMS without GET /api/preview (the editor must fall back to the page URL). */
disablePreviewApi: () => void (previewApi = false),
fetch: ((input: RequestInfo | URL, init?: RequestInit) => handle(new Request(new URL(String(input), 'http://mock'), { ...init, signal: undefined }))) as typeof fetch,
}
}