Files
cms-onpageeditor/mock/server.ts
T
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

153 lines
8.0 KiB
TypeScript

// 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 (path === '/api/nodes' && m === 'GET') {
const type = url.searchParams.get('type')
const q = (url.searchParams.get('q') ?? '').toLowerCase()
const limit = Math.max(0, Number(url.searchParams.get('limit') ?? 50))
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0))
let items = [...graph.nodes.values()]
if (type) items = items.filter((n) => n.dataType === type)
if (q) items = items.filter((n) => n.id.toLowerCase().includes(q) || JSON.stringify(n.data).toLowerCase().includes(q))
const total = items.length
items = limit === 0 ? items.slice(offset) : items.slice(offset, offset + limit)
return json(200, { items: items.map((n) => ({ id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version })), total, offset, limit })
}
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 { ids: string[] }
published.push(...body.ids)
return json(200, { publishedAt: new Date().toISOString(), results: body.ids.map((id) => ({ id })) })
}
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,
}
}