Files
cms-onpageeditor/mock/fixtures.ts
T
mats 33bd5269fd Single panel: outline tree with slots, node search with type filter
The outline and blocks are no longer tabs: one panel shows the
outline tree, the new-block palette and a search over existing
nodes. Outline rows drag to reorder (same snap engine, explicit
gap targets), palette blocks and searched nodes drop onto the
outline gaps as well as the page.

Named slots (x-graph.connections, e.g. a pdp's info-section /
3d-accessories / model) render as groups under their block with
allowed types, live counts and a slot-limited insert picker.
Drops that fit no plain-children rule resolve to the first
accepting slot; existing nodes link via graft tree.link.

Adds Api.searchNodes, schema connections helpers, handle-aware
controller ops (linkExisting, insertIntoSlot, dropExisting) and
mock support for /api/nodes listing and graft links.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-11-06 20:38:31 +01:00

253 lines
17 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',
connections: { hero: { to: ['section', 'heading'], kind: 'single', description: 'Hero slot' } },
},
},
// 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 },
gap: { 'x-style': { kind: 'gap', 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'] },
},
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',
connections: { sidebar: { to: ['text', 'button', 'footer'], kind: 'multi', description: 'Side content' } },
},
},
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: 'orphan1', dataType: 'text', data: { body: 'Orphaned block about sparrows' } },
{ id: 'orphan2', dataType: 'button', data: { label: 'Buy sparrow seed', href: '#seed' } },
{
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 ?? {}
// Mirrors data/k6n/templates/layout.gohtml: tablet flow class only when it differs; spacing as k6n-<t|m>-<prop>-<size>.
const norm = (f: string) => ({ vertical: 'stack', horizontal: 'row', grid: 'row' } as Record<string, string>)[f] ?? f
const tf = r.tablet?.flow ? norm(r.tablet.flow) : flow
const resp = (tf !== flow ? ` k6n-layout--t-${tf}` : '') +
[['tablet', 't'], ['mobile', 'm']]
.flatMap(([bp, c]) => [['gap', 'gap'], ['padding', 'p'], ['paddingTop', 'pt'], ['paddingBottom', 'pb']].filter(([k]) => r[bp]?.[k]).map(([k, n]) => ` k6n-${c}-${n}-${r[bp][k]}`))
.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>`
}