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:
commit
1af9ec3172
67 files changed
+12322
No files matched your search
+160
@@ -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(() => []))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user