Files
matsandClaude Sonnet 5.5 1af9ec3172 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>
2026-10-31 22:34:48 +01:00

175 lines
10 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { collectStrings, getIn, inferBindings, markInferred, parsePath, patchForPath } from '../src/page/infer'
import { acceptsChild, allowedChildren, typeName } from '../src/schema'
import { schemas } from '../mock/fixtures'
import { dropTarget } from '../src/page/dnd'
const dom = (html: string) => {
document.body.innerHTML = `<section data-cms="b">${html}</section>`
const block = document.querySelector<HTMLElement>('[data-cms="b"]')!
return { block, owns: (el: Element) => el.closest('[data-cms]') === block }
}
describe('collectStrings', () => {
it('walks nested arrays and objects with paths, skipping lifecycle, enums, markdown and read-only', () => {
const schema = { type: 'object', properties: { title: { type: 'string' }, kind: { type: 'string', enum: ['a', 'b'] }, body: { type: 'string', 'x-editor': 'markdown' }, ro: { type: 'string', readOnly: true }, items: { type: 'array', items: { type: 'object', properties: { text: { type: 'string' } } } } } }
const data = { title: 'Hi', kind: 'a', body: '# x', ro: 'r', status: 'published', items: [{ text: 'One' }, { text: 'Two' }], blank: ' ' }
expect(collectStrings(data, schema).map((s) => [s.path.join('.'), s.value])).toEqual([['title', 'Hi'], ['items.0.text', 'One'], ['items.1.text', 'Two']])
})
it('works without a schema (everything is a candidate)', () => {
expect(collectStrings({ a: { b: ['x'] } }).map((s) => s.path.join('.'))).toEqual(['a.b.0'])
})
})
describe('inferBindings', () => {
it('binds a unique leaf text to its property, nested paths included', () => {
const { block, owns } = dom('<h2>Hello</h2><ul><li><a href="/a">About</a></li><li><a href="/b">Careers</a></li></ul>')
const r = inferBindings(block, owns, collectStrings({ title: 'Hello', links: [{ text: 'About', href: '/a' }, { text: 'Careers', href: '/b' }] }))
expect(r.text.map((t) => t.path)).toEqual(['title', 'links.0.text', 'links.1.text'])
expect(r.attrs.map((a) => [a.path, a.attr])).toEqual([['links.0.href', 'href'], ['links.1.href', 'href']])
})
it('skips ambiguity instead of guessing: same text twice, or one value on two properties', () => {
const twice = dom('<p>Same</p><p>Same</p>')
expect(inferBindings(twice.block, twice.owns, collectStrings({ a: 'Same' })).text).toEqual([])
const shared = dom('<p>Both</p>')
expect(inferBindings(shared.block, shared.owns, collectStrings({ a: 'Both', b: 'Both' })).text).toEqual([])
})
it('only binds text leaves; whitespace differences do not matter; nested blocks bind their own data', () => {
const { block, owns } = dom('<div><span>Mixed</span> text</div><p> Spaced \n out </p><section data-cms="child"><p>Child text</p></section>')
const r = inferBindings(block, owns, collectStrings({ x: 'Mixed text', y: 'Spaced out', z: 'Child text' }))
expect(r.text.map((t) => t.path)).toEqual(['y']) // x is not a leaf; z belongs to the nested block
})
it('leaves elements with an explicit data-cms-field alone', () => {
const { block, owns } = dom('<h1 data-cms-field="heading" data-cms-editor="text">Title</h1>')
expect(inferBindings(block, owns, collectStrings({ heading: 'Title' })).text).toEqual([])
})
it('is idempotent: re-inferring a block that already carries inferred markers keeps the bindings', () => {
const { block, owns } = dom('<h2>Hello</h2>')
const strings = collectStrings({ a: 'Hello' })
markInferred(block, inferBindings(block, owns, strings).text)
const again = inferBindings(block, owns, strings)
expect(again.text.map((t) => t.path)).toEqual(['a'])
})
it('markInferred tags leaves for the normal inline editor, and re-marking cleans up first', () => {
const { block, owns } = dom('<h2>Hello</h2><p>World</p>')
markInferred(block, inferBindings(block, owns, collectStrings({ a: 'Hello', b: 'World' })).text)
expect(block.querySelector('h2')!.getAttribute('data-cms-field')).toBe('a')
expect(block.querySelector('h2')!.getAttribute('data-cms-editor')).toBe('text')
markInferred(block, inferBindings(block, owns, collectStrings({ b: 'World' })).text)
expect(block.querySelector('h2')!.hasAttribute('data-cms-field')).toBe(false)
expect(block.querySelector('p')!.getAttribute('data-cms-field')).toBe('b')
})
})
describe('paths', () => {
it('parse / get / patchForPath (top-level key rebuilt, input untouched)', () => {
expect(parsePath('links.0.text')).toEqual(['links', 0, 'text'])
const data = { title: 't', links: [{ text: 'A', href: '/a' }, { text: 'B' }] }
expect(getIn(data, parsePath('links.1.text'))).toBe('B')
expect(patchForPath(data, ['title'], 'x')).toEqual({ title: 'x' })
expect(patchForPath(data, parsePath('links.0.text'), 'Z')).toEqual({ links: [{ text: 'Z', href: '/a' }, { text: 'B' }] })
expect(data.links[0].text).toBe('A')
expect(patchForPath({}, parsePath('a.b.c'), 'v')).toEqual({ a: { b: { c: 'v' } } })
expect(patchForPath({}, parsePath('a.0'), 'v')).toEqual({ a: ['v'] })
})
})
describe('what a container accepts', () => {
it('x-graph.children: true means anything (the shape the real schemas use); lists and * work too', () => {
expect(allowedChildren({ 'x-graph': { children: true } }, ['a', 'b'])).toEqual(['a', 'b'])
expect(allowedChildren({ 'x-graph': { children: ['a'] } }, ['a', 'b'])).toEqual(['a'])
expect(allowedChildren({ 'x-graph': { children: ['*'] } }, ['a', 'b'])).toEqual(['a', 'b'])
expect(allowedChildren({ 'x-graph': {} }, ['a'])).toEqual([])
expect(allowedChildren(undefined, ['a'])).toEqual([])
})
it('acceptsChild is optimistic for unknown schemas, strict for known ones', () => {
expect(acceptsChild(undefined, 'x', [])).toBe(true)
expect(acceptsChild(schemas.section, 'heading', [])).toBe(true)
expect(acceptsChild(schemas.section, 'page', [])).toBe(false)
expect(acceptsChild(schemas.heading, 'text', [])).toBe(false) // a leaf type
})
it('typeName tolerates the shapes /api/types might use', () => {
expect([typeName('a'), typeName({ type: 'b' }), typeName({ name: 'c' }), typeName({ dataType: 'd' }), typeName(null)]).toEqual(['a', 'b', 'c', 'd', ''])
})
it('dropTarget refuses containers that do not accept, falling back to an ancestor that does', () => {
const r = (y: number, h = 100) => ({ x: 0, y, w: 600, h })
const blocks = [
{ id: 'outer', container: true, depth: 0, rect: r(0, 400) },
{ id: 'inner', parentId: 'outer', container: true, depth: 1, rect: r(50, 200) },
]
expect(dropTarget(blocks, { x: 10, y: 100 }, null)!.parentId).toBe('inner')
expect(dropTarget(blocks, { x: 10, y: 100 }, null, (p) => p !== 'inner')!.parentId).toBe('outer')
expect(dropTarget(blocks, { x: 10, y: 100 }, null, () => false)!.parentId).toBe('') // page level
expect(dropTarget(blocks, { x: 900, y: 100 }, null, () => false)).toBeNull() // beside the page: over an editor panel
})
})
import { moveTarget, rootExtent, sortTarget } from '../src/page/dnd'
describe('sort snapping between siblings', () => {
const r = (y: number, h: number, x = 0, w = 600) => ({ x, y, w, h })
// four root blocks (A, B, C are containers - the case that used to nest instead of sort), with children
const blocks = [
{ id: 'A', container: true, depth: 0, rect: r(0, 300) },
{ id: 'a1', parentId: 'A', container: false, depth: 1, rect: r(10, 100, 10, 580) },
{ id: 'B', container: true, depth: 0, rect: r(320, 100) },
{ id: 'C', container: true, depth: 0, rect: r(440, 100) },
{ id: 'D', container: false, depth: 0, rect: r(560, 50) },
]
it('snaps to the nearest boundary between root nodes, centred in the gap', () => {
const t = sortTarget(blocks, { x: 50, y: 500 }, 'B', '')! // pointer is deep inside container C
// siblings without B: A(0-300) C(440-540) D(560-610) -> boundaries 0, 370, 550, 610
expect(t.snaps!.map((s) => s.y + 1.5)).toEqual([0, 370, 550, 610])
expect(t).toMatchObject({ parentId: '', index: 2 }) // nearest is 550 (between C and D)
expect(t.line.y + 1.5).toBe(550)
expect(t.line.w).toBe(600)
})
it('snaps by distance to boundaries, not by sibling midpoints (stable near the edges)', () => {
expect(sortTarget(blocks, { x: 50, y: -40 }, 'B', '')!.index).toBe(0)
expect(sortTarget(blocks, { x: 50, y: 5000 }, 'B', '')!.index).toBe(3)
expect(sortTarget(blocks, { x: 50, y: 372 }, 'B', '')!.index).toBe(1)
})
it('excludes the dragged block, and a row of siblings sorts left to right', () => {
const row = [
{ id: 'p', container: true, depth: 0, rect: r(0, 100, 0, 600) },
{ id: 'c1', parentId: 'p', container: false, depth: 1, rect: r(0, 100, 0, 150) },
{ id: 'c2', parentId: 'p', container: false, depth: 1, rect: r(0, 100, 200, 150) },
{ id: 'c3', parentId: 'p', container: false, depth: 1, rect: r(0, 100, 400, 150) },
]
const t = sortTarget(row, { x: 380, y: 50 }, 'c1', 'p')! // siblings: c2, c3
expect(t.snaps).toHaveLength(3)
expect(t.line.h).toBe(100) // a vertical line
expect(t.line.w).toBe(3)
expect(t).toMatchObject({ parentId: 'p', index: 1 }) // between c2 and c3
expect(t.line.x + 1.5).toBe(375) // centred in the 50px gap (350..400)
})
it('moveTarget: dragging a root block sorts by default and nests only with Alt', () => {
const sort = moveTarget(blocks, { x: 50, y: 500 }, { id: 'B' })!
expect(sort).toMatchObject({ parentId: '', index: 2 })
expect(sort.snaps).toBeDefined()
const nest = moveTarget(blocks, { x: 50, y: 500 }, { id: 'B' }, { nest: true })!
expect(nest.parentId).toBe('C') // the container under the pointer
expect(nest.snaps).toBeUndefined()
})
it('moveTarget: a nested block sorts inside its parent, and leaving the parent switches to general drops', () => {
const withSibs = [...blocks, { id: 'a2', parentId: 'A', container: false, depth: 1, rect: r(120, 100, 10, 580) }]
expect(moveTarget(withSibs, { x: 50, y: 250 }, { id: 'a1', parentId: 'A' })).toMatchObject({ parentId: 'A', index: 1 })
expect(moveTarget(withSibs, { x: 50, y: 380 }, { id: 'a1', parentId: 'A' })!.parentId).toBe('B') // left A: dropped into B
})
it('beside the page (over editor chrome) a root drag has no target', () => {
expect(rootExtent(blocks)).toMatchObject({ x: -24, w: 648 })
expect(moveTarget(blocks, { x: 900, y: 100 }, { id: 'B' })).toBeNull()
})
})