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>
234 lines
16 KiB
TypeScript
234 lines
16 KiB
TypeScript
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>`
|
|
}
|