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>
253 lines
17 KiB
TypeScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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>`
|
|
}
|