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:
commit
1af9ec3172
67 files changed
+12322
No files matched your search
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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>`
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user