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

+160
View File
@@ -0,0 +1,160 @@
// Thin client for the CMS HTTP API (docs/onpage-editor.md §3-4). Same origin as
// the page, so cookies flow; a bearer token from the backoffice login is added
// when present. Every call is recorded so the dev panel can show it.
import type { EditResult, Op } from '@k6n/cms-editor'
import { AuthManager } from './auth'
export interface CmsNode {
id: string
dataType: string
data: Record<string, any>
tags?: string[]
version?: number
}
export interface CmsEdge {
id: string
from: string
to: string
type?: string
handle?: string
sort: number
}
export interface NodeEdges {
from: CmsEdge[]
into: CmsEdge[]
}
export interface TypeInfo {
type: string
[k: string]: unknown
}
export interface Blueprint {
id: string
name?: string
description?: string
[k: string]: unknown
}
export type JSONSchema = Record<string, any>
export interface RequestLog {
n: number
method: string
url: string
status: number
ms: number
request?: unknown
response?: unknown
}
export class ApiError extends Error {
constructor(message: string, readonly status: number) {
super(message)
this.name = 'ApiError'
}
}
export interface ApiOptions {
base?: string
fetch?: typeof fetch
/** Auth: tokens, refresh and the sign-in flow. Default: a fresh AuthManager. */
auth?: AuthManager
onLog?: (e: RequestLog) => void
}
export class Api {
private n = 0
private schemas = new Map<string, Promise<JSONSchema>>()
private typesP?: Promise<TypeInfo[]>
private blueprintsP?: Promise<Blueprint[]>
readonly doFetch: typeof fetch
readonly auth: AuthManager
constructor(readonly opts: ApiOptions = {}) {
this.auth = opts.auth ?? new AuthManager({ fetch: opts.fetch })
const raw = opts.fetch ?? ((...a: Parameters<typeof fetch>) => fetch(...a))
// Every request: fresh token in, one transparent refresh+retry on 401, sign-out if that fails too.
this.doFetch = async (input, init) => {
await this.auth.ensureFresh()
const send = () => raw(input, { credentials: 'same-origin', ...init, headers: { ...(init?.headers as Record<string, string>), ...this.auth.headers() } })
let resp = await send()
if (resp.status === 401 && (await this.auth.refresh())) resp = await send()
if (resp.status === 401) this.auth.markSignedOut()
return resp
}
}
headers(): HeadersInit {
return this.auth.headers()
}
async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const t0 = performance.now()
const url = `${this.opts.base ?? ''}${path}`
let status = 0
let json: unknown
try {
const resp = await this.doFetch(url, {
method,
headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
})
status = resp.status
const text = await resp.text()
try {
json = text ? JSON.parse(text) : undefined
} catch {
json = text
}
if (!resp.ok) throw new ApiError((json as any)?.error ?? resp.statusText ?? `HTTP ${status}`, status)
return json as T
} finally {
this.opts.onLog?.({ n: ++this.n, method, url, status, ms: Math.round(performance.now() - t0), request: body, response: json })
}
}
/** A fetch that records into the request log (handed to EditClient so /api/edit shows up too). */
loggedFetch: typeof fetch = async (input, init) => {
const t0 = performance.now()
const resp = await this.doFetch(input, init)
let request: unknown
try {
request = init?.body ? JSON.parse(String(init.body)) : undefined
} catch {
/* not JSON */
}
const text = await resp.clone().text().catch(() => '')
let response: unknown = text
try {
response = JSON.parse(text)
} catch {
/* keep text */
}
this.opts.onLog?.({ n: ++this.n, method: init?.method ?? 'GET', url: String(input), status: resp.status, ms: Math.round(performance.now() - t0), request, response })
return resp
}
get = <T>(path: string) => this.request<T>('GET', path)
post = <T>(path: string, body?: unknown) => this.request<T>('POST', path, body)
me = () => this.get<{ [k: string]: unknown }>('/api/auth/me')
node = (id: string) => this.get<CmsNode>(`/api/nodes/${encodeURIComponent(id)}`)
edges = (id: string) => this.get<NodeEdges>(`/api/nodes/${encodeURIComponent(id)}/edges`)
edit = (ops: Op[]) => this.post<EditResult>('/api/edit', { ops })
publish = (roots: string[]) => this.post('/api/publish-roots', { roots })
/** Schemas are code: cache for the session. */
schema(type: string): Promise<JSONSchema> {
let p = this.schemas.get(type)
if (!p) {
p = this.get<JSONSchema>(`/api/schemas/${encodeURIComponent(type)}`)
p.catch(() => this.schemas.delete(type))
this.schemas.set(type, p)
}
return p
}
types(): Promise<TypeInfo[]> {
return (this.typesP ??= this.get<TypeInfo[] | { types: TypeInfo[] }>('/api/types').then((r) => (Array.isArray(r) ? r : r.types ?? [])))
}
blueprints(): Promise<Blueprint[]> {
return (this.blueprintsP ??= this.get<Blueprint[]>('/api/blueprints').catch(() => []))
}
}