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>
153 lines
8.0 KiB
TypeScript
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,
|
|
}
|
|
}
|