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>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-31 22:34:48 +01:00
commit 1af9ec3172
67 files changed
+12322

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
import { useRef, useState } from 'react'
import type { JSONSchema } from '../api'
import { Icon } from '../ui/Icon'
import { useForm } from './context'
import { JsonArea, Widget } from './Form'
import { defaultFor, resolveSchema, variantsOf } from './resolve'
let uid = 0
const newKey = () => `k${++uid}`
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
/** Short label for a collapsed object card: x-label, else the first useful string. */
function summarise(root: JSONSchema, itemSchema: JSONSchema, item: unknown, index: number): string {
if (typeof item === 'string' || typeof item === 'number') return String(item)
if (item && typeof item === 'object') {
const o = item as Record<string, unknown>
const label = itemSchema['x-label'] ?? itemSchema['x-graph']?.label
const prefer = [label, 'title', 'name', 'label', 'text', 'heading', 'href', 'id'].filter(Boolean) as string[]
for (const k of prefer) if (typeof o[k] === 'string' && o[k]) return String(o[k]).slice(0, 48)
for (const v of Object.values(o)) if (typeof v === 'string' && v) return v.slice(0, 48)
}
return `Item ${index + 1}`
}
export function ArrayField({ schema: s, value, onChange, path }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void; path: string }) {
const { root, errors } = useForm()
const items = Array.isArray(value) ? value : []
const it = resolveSchema(root, Array.isArray(s.items) ? undefined : s.items)
const itemSchema = it.schema
// Tuples / untyped arrays have no per-item form: raw JSON is the honest editor.
if (Array.isArray(s.items) || s.prefixItems) return <JsonArea value={value} onChange={onChange} />
// uniqueItems + enum items: a checkbox set (the natural UI for "pick several").
if (Array.isArray(itemSchema.enum) && s.uniqueItems) {
return (
<div className="cms-checks">
{itemSchema.enum.map((o: unknown) => (
<label key={String(o)} className="cms-inline">
<input
type="checkbox"
className="cms-check"
checked={items.includes(o)}
onChange={(e) => onChange(e.target.checked ? [...items, o] : items.filter((x) => x !== o))}
/>{' '}
{String(o)}
</label>
))}
</div>
)
}
const t = Array.isArray(itemSchema.type) ? itemSchema.type[0] : itemSchema.type
const variants = variantsOf(itemSchema)
const compound = t === 'object' || t === 'array' || (!t && !!itemSchema.properties) || !!variants?.some((v) => resolveSchema(root, v).schema.type === 'object')
return <ArrayList schema={s} itemSchema={itemSchema} items={items} onChange={onChange} path={path} compound={compound} err={errors[path]} />
}
function ArrayList({ schema: s, itemSchema, items, onChange, path, compound }: { schema: JSONSchema; itemSchema: JSONSchema; items: unknown[]; onChange(v: unknown): void; path: string; compound: boolean; err?: string }) {
const { root } = useForm()
// Stable keys survive reorders, so open/closed state and focus stay with the item.
const keys = useRef<string[]>([])
while (keys.current.length < items.length) keys.current.push(newKey())
if (keys.current.length > items.length) keys.current.length = items.length
const [open, setOpen] = useState<Record<string, boolean>>({})
const [dragFrom, setDragFrom] = useState<number | null>(null)
const [dragOver, setDragOver] = useState<number | null>(null)
const min = s.minItems ?? 0
const max = s.maxItems ?? Infinity
const isOpen = (k: string) => open[k] ?? items.length <= 2
const commit = (next: unknown[]) => onChange(next)
const move = (from: number, to: number) => {
if (to < 0 || to >= items.length || from === to) return
const next = [...items]
next.splice(to, 0, ...next.splice(from, 1))
keys.current.splice(to, 0, ...keys.current.splice(from, 1))
commit(next)
}
const remove = (i: number) => {
keys.current.splice(i, 1)
commit(items.filter((_, j) => j !== i))
}
const duplicate = (i: number) => {
const next = [...items]
next.splice(i + 1, 0, structuredClone(items[i]))
keys.current.splice(i + 1, 0, newKey())
commit(next)
}
const add = () => {
if (items.length >= max) return
const k = newKey()
keys.current.push(k)
setOpen((o) => ({ ...o, [k]: true }))
commit([...items, defaultFor(root, itemSchema)])
}
return (
<div className="cms-array" data-count={items.length}>
<ol className="cms-array__list">
{items.map((item, i) => {
const k = keys.current[i]
const expanded = !compound || isOpen(k)
return (
<li
key={k}
className={`cms-item${dragOver === i && dragFrom !== null && dragFrom !== i ? ' is-over' : ''}`}
onDragOver={(e) => {
if (dragFrom === null) return
e.preventDefault()
setDragOver(i)
}}
onDrop={(e) => {
e.preventDefault()
if (dragFrom !== null) move(dragFrom, i)
setDragFrom(null)
setDragOver(null)
}}
>
<div className="cms-item__head">
<span
className="cms-grip"
draggable
title="Drag to reorder"
aria-hidden="true"
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(i))
setDragFrom(i)
}}
onDragEnd={() => (setDragFrom(null), setDragOver(null))}
>
<Icon name="grip" size={18} />
</span>
{compound ? (
<button type="button" className="cms-item__title" aria-expanded={expanded} onClick={() => setOpen((o) => ({ ...o, [k]: !expanded }))}>
<Icon name="chevron" size={16} className={`cms-chev${expanded ? ' is-open' : ''}`} /> {summarise(root, itemSchema, item, i)}
</button>
) : (
<div className="cms-item__value">
<Widget schema={itemSchema} value={item} onChange={(v) => commit(items.map((x, j) => (j === i ? (v === undefined ? defaultFor(root, itemSchema) : v) : x)))} path={`${path}.${i}`} required />
</div>
)}
<span className="cms-item__actions">
<button type="button" className="cms-ico" aria-label={`Move item ${i + 1} up`} title="Move up" disabled={i === 0} onClick={() => move(i, i - 1)}>
<Icon name="up" size={16} />
</button>
<button type="button" className="cms-ico" aria-label={`Move item ${i + 1} down`} title="Move down" disabled={i === items.length - 1} onClick={() => move(i, i + 1)}>
<Icon name="down" size={16} />
</button>
{compound && (
<button type="button" className="cms-ico" aria-label={`Duplicate item ${i + 1}`} title="Duplicate" disabled={items.length >= max} onClick={() => duplicate(i)}>
<Icon name="duplicate" size={16} />
</button>
)}
<button type="button" className="cms-ico cms-ico--danger" aria-label={`Remove item ${i + 1}`} title="Remove" disabled={items.length <= min} onClick={() => remove(i)}>
<Icon name="close" size={16} />
</button>
</span>
</div>
{compound && expanded && (
<div className="cms-item__body">
<Widget schema={itemSchema} value={item} onChange={(v) => commit(items.map((x, j) => (j === i ? (v ?? {}) : x)))} path={`${path}.${i}`} required bare />
</div>
)}
</li>
)
})}
</ol>
<div className="cms-array__foot">
<button type="button" className="cms-btn" aria-label="Add item" onClick={add} disabled={items.length >= max}>
<Icon name="add" size={16} /> Add {itemSchema.title?.toLowerCase() ?? 'item'}
</button>
<span className="cms-muted">
{plural(items.length, 'item')}
{s.minItems ? ` · min ${s.minItems}` : ''}
{s.maxItems ? ` · max ${s.maxItems}` : ''}
</span>
</div>
</div>
)
}
+284
View File
@@ -0,0 +1,284 @@
import { useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { CmsNode, JSONSchema } from '../api'
import type { Controller } from '../controller'
import { fieldEditorFor } from '../registry'
import { labelOf, READONLY_KEYS } from '../schema'
import { Summary } from '../ui/Summary'
import { ArrayField } from './ArrayField'
import { FormContext, useForm } from './context'
import { MapField } from './MapField'
import { resolveSchema, variantsOf } from './resolve'
import { validate } from './validate'
import { VariantField } from './VariantField'
export { validate } from './validate'
export type { Errors } from './validate'
interface FormProps {
schema: JSONSchema
value: Record<string, any>
onChange(v: Record<string, any>): void
node: CmsNode
ctrl: Controller
}
/** The whole properties form. Reports validity through `useFormErrors` callers via `validate()`. */
export function SchemaForm({ schema, value, onChange, node, ctrl }: FormProps) {
const [advanced, setAdvanced] = useState(false)
const errors = useMemo(() => validate(schema, schema, value), [schema, value])
const hasAdvanced = useMemo(() => JSON.stringify(schema).includes('"x-advanced"'), [schema])
return (
<FormContext.Provider value={{ root: schema, node, ctrl, errors, advanced }}>
{hasAdvanced && (
<label className="cms-inline">
<input type="checkbox" className="cms-check" checked={advanced} onChange={(e) => setAdvanced(e.target.checked)} /> Show advanced fields
</label>
)}
<ObjectFields schema={resolveSchema(schema, schema).schema} value={value} onChange={(v) => onChange(v ?? {})} path="" />
</FormContext.Provider>
)
}
// --- containers ----------------------------------------------------------------
const isPlainObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
/** Should a cleared value remove its key (optional fields never round-trip as '' / [] / {})? */
const dropsKey = (v: unknown) => v === undefined || v === '' || (Array.isArray(v) && v.length === 0) || (isPlainObject(v) && Object.keys(v).length === 0)
export function ObjectFields({ schema: s, value, onChange, path }: { schema: JSONSchema; value: Record<string, any>; onChange(v: Record<string, any> | undefined): void; path: string }) {
const { root, advanced } = useForm()
const required = new Set<string>(s.required ?? [])
const entries = Object.entries<JSONSchema>(s.properties ?? {}).filter(([k, raw]) => {
if (READONLY_KEYS.has(k) && path === '') return false
const p = resolveSchema(root, raw).schema
if (p['x-editor'] === 'hidden') return false
if ('const' in p) return false // discriminators are set by the variant picker, not typed
if (!advanced && (p['x-advanced'] || p['x-graph']?.advanced)) return false
return true
})
const extra = Object.keys(value).filter((k) => !(k in (s.properties ?? {})) && !(path === '' && READONLY_KEYS.has(k)))
const set = (k: string, v: unknown) => {
const next = { ...value }
if (!required.has(k) && dropsKey(v)) delete next[k]
else next[k] = v
onChange(next)
}
const at = (k: string) => (path ? `${path}.${k}` : k)
// x-group: collapsible sections, in order of first appearance.
const meta = (s['x-groups'] ?? {}) as Record<string, { title?: string; description?: string; collapsedByDefault?: boolean; advancedOnly?: boolean }>
const sections: Array<{ group: string | null; items: Array<[string, JSONSchema]> }> = []
for (const e of entries) {
const g = resolveSchema(root, e[1]).schema['x-group'] as string | undefined
const existing = g ? sections.find((x) => x.group === g) : undefined
if (existing) existing.items.push(e)
else sections.push({ group: g ?? null, items: [e] })
}
const row = ([k, raw]: [string, JSONSchema]) => (
<FieldRow key={k} name={k} schema={raw} required={required.has(k)} path={at(k)}>
<Widget schema={raw} value={value[k]} onChange={(v) => set(k, v)} path={at(k)} required={required.has(k)} />
</FieldRow>
)
return (
<>
{sections.map((sec) =>
sec.group === null ? (
row(sec.items[0])
) : meta[sec.group]?.advancedOnly && !advanced ? null : (
<details key={sec.group} className="cms-group" open={!(meta[sec.group]?.collapsedByDefault ?? true)}>
<Summary>
{meta[sec.group]?.title ?? labelOf(sec.group, {})} <span className="cms-muted">({sec.items.length})</span>
</Summary>
{meta[sec.group]?.description && <p className="cms-muted">{meta[sec.group]!.description}</p>}
{sec.items.map(row)}
</details>
),
)}
{extra.length > 0 && (
<details className="cms-extra">
<Summary>{extra.length} field(s) not in the schema</Summary>
{extra.map((k) => (
<FieldRow key={k} name={k} schema={{}} path={at(k)} block>
<Widget schema={s.additionalProperties && typeof s.additionalProperties === 'object' ? s.additionalProperties : {}} value={value[k]} onChange={(v) => set(k, v)} path={at(k)} />
</FieldRow>
))}
</details>
)}
</>
)
}
// --- field chrome ------------------------------------------------------------------
/** Containers get a heading `div`; scalars get a wrapping `label` (click-to-focus). */
export function isBlock(root: JSONSchema, raw: JSONSchema): boolean {
const s = resolveSchema(root, raw).schema
if (s['x-editor'] && ['json', 'dictionary'].includes(s['x-editor'])) return true
if (variantsOf(s)) return true
const t = Array.isArray(s.type) ? s.type[0] : s.type
return t === 'array' || t === 'object' || (!t && !!s.properties)
}
export function FieldRow({ name, schema, required, path, children, block }: { name: string; schema: JSONSchema; required?: boolean; path: string; children: ReactNode; block?: boolean }) {
const { root, errors } = useForm()
const { schema: s } = resolveSchema(root, schema)
const err = errors[path]
const Tag = block ?? isBlock(root, schema) ? 'div' : 'label'
return (
<Tag className={`cms-field${err ? ' has-error' : ''}`} data-path={path}>
<span className="cms-field__label">
{s.title ?? labelOf(name, s)}
{required && <b title="required"> *</b>}
</span>
{s.description && <span className="cms-field__hint">{s.description}</span>}
{children}
{err && (
<span className="cms-field__error" role="alert">
{err}
</span>
)}
</Tag>
)
}
// --- widget dispatch -----------------------------------------------------------------
export interface WidgetProps {
schema: JSONSchema
value: unknown
onChange(v: unknown): void
path: string
required?: boolean
/** Object editors drop their own border (used inside array cards). */
bare?: boolean
}
export function Widget({ schema: raw, value, onChange, path, required, bare }: WidgetProps) {
const { root, node, ctrl } = useForm()
const { schema: s } = resolveSchema(root, raw)
const x = s['x-editor'] as string | undefined
if (x === 'hidden') return null
const Custom = fieldEditorFor(s, path, node)?.component
if (Custom) return <Custom schema={s} value={value} onChange={onChange} path={path} node={node} ctrl={ctrl} />
if (s.readOnly || x === 'readonly') return <ReadOnly value={value} />
if (variantsOf(s)) return <VariantField schema={s} value={value} onChange={onChange} path={path} />
if (x === 'json') return <JsonArea value={value} onChange={onChange} />
const t = Array.isArray(s.type) ? s.type.find((y: string) => y !== 'null') : s.type
if (s.enum) return <EnumSelect options={s.enum} value={value} onChange={onChange} required={required} />
if (t === 'array') return <ArrayField schema={s} value={value} onChange={onChange} path={path} />
if (t === 'object' || (!t && (s.properties || s.additionalProperties))) {
if (x === 'dictionary' || s['x-dict-keys'] || !s.properties) return <MapField schema={s} value={value} onChange={onChange} path={path} />
const obj = isPlainObject(value) ? value : {}
const fields = <ObjectFields schema={s} value={obj} onChange={(v) => onChange(v)} path={path} />
return bare ? <div className="cms-object">{fields}</div> : <fieldset className="cms-nested">{fields}</fieldset>
}
if (t === 'boolean') return <input type="checkbox" className="cms-check" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
if (t === 'integer' || t === 'number') return <NumberInput schema={s} value={value} onChange={onChange} />
if (t === 'string') return <StringInput schema={s} value={value} onChange={onChange} />
// Untyped ("any"): decide by what is actually stored.
switch (typeof value) {
case 'string':
return <StringInput schema={s} value={value} onChange={onChange} />
case 'number':
return <NumberInput schema={s} value={value} onChange={onChange} />
case 'boolean':
return <input type="checkbox" className="cms-check" checked={value} onChange={(e) => onChange(e.target.checked)} />
case 'undefined':
return <StringInput schema={s} value={value} onChange={onChange} />
default:
return <JsonArea value={value} onChange={onChange} />
}
}
// --- scalar inputs ----------------------------------------------------------------------
const looksLikeImage = (v: unknown) => typeof v === 'string' && /\.(jpe?g|gif|png|webp|svg|avif)(\?|$)/i.test(v)
export function StringInput({ schema: s, value, onChange }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void }) {
const x = s['x-editor'] as string | undefined
const text = typeof value === 'string' ? value : value == null ? '' : String(value)
if (x && ['textarea', 'markdown', 'monaco', 'html', 'richtext'].includes(x) || (s.maxLength ?? 0) > 200) {
const mono = x === 'monaco' || x === 'html'
return <textarea className={`cms-input cms-input--area${mono ? ' cms-mono' : ''}`} rows={x === 'markdown' || x === 'richtext' ? 8 : 5} value={text} onChange={(e) => onChange(e.target.value)} spellCheck={!mono} />
}
const type = ({ email: 'email', uri: 'url', url: 'url', date: 'date', time: 'time' } as Record<string, string>)[s.format] ?? 'text'
return (
<>
<input className={`cms-input${x === 'node' ? ' cms-mono' : ''}`} type={type} value={text} placeholder={x === 'node' ? 'node id' : s.default ?? s.examples?.[0] ?? ''} onChange={(e) => onChange(e.target.value)} />
{x === 'asset' && looksLikeImage(text) && <img className="cms-thumb" src={text} alt="" />}
</>
)
}
export function NumberInput({ schema: s, value, onChange }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void }) {
return (
<input
className="cms-input"
type="number"
value={typeof value === 'number' ? value : ''}
min={s.minimum}
max={s.maximum}
step={s.type === 'integer' ? 1 : (s.multipleOf ?? 'any')}
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
/>
)
}
export function EnumSelect({ options, value, onChange, required }: { options: unknown[]; value: unknown; onChange(v: unknown): void; required?: boolean }) {
const known = options.some((o) => o === value)
return (
<select
className="cms-input"
value={value === undefined || value === null ? '' : String(value)}
onChange={(e) => onChange(e.target.value === '' ? undefined : (options.find((o) => String(o) === e.target.value) ?? e.target.value))}
>
{!required || value === undefined ? <option value="">—</option> : null}
{!known && value !== undefined && value !== null && <option value={String(value)}>{String(value)} (not in schema)</option>}
{options.map((o) => (
<option key={String(o)} value={String(o)}>
{String(o)}
</option>
))}
</select>
)
}
export function ReadOnly({ value }: { value: unknown }) {
return <pre className="cms-readonly">{typeof value === 'string' ? value : JSON.stringify(value, null, 2) ?? '—'}</pre>
}
/** Free-form JSON. Keeps invalid text locally until it parses; a parse error is shown, never lost. */
export function JsonArea({ value, onChange }: { value: unknown; onChange(v: unknown): void }) {
const [text, setText] = useState(() => (value === undefined ? '' : JSON.stringify(value, null, 2)))
const [bad, setBad] = useState<string | null>(null)
return (
<>
<textarea
className={`cms-input cms-input--area cms-mono${bad ? ' cms-input--bad' : ''}`}
rows={5}
value={text}
spellCheck={false}
onChange={(e) => {
setText(e.target.value)
if (e.target.value.trim() === '') {
setBad(null)
return onChange(undefined)
}
try {
onChange(JSON.parse(e.target.value))
setBad(null)
} catch (err) {
setBad((err as Error).message)
}
}}
/>
{bad && <span className="cms-field__error">Invalid JSON: {bad}</span>}
</>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { useState } from 'react'
import type { JSONSchema } from '../api'
import { Icon } from '../ui/Icon'
import { useForm } from './context'
import { Widget } from './Form'
import { defaultFor, resolveSchema } from './resolve'
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
/**
* Key → value editor: `additionalProperties` schemas, `x-editor: dictionary`, objects without a
* declared shape. With `x-dict-keys` the keys are a fixed vocabulary (one row each, blank removes);
* otherwise keys are free-form (rename, add, remove).
*/
export function MapField({ schema: s, value, onChange, path }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void; path: string }) {
const { root } = useForm()
const obj = isObj(value) ? value : {}
const ap = s.additionalProperties
const valueSchema: JSONSchema = ap && typeof ap === 'object' ? ap : {}
const fixed = Array.isArray(s['x-dict-keys']) ? (s['x-dict-keys'] as string[]) : null
const [draftKey, setDraftKey] = useState('')
const [dupe, setDupe] = useState(false)
const setVal = (k: string, v: unknown) => {
const next = { ...obj }
if (fixed && (v === undefined || v === '')) delete next[k]
else next[k] = v === undefined ? defaultFor(root, valueSchema) : v
onChange(next)
}
if (fixed) {
const rest = Object.keys(obj).filter((k) => !fixed.includes(k))
return (
<div className="cms-map">
{[...fixed, ...rest].map((k) => (
<div key={k} className="cms-map__row">
<span className="cms-map__key">{k}</span>
<div className="cms-map__value">
<Widget schema={valueSchema} value={obj[k]} onChange={(v) => setVal(k, v)} path={`${path}.${k}`} />
</div>
{k in obj && (
<button type="button" className="cms-ico cms-ico--danger" aria-label={`Clear ${k}`} onClick={() => setVal(k, undefined)}>
<Icon name="close" size={16} />
</button>
)}
</div>
))}
</div>
)
}
const rename = (from: string, to: string) => {
to = to.trim()
if (!to || to === from) return false
if (to in obj) return setDupe(true), false
// Rebuild to keep the row's position.
onChange(Object.fromEntries(Object.entries(obj).map(([k, v]) => [k === from ? to : k, v])))
return true
}
const add = () => {
const k = draftKey.trim()
if (!k) return
if (k in obj) return setDupe(true)
onChange({ ...obj, [k]: defaultFor(root, valueSchema) })
setDraftKey('')
setDupe(false)
}
return (
<div className="cms-map">
{Object.entries(obj).map(([k, v]) => (
<div key={k} className="cms-map__row">
<KeyInput name={k} onCommit={(to) => rename(k, to)} />
<div className="cms-map__value">
<Widget schema={valueSchema} value={v} onChange={(nv) => setVal(k, nv)} path={`${path}.${k}`} required />
</div>
<button
type="button"
className="cms-ico cms-ico--danger"
aria-label={`Remove ${k}`}
onClick={() => {
const { [k]: _gone, ...rest } = obj
onChange(rest)
}}
>
<Icon name="close" size={16} />
</button>
</div>
))}
<div className="cms-map__add">
<input
className={`cms-input${dupe ? ' cms-input--bad' : ''}`}
placeholder="new key"
aria-label="New key"
value={draftKey}
onChange={(e) => (setDraftKey(e.target.value), setDupe(false))}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
/>
<button type="button" className="cms-btn" aria-label="Add entry" onClick={add} disabled={!draftKey.trim()}>
<Icon name="add" size={16} /> Add
</button>
</div>
{dupe && <span className="cms-field__error">That key already exists</span>}
</div>
)
}
/** Key text is local until blur/Enter: renaming rebuilds the object, which would otherwise steal focus per keystroke. */
function KeyInput({ name, onCommit }: { name: string; onCommit(to: string): boolean }) {
const [text, setText] = useState(name)
const [seen, setSeen] = useState(name)
if (seen !== name) {
setSeen(name)
setText(name)
}
const commit = () => (onCommit(text) ? undefined : setText(name))
return <input className="cms-input cms-map__key cms-mono" aria-label={`Key ${name}`} value={text} onChange={(e) => setText(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), commit())} />
}
+33
View File
@@ -0,0 +1,33 @@
import type { JSONSchema } from '../api'
import { useForm } from './context'
import { Widget } from './Form'
import { defaultFor, discriminators, pickVariant, resolveSchema, variantLabel, variantsOf } from './resolve'
/** oneOf / anyOf: choose a branch, then edit it with that branch's own form. */
export function VariantField({ schema: s, value, onChange, path }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void; path: string }) {
const { root } = useForm()
const branches = variantsOf(s) ?? []
const current = pickVariant(root, branches, value)
const choose = (i: number) => {
if (i < 0) return onChange(undefined)
const b = branches[i]
const base = defaultFor(root, b)
const consts = discriminators(root, b)
// Keep what the old branch and the new one share, so switching does not wipe the user's input.
const carried = value && typeof value === 'object' && base && typeof base === 'object' ? Object.fromEntries(Object.entries(value as object).filter(([k]) => k in (resolveSchema(root, b).schema.properties ?? {}))) : {}
onChange(base && typeof base === 'object' && !Array.isArray(base) ? { ...base, ...carried, ...consts } : base)
}
return (
<div className="cms-variant">
<select className="cms-input" aria-label="Variant" value={current} onChange={(e) => choose(Number(e.target.value))}>
<option value={-1}>Choose…</option>
{branches.map((b, i) => (
<option key={i} value={i}>
{variantLabel(root, b, i)}
</option>
))}
</select>
{current >= 0 && <Widget schema={{ ...branches[current] }} value={value} onChange={onChange} path={path} required bare />}
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { createContext, useContext } from 'react'
import type { CmsNode, JSONSchema } from '../api'
import type { Controller } from '../controller'
import type { Errors } from './validate'
export interface FormCtx {
root: JSONSchema
node: CmsNode
ctrl: Controller
errors: Errors
advanced: boolean
}
export const FormContext = createContext<FormCtx>(null as never)
export const useForm = () => useContext(FormContext)
+152
View File
@@ -0,0 +1,152 @@
// Schema normalisation for the form: $ref, allOf, nullable unions and
// oneOf/anyOf variants. Everything the widgets need to know about "what is this
// field" is decided here, so widgets stay dumb.
import type { JSONSchema } from '../api'
export interface Resolved {
schema: JSONSchema
/** `type: [x, 'null']` or `anyOf: [x, {type:'null'}]` */
nullable: boolean
}
const isNullBranch = (s: JSONSchema) => s?.type === 'null' || (s?.enum?.length === 1 && s.enum[0] === null)
function lookup(root: JSONSchema, ref: string): JSONSchema | undefined {
if (!ref.startsWith('#')) return undefined // remote refs are not fetched
let t: any = root
for (const part of ref.replace(/^#\/?/, '').split('/').filter(Boolean)) t = t?.[decodeURIComponent(part.replace(/~1/g, '/').replace(/~0/g, '~'))]
return t
}
/** Merge allOf branches into one schema (properties/required are combined, later wins elsewhere). */
function mergeAllOf(root: JSONSchema, s: JSONSchema, depth: number): JSONSchema {
if (!s.allOf) return s
const { allOf, ...rest } = s
let out: JSONSchema = { ...rest }
for (const part of allOf as JSONSchema[]) {
const p = resolveSchema(root, part, depth + 1).schema
out = {
...out,
...p,
properties: { ...(out.properties ?? {}), ...(p.properties ?? {}) },
required: [...new Set([...(out.required ?? []), ...(p.required ?? [])])],
}
if (!Object.keys(out.properties).length) delete out.properties
if (!out.required.length) delete out.required
}
return out
}
export function resolveSchema(root: JSONSchema, s: JSONSchema | undefined | boolean, depth = 0): Resolved {
if (!s || s === true || depth > 12) return { schema: {}, nullable: false }
let cur: JSONSchema = s
let nullable = false
for (let i = 0; i < 12 && typeof cur.$ref === 'string'; i++) {
const { $ref, ...siblings } = cur
cur = { ...(lookup(root, $ref) ?? {}), ...siblings }
}
cur = mergeAllOf(root, cur, depth)
if (Array.isArray(cur.type) && cur.type.includes('null')) {
nullable = true
const rest = cur.type.filter((t: string) => t !== 'null')
cur = { ...cur, type: rest.length === 1 ? rest[0] : rest }
}
for (const key of ['anyOf', 'oneOf'] as const) {
const branches = cur[key] as JSONSchema[] | undefined
if (!branches) continue
const real = branches.filter((b) => !isNullBranch(resolveSchema(root, b, depth + 1).schema))
if (real.length !== branches.length) nullable = true
if (real.length === 1) {
const { [key]: _drop, ...rest } = cur
const inner = resolveSchema(root, { ...rest, ...real[0] }, depth + 1)
return { schema: inner.schema, nullable: nullable || inner.nullable }
}
if (real.length !== branches.length) cur = { ...cur, [key]: real }
}
return { schema: cur, nullable }
}
export const variantsOf = (s: JSONSchema): JSONSchema[] | undefined => (s.oneOf ?? s.anyOf) as JSONSchema[] | undefined
/** Const / single-value-enum properties of an object branch: its discriminator(s). */
export function discriminators(root: JSONSchema, branch: JSONSchema): Record<string, unknown> {
const b = resolveSchema(root, branch).schema
const out: Record<string, unknown> = {}
for (const [k, raw] of Object.entries<JSONSchema>(b.properties ?? {})) {
const p = resolveSchema(root, raw).schema
if ('const' in p) out[k] = p.const
else if (Array.isArray(p.enum) && p.enum.length === 1) out[k] = p.enum[0]
}
return out
}
const typeOf = (v: unknown) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'number' && Number.isInteger(v) ? 'integer' : typeof v)
/** Which branch of a oneOf/anyOf does `value` belong to? -1 when undecidable (e.g. no value). */
export function pickVariant(root: JSONSchema, branches: JSONSchema[], value: unknown): number {
if (value === undefined || value === null) return -1
const scored = branches.map((raw, i) => {
const b = resolveSchema(root, raw).schema
const t = typeOf(value)
const bt = Array.isArray(b.type) ? b.type : b.type ? [b.type] : []
let score = 0
if (bt.length && !(bt.includes(t) || (t === 'integer' && bt.includes('number')))) return { i, score: -1 }
if (bt.length) score += 1
if (t === 'object') {
const d = discriminators(root, b)
for (const [k, v] of Object.entries(d)) score += (value as any)[k] === v ? 10 : -10
for (const k of b.required ?? []) score += k in (value as object) ? 1 : -1
}
if (Array.isArray(b.enum)) score += b.enum.includes(value) ? 5 : -5
if ('const' in b) score += b.const === value ? 5 : -5
return { i, score }
})
const best = scored.sort((a, b) => b.score - a.score)[0]
return best && best.score >= 0 ? best.i : -1
}
export function variantLabel(root: JSONSchema, raw: JSONSchema, i: number): string {
const b = resolveSchema(root, raw).schema
if (b.title) return b.title
const d = Object.values(discriminators(root, b))
if (d.length) return d.map(String).join(' / ')
if ('const' in b) return String(b.const)
return typeof b.type === 'string' ? `${b.type} ${i + 1}` : `Option ${i + 1}`
}
/** A sensible starting value for a new array item / newly chosen variant. */
export function defaultFor(root: JSONSchema, raw: JSONSchema | undefined, depth = 0): unknown {
const { schema: s } = resolveSchema(root, raw)
if (s.default !== undefined) return structuredClone(s.default)
if ('const' in s) return s.const
if (Array.isArray(s.enum)) return s.enum[0]
const variants = variantsOf(s)
if (variants?.length && depth < 6) return defaultFor(root, variants[0], depth + 1)
const t = Array.isArray(s.type) ? s.type[0] : s.type
switch (t) {
case 'string':
return ''
case 'integer':
case 'number':
return s.minimum ?? 0
case 'boolean':
return false
case 'array':
return []
case 'object':
default: {
if (t !== 'object' && !s.properties) return ''
const out: Record<string, unknown> = {}
if (depth < 6) {
for (const k of s.required ?? []) {
const v = defaultFor(root, s.properties?.[k], depth + 1)
if (v !== undefined) out[k] = v
}
for (const [k, p] of Object.entries<JSONSchema>(s.properties ?? {})) {
if (!(k in out) && (resolveSchema(root, p).schema as JSONSchema).const !== undefined) out[k] = defaultFor(root, p, depth + 1)
}
}
return out
}
}
}
+56
View File
@@ -0,0 +1,56 @@
// Client-side validation: early feedback only. The server's schema validation
// stays authoritative (400 on /api/edit), this just stops obvious mistakes and
// powers the inline messages / disabled Save. Keys are form paths (`links.0.text`).
import type { JSONSchema } from '../api'
import { pickVariant, resolveSchema, variantsOf } from './resolve'
export type Errors = Record<string, string>
const empty = (v: unknown) => v === undefined || v === null || v === ''
export function validate(root: JSONSchema, rawSchema: JSONSchema, value: unknown, path = '', errors: Errors = {}, depth = 0): Errors {
if (depth > 20) return errors
const { schema: s } = resolveSchema(root, rawSchema)
if (s['x-editor'] === 'hidden' || s['x-editor'] === 'readonly' || s.readOnly) return errors
const at = (msg: string) => void (errors[path] ??= msg)
if (value === undefined || value === null) return errors
const variants = variantsOf(s)
if (variants) {
const i = pickVariant(root, variants, value)
if (i >= 0) validate(root, variants[i], value, path, errors, depth + 1)
return errors
}
if (typeof value === 'string') {
if (s.minLength !== undefined && value.length < s.minLength && value !== '') at(`At least ${s.minLength} characters`)
if (s.maxLength !== undefined && value.length > s.maxLength) at(`At most ${s.maxLength} characters`)
if (s.pattern) {
try {
if (value !== '' && !new RegExp(s.pattern).test(value)) at(s['x-pattern-message'] ?? `Must match ${s.pattern}`)
} catch {
/* bad pattern in schema: ignore */
}
}
if (s.format === 'email' && value !== '' && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) at('Not a valid email address')
} else if (typeof value === 'number') {
if (s.minimum !== undefined && value < s.minimum) at(`Must be at least ${s.minimum}`)
if (s.maximum !== undefined && value > s.maximum) at(`Must be at most ${s.maximum}`)
if (s.type === 'integer' && !Number.isInteger(value)) at('Must be a whole number')
} else if (Array.isArray(value)) {
if (s.minItems !== undefined && value.length < s.minItems) at(`Add at least ${s.minItems} item${s.minItems === 1 ? '' : 's'}`)
if (s.maxItems !== undefined && value.length > s.maxItems) at(`At most ${s.maxItems} items`)
value.forEach((it, i) => validate(root, s.items ?? {}, it, path ? `${path}.${i}` : String(i), errors, depth + 1))
} else if (typeof value === 'object') {
const obj = value as Record<string, unknown>
for (const k of s.required ?? []) {
const p = resolveSchema(root, s.properties?.[k]).schema
if (empty(obj[k]) && !p.readOnly && p['x-editor'] !== 'hidden') errors[path ? `${path}.${k}` : k] ??= 'Required'
}
for (const [k, p] of Object.entries<JSONSchema>(s.properties ?? {})) validate(root, p, obj[k], path ? `${path}.${k}` : k, errors, depth + 1)
const ap = s.additionalProperties
if (ap && typeof ap === 'object') {
for (const [k, v] of Object.entries(obj)) if (!(k in (s.properties ?? {}))) validate(root, ap, v, path ? `${path}.${k}` : k, errors, depth + 1)
}
}
return errors
}