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 = { 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, '$1').replace(/\*(.+?)\*/g, '$1') return src .split(/\n{2,}/) .map((blk) => { const lines = blk.split('\n') if (lines.every((l) => /^[-*] /.test(l))) return `` const h = /^(#{1,3}) (.*)$/.exec(blk) if (h) return `${inline(h[2])}` return `

${inline(blk)}

` }) .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 `
${ n.data.title ? `

${esc(n.data.title)}

` : '' }\n${kids()}\n
` case 'heading': { const l = n.data.level ?? 2 return `${esc(n.data.text)}` } case 'text': return `
${md(n.data.body ?? '')}
` case 'layout': { const d = n.data const flow = ({ vertical: 'stack', horizontal: 'row', grid: 'row' } as Record)[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---. const norm = (f: string) => ({ vertical: 'stack', horizontal: 'row', grid: 'row' } as Record)[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) => `
${render(g, e.to)}
`).join('\n') return `
\n${cells}\n
` } case 'footer': { const links = ((n.data.links as any[]) ?? []).map((l) => `
  • ${esc(l.text)}
  • `).join('') return `
    ${esc(n.data.companyName)}
      ${links}
    ` } case 'button': return `

    ${esc(n.data.label)}

    ` default: return `
    ${kids()}
    ` } } 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 `${title}
    \n${body}\n
    ` }