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

+233
View File
@@ -0,0 +1,233 @@
import { Graph } from './graph'
import type { MNode } from './graph'
const lifecycle = { lastModified: { type: 'string', readOnly: true }, status: { type: 'string', readOnly: true } }
/** JSON Schemas with the x-graph extension, shaped like GET /api/schemas/{type}. */
export const schemas: Record<string, any> = {
page: {
type: 'object',
title: 'Page',
required: ['title'],
properties: { title: { type: 'string' }, path: { type: 'string' }, ...lifecycle },
'x-graph': { children: ['section', 'heading', 'text', 'button', 'footer', 'layout'], group: 'Pages', label: 'title' },
},
// Mirrors the real footer schema, plus a map, an enum set, a oneOf and a $ref - exercises every form widget.
footer: {
type: 'object',
title: 'Footer',
'x-graph': { group: 'Layout', label: 'companyName' },
required: ['companyName'],
'x-groups': { contact: { title: 'Contact', collapsedByDefault: false }, social: { title: 'Social' } },
$defs: { link: { type: 'object', title: 'Link', required: ['text'], properties: { text: { type: 'string', minLength: 2 }, href: { type: 'string', format: 'uri' }, external: { type: 'boolean' } }, additionalProperties: false } },
properties: {
companyName: { type: 'string', default: 'Acme AB' },
tagline: { type: 'string' },
address: { type: 'string', 'x-editor': 'markdown', description: 'One line per row', 'x-group': 'contact' },
email: { type: 'string', format: 'email', 'x-group': 'contact' },
links: { type: 'array', description: 'Footer links', minItems: 0, maxItems: 6, items: { $ref: '#/$defs/link' } },
usps: { type: 'array', items: { type: 'string' }, description: 'Short selling points' },
social: { type: 'object', additionalProperties: { type: 'string', format: 'uri' }, 'x-group': 'social', description: 'network → url' },
badges: { type: 'array', uniqueItems: true, items: { type: 'string', enum: ['iso9001', 'gdpr', 'b-corp'] } },
cta: {
title: 'Call to action',
oneOf: [
{ title: 'Link', type: 'object', required: ['kind', 'href'], properties: { kind: { const: 'link' }, href: { type: 'string' } } },
{ title: 'Phone', type: 'object', required: ['kind', 'number'], properties: { kind: { const: 'phone' }, number: { type: 'string' } } },
],
},
internal: { type: 'string', readOnly: true },
secret: { type: 'string', 'x-editor': 'hidden' },
},
additionalProperties: false,
},
// Mirrors data/k6n/schemas/layout.json (annotated with x-style): token enums, overrides, responsive presets.
layout: {
type: 'object',
title: 'Layout',
'x-style': { wrapper: true },
'x-graph': { children: ['heading', 'text', 'button', 'section', 'footer', 'layout'], group: 'Layout', label: 'label' },
$defs: {
breakpoint: {
type: 'object',
properties: {
flow: { 'x-style': { kind: 'flow' }, type: 'string', enum: ['row', 'stack', 'actions'] },
columns: { 'x-style': { kind: 'columns' }, type: 'integer', minimum: 1, maximum: 12 },
},
additionalProperties: false,
},
},
properties: {
label: { type: 'string' },
flow: { 'x-style': { kind: 'flow' }, type: 'string', enum: ['row', 'stack', 'actions', 'vertical', 'horizontal', 'grid'], default: 'row' },
columns: { 'x-style': { kind: 'columns' }, type: 'integer', minimum: 1, maximum: 12, default: 2 },
gap: { 'x-style': { kind: 'gap', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'], default: 'md' },
padding: { 'x-style': { kind: 'spacing', box: 'padding', side: 'all', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] },
paddingTop: { 'x-style': { kind: 'spacing', box: 'padding', side: 'top', overrides: 'padding', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] },
paddingBottom: { 'x-style': { kind: 'spacing', box: 'padding', side: 'bottom', overrides: 'padding', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] },
margin: { 'x-style': { kind: 'spacing', box: 'margin', side: 'all', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] },
marginTop: { 'x-style': { kind: 'spacing', box: 'margin', side: 'top', overrides: 'margin', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] },
marginBottom: { 'x-style': { kind: 'spacing', box: 'margin', side: 'bottom', overrides: 'margin', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] },
align: { 'x-style': { kind: 'align' }, type: 'string', enum: ['start', 'center', 'end', 'stretch'] },
justify: { 'x-style': { kind: 'justify' }, type: 'string', enum: ['start', 'center', 'end', 'between'] },
tone: { 'x-style': { kind: 'tone' }, type: 'string', enum: ['none', 'dark', 'paper', 'light'] },
border: { 'x-style': { kind: 'border' }, type: 'string', enum: ['none', 'thin', 'strong'] },
responsive: {
type: 'object',
title: 'Responsive presets',
properties: { mobile: { $ref: '#/$defs/breakpoint' }, tablet: { $ref: '#/$defs/breakpoint' }, desktop: { $ref: '#/$defs/breakpoint' } },
additionalProperties: false,
},
},
additionalProperties: false,
},
section: {
type: 'object',
title: 'Section',
properties: {
title: { type: 'string', description: 'Shown as the section heading' },
tone: { type: 'string', enum: ['light', 'dark', 'accent'], description: 'Background style' },
padded: { type: 'boolean' },
meta: { type: 'object', properties: { anchor: { type: 'string' }, order: { type: 'integer', minimum: 0 } } },
},
'x-graph': { children: ['heading', 'text', 'button', 'section', 'layout'], group: 'Layout', label: 'title' },
},
heading: {
type: 'object',
title: 'Heading',
'x-graph': { group: 'Content', label: 'text' },
required: ['text'],
properties: { text: { type: 'string' }, level: { type: 'integer', enum: [1, 2, 3] } },
},
text: {
type: 'object',
title: 'Text',
'x-graph': { group: 'Content' },
properties: { body: { type: 'string', format: 'markdown', description: 'Markdown' } },
},
button: {
type: 'object',
title: 'Button',
'x-graph': { group: 'Content', label: 'label' },
required: ['label'],
properties: { label: { type: 'string' }, href: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } } },
},
}
/** The real shape: `{ types: [{ type, schema, staticRoot? }] }` - every type's schema comes with the list. */
export const types = Object.keys(schemas).map((type) => ({ type, schema: schemas[type], ...(type === 'page' ? { staticRoot: true } : {}) }))
export const blueprints = [{ id: 'hero', name: 'Hero (heading + text + button)', description: 'Not supported by the mock server' }]
export function seedGraph(): Graph {
return new Graph({
nodes: [
{ id: 'p1', dataType: 'page', data: { title: 'Mock page', path: '/' } },
{ id: 's1', dataType: 'section', data: { title: 'Welcome', tone: 'dark', padded: true } },
{ id: 'h1', dataType: 'heading', data: { text: 'Edit this headline', level: 1 } },
{ id: 't1', dataType: 'text', data: { body: 'A paragraph with **bold** and *italic* text.\n\n- one\n- two' } },
{ id: 'b1', dataType: 'button', data: { label: 'Get in touch', href: '#contact' } },
{ id: 's2', dataType: 'section', data: { title: 'Services', tone: 'light' } },
{ id: 't2', dataType: 'text', data: { body: 'Second block of copy. Double-click to edit it in place.' } },
{ id: 'l1', dataType: 'layout', data: { flow: 'row', columns: 2, gap: 'md', padding: 'sm', paddingTop: 'lg', margin: 'sm', label: 'Two columns' } },
{ id: 'x1', dataType: 'text', data: { body: 'Left column.' } },
{ id: 'x2', dataType: 'text', data: { body: 'Right column.' } },
{
id: 'f1',
dataType: 'footer',
data: { companyName: 'Mock AB', links: [{ text: 'About', href: 'https://example.com/about' }, { text: 'Careers', href: 'https://example.com/jobs', external: true }], usps: ['Fast', 'Friendly'], social: { github: 'https://github.com/example' }, badges: ['gdpr'], cta: { kind: 'link', href: '/contact' } },
},
],
edges: [
{ id: 'e1', from: 'p1', to: 's1', sort: 0 },
{ id: 'e2', from: 's1', to: 'h1', sort: 0 },
{ id: 'e3', from: 's1', to: 't1', sort: 1 },
{ id: 'e4', from: 's1', to: 'b1', sort: 2 },
{ id: 'e5', from: 'p1', to: 's2', sort: 1 },
{ id: 'e6', from: 's2', to: 't2', sort: 0 },
{ id: 'e8', from: 'p1', to: 'l1', sort: 2 },
{ id: 'e9', from: 'l1', to: 'x1', sort: 0 },
{ id: 'e10', from: 'l1', to: 'x2', sort: 1 },
{ id: 'e7', from: 'p1', to: 'f1', sort: 3 },
],
})
}
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!)
/** A tiny markdown subset (paragraphs, bold, italic, lists) - enough for fixtures. */
export function md(src: string): string {
const inline = (t: string) => esc(t).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>')
return src
.split(/\n{2,}/)
.map((blk) => {
const lines = blk.split('\n')
if (lines.every((l) => /^[-*] /.test(l))) return `<ul>${lines.map((l) => `<li>${inline(l.slice(2))}</li>`).join('')}</ul>`
const h = /^(#{1,3}) (.*)$/.exec(blk)
if (h) return `<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`
return `<p>${inline(blk)}</p>`
})
.join('\n')
}
/** Server-side render of one node + descendants, with the data-cms-* markup contract. */
export function render(g: Graph, id: string): string {
const n: MNode = g.node(id)
const kids = () => g.children(id).map((e) => render(g, e.to)).join('\n')
const attrs = `data-cms="${n.id}" data-cms-type="${n.dataType}"`
switch (n.dataType) {
case 'section':
return `<section class="sec sec--${esc(n.data.tone ?? 'light')}${n.data.padded ? ' sec--padded' : ''}" ${attrs} data-cms-container="true">${
n.data.title ? `<p class="sec__title">${esc(n.data.title)}</p>` : ''
}\n${kids()}\n</section>`
case 'heading': {
const l = n.data.level ?? 2
return `<h${l} ${attrs} data-cms-field="text" data-cms-editor="text">${esc(n.data.text)}</h${l}>`
}
case 'text':
return `<div class="prose" ${attrs} data-cms-field="body" data-cms-editor="markdown">${md(n.data.body ?? '')}</div>`
case 'layout': {
const d = n.data
const flow = ({ vertical: 'stack', horizontal: 'row', grid: 'row' } as Record<string, string>)[d.flow] ?? d.flow ?? 'row'
const sp = [['padding', 'p'], ['paddingTop', 'pt'], ['paddingBottom', 'pb'], ['margin', 'm'], ['marginTop', 'mt'], ['marginBottom', 'mb']]
.filter(([k]) => d[k])
.map(([k, c]) => ` k6n-${c}-${d[k]}`)
.join('')
const r = d.responsive ?? {}
const resp = [['mobile', 'm'], ['tablet', 't'], ['desktop', 'd']]
.flatMap(([bp, c]) => [r[bp]?.flow ? ` k6n-layout--${c}-${r[bp].flow}` : '', r[bp]?.columns ? ` k6n-layout--${c}c-${r[bp].columns}` : ''])
.join('')
const tone = d.tone && d.tone !== 'none' ? ` k6n-${d.tone}` : ''
const cells = g.children(id).map((e) => `<div class="k6n-layout__cell">${render(g, e.to)}</div>`).join('\n')
return `<div class="k6n-layout k6n-layout--${flow} k6n-gap-${d.gap ?? 'md'}${sp}${tone}${resp}" style="--k6n-cols:${d.columns ?? 2}" ${attrs} data-cms-container="true">\n${cells}\n</div>`
}
case 'footer': {
const links = ((n.data.links as any[]) ?? []).map((l) => `<li><a href="${esc(l.href ?? '#')}">${esc(l.text)}</a></li>`).join('')
return `<footer class="foot" ${attrs}><strong>${esc(n.data.companyName)}</strong><ul>${links}</ul></footer>`
}
case 'button':
return `<p ${attrs}><a class="btn" href="${esc(n.data.href ?? '#')}" data-cms-field="label" data-cms-editor="text">${esc(n.data.label)}</a></p>`
default:
return `<div ${attrs}>${kids()}</div>`
}
}
export const pageCss = `
.k6n-gap-none{gap:var(--k6n-sp-none)}.k6n-gap-xs{gap:var(--k6n-sp-xs)}.k6n-gap-sm{gap:var(--k6n-sp-sm)}.k6n-gap-md{gap:var(--k6n-sp-md)}.k6n-gap-lg{gap:var(--k6n-sp-lg)}.k6n-gap-xl{gap:var(--k6n-sp-xl)}.k6n-p-none{padding:var(--k6n-sp-none)}.k6n-p-xs{padding:var(--k6n-sp-xs)}.k6n-p-sm{padding:var(--k6n-sp-sm)}.k6n-p-md{padding:var(--k6n-sp-md)}.k6n-p-lg{padding:var(--k6n-sp-lg)}.k6n-p-xl{padding:var(--k6n-sp-xl)}.k6n-pt-none{padding-top:var(--k6n-sp-none)}.k6n-pt-xs{padding-top:var(--k6n-sp-xs)}.k6n-pt-sm{padding-top:var(--k6n-sp-sm)}.k6n-pt-md{padding-top:var(--k6n-sp-md)}.k6n-pt-lg{padding-top:var(--k6n-sp-lg)}.k6n-pt-xl{padding-top:var(--k6n-sp-xl)}.k6n-pb-none{padding-bottom:var(--k6n-sp-none)}.k6n-pb-xs{padding-bottom:var(--k6n-sp-xs)}.k6n-pb-sm{padding-bottom:var(--k6n-sp-sm)}.k6n-pb-md{padding-bottom:var(--k6n-sp-md)}.k6n-pb-lg{padding-bottom:var(--k6n-sp-lg)}.k6n-pb-xl{padding-bottom:var(--k6n-sp-xl)}.k6n-m-none{margin:var(--k6n-sp-none)}.k6n-m-xs{margin:var(--k6n-sp-xs)}.k6n-m-sm{margin:var(--k6n-sp-sm)}.k6n-m-md{margin:var(--k6n-sp-md)}.k6n-m-lg{margin:var(--k6n-sp-lg)}.k6n-m-xl{margin:var(--k6n-sp-xl)}.k6n-mt-none{margin-top:var(--k6n-sp-none)}.k6n-mt-xs{margin-top:var(--k6n-sp-xs)}.k6n-mt-sm{margin-top:var(--k6n-sp-sm)}.k6n-mt-md{margin-top:var(--k6n-sp-md)}.k6n-mt-lg{margin-top:var(--k6n-sp-lg)}.k6n-mt-xl{margin-top:var(--k6n-sp-xl)}.k6n-mb-none{margin-bottom:var(--k6n-sp-none)}.k6n-mb-xs{margin-bottom:var(--k6n-sp-xs)}.k6n-mb-sm{margin-bottom:var(--k6n-sp-sm)}.k6n-mb-md{margin-bottom:var(--k6n-sp-md)}.k6n-mb-lg{margin-bottom:var(--k6n-sp-lg)}.k6n-mb-xl{margin-bottom:var(--k6n-sp-xl)}
:root{color-scheme:light}body{margin:0;font:16px/1.6 Georgia,serif;background:#f6f3ec;color:#1a1a1a}
main{max-width:760px;margin:0 auto;padding:32px 20px 140px}.sec{padding:24px 28px;margin:16px 0;border-radius:10px}
.sec--dark{background:#15171d;color:#f1efe8}.sec--light{background:#fff}.sec--accent{background:#d2a24c}.sec--padded{padding:48px 28px}
.sec__title{margin:0 0 8px;font:600 12px/1 system-ui;letter-spacing:.12em;text-transform:uppercase;opacity:.6}
h1{font-size:40px;margin:.2em 0}.btn{display:inline-block;background:#d2a24c;color:#111;padding:10px 18px;border-radius:999px;text-decoration:none;font:600 14px system-ui}
.prose p,.prose ul{margin:.6em 0}
:root{--k6n-sp-none:0;--k6n-sp-xs:.5rem;--k6n-sp-sm:clamp(.75rem,1.5vw,1.25rem);--k6n-sp-md:clamp(1.5rem,3vw,3rem);--k6n-sp-lg:clamp(2.5rem,5vw,5rem);--k6n-sp-xl:clamp(4rem,8vw,8rem)}
.k6n-layout{display:grid;grid-template-columns:repeat(var(--k6n-cols,2),minmax(0,1fr));align-items:start}.k6n-layout--stack{grid-template-columns:minmax(0,1fr)}
.k6n-layout--actions{display:flex;flex-wrap:wrap;align-items:center}.k6n-layout > .k6n-layout__cell{min-width:0}
.k6n-layout--t-row{grid-template-columns:repeat(var(--k6n-cols,2),minmax(0,1fr))}.k6n-layout--m-stack{grid-template-columns:minmax(0,1fr)}
.k6n-dark{background:#15171d;color:#f1efe8}.k6n-paper{background:#f6f3ec}.k6n-light{background:#fff}.foot{margin-top:32px;padding:20px;border-top:1px solid #ccc;font:14px system-ui}.foot ul{display:flex;gap:16px;list-style:none;padding:0}
`
export function renderPage(g: Graph, rootId = 'p1'): string {
const body = g.children(rootId).map((e) => render(g, e.to)).join('\n')
const title = esc(g.node(rootId).data.title)
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title><style>${pageCss}</style></head><body><main>\n${body}\n</main></body></html>`
}
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { seedGraph } from './fixtures'
import { HttpError } from './graph'
const kids = (g: ReturnType<typeof seedGraph>, id: string) => g.children(id).map((e) => e.to)
describe('mock graph /api/edit semantics', () => {
it('insert at an index, and its inverse removes it again', () => {
const g = seedGraph()
const r = g.edit([{ op: 'insert', parent: 's1', type: 'text', index: 1, data: { body: 'x' } }])
expect(kids(g, 's1')).toEqual(['h1', r.results[0].node, 't1', 'b1'])
g.edit(r.inverse)
expect(kids(g, 's1')).toEqual(['h1', 't1', 'b1'])
expect(g.nodes.has(r.results[0].node!)).toBe(false)
})
it('set merges, null deletes, inverse restores (including absent keys)', () => {
const g = seedGraph()
const r = g.edit([{ op: 'set', id: 'h1', data: { text: 'New', extra: 1, level: null } }])
expect(g.node('h1').data).toEqual({ text: 'New', extra: 1 })
g.edit(r.inverse)
expect(g.node('h1').data).toEqual({ text: 'Edit this headline', level: 1 })
})
it('rejects a stale version with 409 and lifecycle keys with 400', () => {
const g = seedGraph()
g.edit([{ op: 'set', id: 'h1', data: { text: 'a' } }])
expect(() => g.edit([{ op: 'set', id: 'h1', data: { text: 'b' }, version: 1 }])).toThrow(expect.objectContaining({ status: 409 }))
expect(() => g.edit([{ op: 'set', id: 'h1', data: { status: 'x' } }])).toThrow(HttpError)
})
it('move reorders and re-parents; inverse puts it back', () => {
const g = seedGraph()
const r = g.edit([{ op: 'move', edge: 'e4', parent: 's2', index: 0 }])
expect(kids(g, 's2')).toEqual(['b1', 't2'])
expect(kids(g, 's1')).toEqual(['h1', 't1'])
g.edit(r.inverse)
expect(kids(g, 's1')).toEqual(['h1', 't1', 'b1'])
expect(kids(g, 's2')).toEqual(['t2'])
})
it('refuses cycles', () => {
const g = seedGraph()
g.edit([{ op: 'insert', parent: 's1', type: 'section', id: 'inner' }])
expect(() => g.edit([{ op: 'move', edge: 'e1', parent: 'inner' }])).toThrow(/cycle/)
})
it('delete takes the subtree; the inverse (restore) brings it back verbatim', () => {
const g = seedGraph()
const r = g.edit([{ op: 'delete', edge: 'e1' }])
expect(g.nodes.has('s1')).toBe(false)
expect(g.nodes.has('h1')).toBe(false)
g.edit(r.inverse)
expect(kids(g, 'p1')).toEqual(['s1', 's2', 'l1', 'f1'])
expect(kids(g, 's1')).toEqual(['h1', 't1', 'b1'])
})
it('duplicate deep-copies right after the original', () => {
const g = seedGraph()
const r = g.edit([{ op: 'duplicate', edge: 'e1' }])
const copy = r.results[0].node!
expect(kids(g, 'p1')).toEqual(['s1', copy, 's2', 'l1', 'f1'])
expect(kids(g, copy)).toHaveLength(3)
})
it('a failing op rolls the whole batch back and names the index', () => {
const g = seedGraph()
const before = JSON.stringify([...g.nodes])
expect(() => g.edit([{ op: 'set', id: 'h1', data: { text: 'changed' } }, { op: 'delete', edge: 'nope' }])).toThrow(/op 1/)
expect(JSON.stringify([...g.nodes])).toBe(before)
})
})
+196
View File
@@ -0,0 +1,196 @@
// An in-memory stand-in for the CMS: just enough of /api/edit, /api/nodes and
// /api/schemas to develop and test the editor with no server and no login.
// Semantics follow docs/onpage-editor.md (atomic batches, inverse ops, 409 on a
// stale version, patches = re-rendered subtrees). It is deliberately small - when
// real behaviour differs, fix the mock, then the editor.
export interface MNode { id: string; dataType: string; data: Record<string, any>; version: number; tags: string[] }
export interface MEdge { id: string; from: string; to: string; type: string; handle?: string; sort: number }
export class HttpError extends Error {
constructor(readonly status: number, message: string) {
super(message)
}
}
type Op = Record<string, any>
export class Graph {
nodes = new Map<string, MNode>()
edges = new Map<string, MEdge>()
private seq = 100
constructor(seed?: { nodes: Omit<MNode, 'version' | 'tags'>[]; edges: Omit<MEdge, 'type'>[] }) {
if (seed) {
for (const n of seed.nodes) this.nodes.set(n.id, { version: 1, tags: [], ...n })
for (const e of seed.edges) this.edges.set(e.id, { type: 'child', ...e })
}
}
private id(p: string) {
return `${p}${++this.seq}`
}
node(id: string): MNode {
const n = this.nodes.get(id)
if (!n) throw new HttpError(404, `node ${id} not found`)
return n
}
edge(id: string): MEdge {
const e = this.edges.get(id)
if (!e) throw new HttpError(404, `edge ${id} not found`)
return e
}
children(id: string): MEdge[] {
return [...this.edges.values()].filter((e) => e.from === id && e.type === 'child').sort((a, b) => a.sort - b.sort)
}
parents(id: string): MEdge[] {
return [...this.edges.values()].filter((e) => e.to === id)
}
edgesOf(id: string) {
return { from: this.children(id), into: this.parents(id) }
}
private renumber(parent: string) {
this.children(parent).forEach((e, i) => (e.sort = i))
}
/** Place edge `e` at `index` among its parent's children. */
private place(e: MEdge, index?: number) {
const sibs = this.children(e.from).filter((x) => x.id !== e.id)
const at = index === undefined || index > sibs.length ? sibs.length : Math.max(0, index)
sibs.splice(at, 0, e)
sibs.forEach((x, i) => (x.sort = i))
}
private subtree(edgeId: string): { nodes: MNode[]; edges: MEdge[] } {
const root = this.edge(edgeId)
const nodes: MNode[] = []
const edges: MEdge[] = [root]
const walk = (id: string) => {
nodes.push(this.node(id))
for (const c of this.children(id)) {
edges.push(c)
walk(c.to)
}
}
walk(root.to)
return { nodes, edges }
}
/** Apply a batch atomically. Returns results, inverse (already reversed) and touched ids. */
edit(ops: Op[]) {
if (!Array.isArray(ops) || ops.length < 1 || ops.length > 200) throw new HttpError(400, 'ops must hold 1-200 operations')
const snapshot = structuredClone({ nodes: [...this.nodes], edges: [...this.edges], seq: this.seq })
const results: Array<{ node?: string; edge?: string }> = []
const inverse: Op[] = []
const touched = new Set<string>()
try {
ops.forEach((op, i) => {
try {
const { result, inv, touch } = this.applyOne(op)
results.push(result)
inverse.unshift(...inv)
touch.forEach((t) => touched.add(t))
} catch (e) {
if (e instanceof HttpError) throw new HttpError(e.status, `op ${i} (${op.op}): ${e.message}`)
throw e
}
})
} catch (e) {
this.nodes = new Map(snapshot.nodes)
this.edges = new Map(snapshot.edges)
this.seq = snapshot.seq
throw e
}
return { results, inverse, touched: [...touched] }
}
private applyOne(op: Op): { result: { node?: string; edge?: string }; inv: Op[]; touch: string[] } {
switch (op.op) {
case 'insert': {
this.node(op.parent)
if (!op.type) throw new HttpError(400, 'type required')
const node: MNode = { id: op.id ?? this.id('n'), dataType: op.type, data: { ...(op.data ?? {}) }, version: 1, tags: [] }
this.nodes.set(node.id, node)
const edge: MEdge = { id: this.id('e'), from: op.parent, to: node.id, type: 'child', handle: op.handle, sort: 0 }
this.edges.set(edge.id, edge)
this.place(edge, op.index)
return { result: { node: node.id, edge: edge.id }, inv: [{ op: 'delete', edge: edge.id }], touch: [op.parent] }
}
case 'set': {
const n = this.node(op.id)
if (op.version !== undefined && op.version !== n.version) throw new HttpError(409, `stale version (have ${n.version}, got ${op.version})`)
const undo: Record<string, unknown> = {}
for (const [k, v] of Object.entries<any>(op.data ?? {})) {
if (['lastModified', 'status'].includes(k)) throw new HttpError(400, `lifecycle key ${k} cannot be set`)
undo[k] = k in n.data ? n.data[k] : null
if (v === null) delete n.data[k]
else n.data[k] = v
}
n.version++
return { result: { node: n.id }, inv: [{ op: 'set', id: n.id, data: undo }], touch: [n.id] }
}
case 'move': {
const e = this.edge(op.edge)
const before = { parent: e.from, handle: e.handle, index: this.children(e.from).findIndex((x) => x.id === e.id) }
const to = op.parent ?? e.from
if (this.isDescendant(to, e.to)) throw new HttpError(400, 'move would create a cycle')
const oldParent = e.from
e.from = to
if ('handle' in op) e.handle = op.handle
this.renumber(oldParent)
this.place(e, op.index)
return {
result: { edge: e.id },
inv: [{ op: 'move', edge: e.id, parent: before.parent, handle: before.handle, index: before.index }],
touch: [...new Set([oldParent, to])],
}
}
case 'delete': {
const e = this.edge(op.edge)
const snap = this.subtree(e.id)
for (const x of snap.edges) this.edges.delete(x.id)
// Subtree nodes survive only if still composed elsewhere (shared blocks).
for (const n of snap.nodes) if (this.parents(n.id).length === 0) this.nodes.delete(n.id)
this.renumber(e.from)
return { result: {}, inv: [{ op: 'restore', edge: e.id, nodes: structuredClone(snap.nodes), edges: structuredClone(snap.edges) }], touch: [e.from] }
}
case 'restore': {
for (const n of op.nodes ?? []) this.nodes.set(n.id, structuredClone(n))
for (const e of op.edges ?? []) this.edges.set(e.id, structuredClone(e))
const root = this.edge(op.edge)
this.place(root, root.sort)
return { result: { edge: root.id, node: root.to }, inv: [{ op: 'delete', edge: root.id }], touch: [root.from] }
}
case 'duplicate': {
const e = this.edge(op.edge)
const map = new Map<string, string>()
const clone = (id: string): string => {
const src = this.node(id)
const copy: MNode = { ...structuredClone(src), id: this.id('n'), version: 1 }
this.nodes.set(copy.id, copy)
map.set(id, copy.id)
for (const c of this.children(id)) {
const ce: MEdge = { ...c, id: this.id('e'), from: copy.id, to: clone(c.to) }
this.edges.set(ce.id, ce)
}
return copy.id
}
const nid = clone(e.to)
const ne: MEdge = { ...e, id: this.id('e'), to: nid }
this.edges.set(ne.id, ne)
this.place(ne, e.sort + 1)
return { result: { node: nid, edge: ne.id }, inv: [{ op: 'delete', edge: ne.id }], touch: [e.from] }
}
default:
throw new HttpError(400, `op "${op.op}" is not implemented by the mock`)
}
}
private isDescendant(id: string, ancestor: string): boolean {
if (id === ancestor) return true
return this.children(ancestor).some((c) => this.isDescendant(id, c.to))
}
/** Root = a node nobody composes. */
roots(): MNode[] {
return [...this.nodes.values()].filter((n) => this.parents(n.id).length === 0)
}
}
+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,
}
}