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

+18
View File
@@ -0,0 +1,18 @@
# Which CMS the dev server talks to:
# live (default) - proxy everything to CMS_TARGET (page HTML is fetched server-side and the editor is injected)
# mock - fully offline: in-memory CMS + fixture page (same as `npm run dev:mock`)
CMS_MODE=live
CMS_TARGET=https://k6n.net
# Auth for the dev proxy. The proxy adds these to every upstream request, so the
# secret never reaches the browser or the bundle. Use ONE of:
# CMS_TOKEN - an OIDC/device-flow JWT -> Authorization: Bearer <token>
# CMS_API_KEY - a minted key with write scope -> X-API-Key: <key>
# CMS_COOKIE - the admin session cookie string -> Cookie: <value>
# Easiest: log in to the backoffice, copy `accessToken` from localStorage['auth.tokens'].
CMS_TOKEN=
CMS_API_KEY=
CMS_COOKIE=
# Location of the shared @k6n/cms-editor package sources (EditClient, morph, markdown editor).
CMS_EDITOR_SRC=../ecommerce/backoffice/web/packages/cms-editor/src
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
.env.local
+158
View File
@@ -0,0 +1,158 @@
# cms-onpageeditor
Injectable on-page editor for the go-graph-cms (`docs/onpage-editor.md` in that repo).
It is side-loaded onto the statically rendered page, mounts its UI in a shadow root, and
edits **only through the HTTP API** (`POST /api/edit`, with undo/redo from server inverses).
The page DOM stays server HTML: edits come back as `patches` and are morphed in.
Reused from `@k6n/cms-editor` (aliased to the sources in the ecommerce repo, see `CMS_EDITOR_SRC`):
`EditClient`, `applyPatches`/`morphChildren`, and the Tiptap `mountMarkdownEditor`.
## Develop
```sh
npm install
npm run dev:mock # offline: in-memory CMS + fixture page, no login -> http://localhost:5173
cp .env.example .env # then set CMS_TOKEN (or CMS_API_KEY / CMS_COOKIE)
npm run dev # live: proxies https://k6n.net, injects the editor from source (HMR)
npm test # editor + mock CMS in jsdom
npm run build # dist/onpage-editor.js (what the site serves at /editor/onpage-editor.js)
```
**Live mode.** The proxy fetches page HTML server-side, strips the CSP, injects
`/src/dev.ts`, and forwards cookies. `dev.ts` sets `cms_preview=1`, so the edge renders
`/api/preview` - the page you see is the live store, not the published file. The dev server
must run on **port 5173** (`strictPort`): go-auth's CORS allowlist names `localhost:5173`.
## Signing in
The editor has its own login (`src/auth.ts`, UI in `src/ui/LoginPanel.tsx`) using go-auth's
RFC 8628 device grant, as described in go-auth's `/dev-auth.md`:
1. The page loads as a normal visitor page; the editor probes `GET /api/auth/me`, gets 401 and shows **Sign in**.
2. *Sign in* starts a grant (`POST {issuer}/api/auth/device`), opens the approval page and shows the user code.
3. You approve on the issuer (admin-gated); the editor polls (honouring `interval` / `slow_down`), stores the tokens,
re-fetches the page in preview mode and unlocks editing.
4. Access tokens (~15 min) are renewed with the rotating refresh token; a 401 triggers one refresh + retry, and a
failed refresh returns to the login panel. *Sign out* (⎋) clears everything.
The issuer and client come from `GET /api/auth/config`. The grant is run **as that client** (`k6n-admin` on k6n.net): the CMS verifies
`aud` against its `OIDC_CLIENT_ID` (`cms.go: auth.NewOIDC(issuer, clientID)`), while go-auth's clientless fallback (`dev-console`) mints
`aud=https://otel.k6n.net` and gets `401 oidc: wrong audience`. Override with `__CMS_EDITOR_CONFIG__.clientId` / `.audience`. A rejected token's
reason is shown in the login panel. Storage: in **dev** the refresh token is kept in `localStorage`;
in **production builds** it is only held in `sessionStorage` (go-auth's rule: never persist it there). An already
valid backoffice login (`auth.tokens`) is picked up too. Fallbacks: *Use an access token instead* in the panel,
or `window.__CMS_EDITOR_CONFIG__ = { token | apiKey | issuer }`.
In dev the access token is also POSTed to the dev server (`/__dev/token`) so its server-side page fetches are
authenticated as you. To skip the browser login entirely, put `CMS_TOKEN` / `CMS_API_KEY` / `CMS_COOKIE` in `.env`
(they win), e.g. `CMS_TOKEN=$(node ../ecommerce/go-auth/scripts/dev-token.mjs token)`.
`CMS_MOCK_AUTH=1 npm run dev:mock` makes the mock require the same flow (approve on the mock `/device` page), so
the login UI can be developed offline.
**Mock mode.** `mock/` is a small in-memory implementation of `/api/edit` (insert, set, move,
delete, restore, duplicate; inverse ops; 409 on stale `version`; atomic rollback),
`/api/nodes`, `/api/schemas`, `/api/types`. Fixture schemas/templates live in
`mock/fixtures.ts` - add a node type there to try a new editor. `POST /__mock/reset` resets it.
When the real server behaves differently, fix the mock first, then the editor.
**Dev panel** (`` ` `` or the gear): every request with bodies/timings, the selected node + schema,
and everything registered. `window.__cmsEditor` exposes `ctrl`, the registries and `destroy()`.
## Adding editors (the extension points, `src/registry.ts`)
```ts
// A dialog for a node type (shows up as a toolbar button; same id replaces a built-in)
registerEditor({ id: 'hero', label: 'Hero', icon: '★', match: 'hero', component: ({ node, schema, ctrl, close }) => ... })
// Ad-hoc: attach to any block by selector / node id / element
attachDialog('.pricing-table', MyPricingDialog, { label: 'Prices' })
// A form widget for matching schema fields (x-editor, format, path...)
registerFieldEditor({ id: 'colour', match: (s) => s['x-editor'] === 'colour', component: ColourField })
// Inline editing for a `data-cms-editor="<id>"` field (built in: text, markdown)
registerInlineEditor({ id: 'richtext', start({ el, node, field, ctrl }) { ...; return { stop() {} } } })
```
Components get the `Controller`: `ctrl.setData / insert / remove / duplicate / nudge / apply(ops)`,
`ctrl.openDialog(...)`, `ctrl.node(id)`, `ctrl.schema(type)`.
## The form (`src/form/`)
Covers what the CMS schemas use: nested objects, `$ref`/`$defs`, `allOf`, nullable unions, `oneOf`/`anyOf` variant pickers
(discriminator `const`s are set for you), **arrays** (primitive rows; object cards with a summary title, drag/up/down reorder,
duplicate, remove, `minItems`/`maxItems`; `uniqueItems`+`enum` as checkboxes), **maps** (`additionalProperties`, free keys with
rename/add/remove, or a fixed vocabulary via `x-dict-keys` / `x-editor: dictionary`), `x-group`/`x-groups` sections,
`x-advanced` toggle, and the `x-editor` hints `hidden`, `readonly`, `json`, `markdown`/`textarea`/`monaco`/`html`/`richtext`,
`asset` (thumbnail), `node`. Unknown `x-editor` values fall back to the type's normal widget; tuples and unmodelled shapes get
a JSON box that keeps invalid text instead of losing it. Optional fields that are cleared (`''`, `[]`, `{}`) remove their key.
Client-side validation (required, lengths, pattern, bounds, item counts, email) marks fields inline and disables Save; the
server's schema validation stays authoritative. Not covered: `if/then/else`, `patternProperties`, `dependentSchemas`
(they show up under "not in the schema" / JSON). Specialised backoffice editors (finder-item, tracking-event, layout-settings,
style-class, sizes) are not ported - register one with `registerFieldEditor` where needed.
## Seeing the latest content (preview)
The preview cookie makes the *edge* route page URLs to `/api/preview`, but a plain navigation can't carry the editor's bearer
token, so the edge may still serve the published file. The editor therefore fetches the live render itself and morphs it into
the page (no reload; scroll, focus and open dialogs stay): on load when signed in, after signing in, and whenever the tab becomes
visible again (throttled to 5 s). Source order: `GET /api/preview?path=<path>`, then the same with `&locale=<html lang>`, then
the page URL with `X-CMS-Preview: 1`. Visitors who are not signed in are never touched.
## Editing on the page
- **Every string property is editable where it is rendered.** Templates only mark a few fields (`data-cms-field` +
`data-cms-editor`); for the rest, selecting a block binds its data to its DOM *by value* (`src/page/infer.ts`): a property
whose text is exactly one leaf element becomes an inline text field (nested arrays/objects included: `links.0.text`), and one
whose value is exactly one attribute (`href`, `src`, `alt`, `title`…) gets a chip that edits it. Click a block to select it,
click a field again to edit. Ambiguous values (same text twice, one value on two properties) are skipped rather than guessed, and
enums, markdown/html fields and read-only fields are never bound as text - those stay in Properties (or need a template marker,
e.g. `data-cms-editor="markdown"` for the WYSIWYG editor).
- **Block palette** (dock ▸ Blocks): every type that fits somewhere on the page, plus blueprints. Drag onto the page - a ghost
"virtual element" and a drop line show where it lands, and containers whose `x-graph.children` does not accept the type refuse it -
or click to add to the selection. Existing blocks drag the same way from the toolbar grip, and **sort by snapping between their siblings** (root nodes included): faint markers show every position, the line snaps to the nearest, and the block is only nested into a container under the pointer while **Alt** is held.
- **Insertion slots**: a selected or hovered container shows "+" between its children (and a big "Add block" when empty).
- **One dialog per thing**: opening Properties/Style/an attribute editor/an insert picker that is already open raises it, pulses it and focuses its first field (unsaved edits are kept) instead of opening a duplicate; clicking a dialog brings it forward and Escape closes the topmost. Custom dialogs opt in with `ctrl.openDialog({ id, ... })`.
- The toolbar keeps the everyday actions; **JSON** and other developer tools live under **…** (`placement: 'menu'` on a
`registerEditor`). Icons are inline SVG (Material Design Icons via `@mdi/js`, tree-shaken, no font/CSP issues); `registerTypeIcon`
gives a node type its own.
## Titles and groups (`x-graph`)
A schema's `x-graph: { "group": "Commerce", "label": "title" }` drives the UI: **`label`** names the property whose value is the
node's title, shown in the outline, hover tag, toolbar chip, dialog titles and drag chip (types without a label fall back to the
type name); **`group`** categorises the type in the block palette and the insert picker (groups are collapsible; types without one
land in "Other") and is shown next to the type on hover. The block list shows each type's schema `title` with the type id underneath, and skips page-root (`staticRoot`) types. One `GET /api/types` request (which carries every type's schema) supplies titles, groups, labels and acceptance rules for the whole list. Node data for the titles arrives in one `GET /api/tree/{root}` request,
stays live as you edit, and is fetched lazily for new nodes.
## Styling (spacing, flow, gap)
Schema-driven via the `x-style` annotation, with on-page drag handles, a Style dialog and wrap-in-layout. Design and the
schema contract: [docs/styling.md](docs/styling.md).
## How it behaves
- Drag the ⠿ grip on the selected block to reorder / re-parent (green line = drop position); deleting shows an Undo toast.
- Click selects (links don't navigate in *Editing*; use *Browsing* to follow them); double-click a
field with `data-cms-editor` edits in place (`text`: contenteditable, `markdown`: Tiptap), otherwise
opens Properties. Typing is debounced and coalesced into one undo step.
- Properties = schema-driven form (`src/form/`), saves only the diff with the node `version` (409 -> conflict toast).
- Insert menu = `x-graph.children` of the parent schema + blueprints.
- `data-cms-edge` isn't emitted by the templates yet, so the edge is resolved via
`GET /api/nodes/{id}/edges` (and used automatically once the attribute appears).
- Patches with `error` (e.g. the page node itself) trigger a soft re-fetch + morph of the page.
## Deploying
Serve `dist/onpage-editor.js` at `/editor/onpage-editor.js` and let the existing loader do
`if (document.cookie.includes('cms_preview=1')) import('/editor/onpage-editor.js')`. The bundle
re-checks the cookie, so loading it for a visitor is a no-op.
## Not done / next
- Real-browser e2e (Playwright against `dev:mock`) - only jsdom is covered so far; CSS/shadow-DOM look and Tiptap have not been checked in a real browser.
- Drag reorder is vertical-flow only, and assumes `move`'s `index` counts siblings excluding the moved block (as the mock does) - verify against the real server.
- Bundle is ~295 kB gzipped, mostly Tiptap; split the markdown editor into a lazy chunk if that matters.
- The mock doesn't implement `graft`/`blueprint`; the picker lists blueprints but they only work on the real server.
+72
View File
@@ -0,0 +1,72 @@
# Styling on the page: flow, padding, margin, gap
## Decision
**Keep the typed token object on the node; tag its fields with `x-style`; let on-page editors key off the tag.**
Not a free-form `style` object on every node, and not per-node CSS: the k6n design system (`data/k6n/design.md`) already says
*blocks own no outer margin, spacing comes from the parent layout, spacing values are tokens (`none xs sm md lg xl`) and never raw
lengths*. The `layout` node is that primitive: `flow`, `columns`, `gap`, `padding(Top|Bottom)`, `margin(Top|Bottom)`, `align`,
`justify`, `tone`, `border`, `reveal`, and `responsive.{mobile,tablet,desktop}`. The template turns those into `.k6n-gap-md` /
`.k6n-p-lg` helper classes. So:
| Option | Verdict |
|---|---|
| Generic `style: {…}` on every node | **No.** Every template would have to consume it, it re-opens raw CSS in class attributes, and it contradicts "blocks own no margin". |
| `style-class` nodes (named CSS class, linked by edge) | **Keep, but not for on-page tweaks.** Shared by design (blast radius = every layout that links it) and emitted into `<style>` in the head, which a per-node patch cannot refresh. Good for reusable surfaces (`tone`, `border`); edit them in the dialog. |
| Per-type editor code ("if layout then …") | **No.** Every new block type would need editor changes. |
| **`x-style` on schema properties** | **Yes.** The schema already is the source of truth (`x-graph`, `x-editor`); one `set` op per change (undo/redo, coalescing, 409 all free); any type opts in by annotating a field, zero editor code. |
Blocks that have no style fields (hero, text, …) get spacing by being **wrapped in a layout** — one atomic batch
(`insert` layout + `move` block into it), undone in one step. The wrapper type is the schema tagged `x-style.wrapper`.
## `x-style` (schema contract)
On a property:
```json
"paddingTop": {
"type": "string", "enum": ["none","xs","sm","md","lg","xl"],
"x-style": { "kind": "spacing", "box": "padding", "side": "top", "overrides": "padding", "token": "--k6n-sp-{value}" }
}
```
| key | meaning |
|---|---|
| `kind` | what the field *is*: `spacing` `gap` `flow` `columns` `align` `justify` `tone` `border` `reveal` `choice` |
| `box`, `side` | spacing only: `padding`/`margin` × `all top bottom left right x y` |
| `overrides` | sibling this field wins over (`paddingTop` over `padding`); drives the "inherited" display and which field a handle edits |
| `token` | CSS custom property template that resolves an enum value to a length. The editor measures real pixels from the page's own CSS (so fluid `clamp()` tokens are right) and snaps drags to the nearest token |
On the schema root: `"x-style": { "wrapper": true }` marks the type blocks can be wrapped in.
`responsive.<bp>.<field>` (the `$defs/breakpoint` properties) are matched to base fields by name: those fields get a breakpoint tab
and write to `responsive.<bp>.<field>`. Today only `flow` and `columns` are declared there - the template has classes for nothing else.
**Fallback.** For schemas without `x-style` (e.g. production before the annotated schemas deploy) the editor infers the same
descriptors from the property name + enum (`gap`, `padding|margin(Top|Bottom|Left|Right|X|Y)` with a `none…xl` scale, `flow`,
`columns`, `align`, `justify`, `tone`, `border`, `reveal`) and says so in the panel. The wrapper tag is never guessed.
Raw-length fields (`grid.gap: "1rem"`) are intentionally not inferred.
Annotated in `data/k6n/schemas`: `layout` (all fields, `$defs/breakpoint`, wrapper), `consulting-band` (`tone`, `align`, `width`),
and every block with a numeric `columns`.
## What the editor does with it
- **Handles on the page** (`src/style/BoxHandles.tsx`): padding (green), margin (orange) and gap (purple) bands from computed style,
each with a grip. Drag → live inline preview → snaps to a token on release → one coalesced `set`. Only sides the schema can express
get a grip (`left/right` fall back to the `all` field and say "(all sides)").
- **Style dialog** (`src/style/StylePanel.tsx`, toolbar ◐): flow / align / justify chips, columns stepper, a margin ⟶ padding box
diagram with one select per expressible side (override fields show what they inherit), gap chips with px, tone / border / reveal,
breakpoint tabs. Registered with `match: (info) => describeStyle(info.schema).length > 0`: **by schema, not by type**.
- **Wrap in layout** (toolbar ▣) for blocks without style fields.
- Pure, tested pieces: `model.ts` (descriptors, effective values, patches), `tokens.ts` (measure/snap), `handles.ts` (geometry, drag maths).
## Open points / suggestions for the CMS side
1. **Responsive spacing** is not possible today: no `gap/padding/margin` classes per breakpoint. If wanted: add `x-style` spacing
fields to `$defs/breakpoint` and emit e.g. `k6n-gap-md@t` classes from `layout.gohtml`; the editor picks them up unchanged.
2. **Left/right padding and margin** have no fields (`paddingTop/Bottom` only), so those grips edit the all-sides field. Adding
`paddingX/marginX` (`side: "x"`) gives them their own handles with no editor change.
3. `InsertOp` in `@k6n/cms-editor` lacks the optional `id` the server accepts (the editor casts for the wrap batch).
4. A layout linked to a `style-class` inherits `tone`/`border` from it; the panel shows the layout's own value only.
+233
View File
@@ -0,0 +1,233 @@
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<string, any> = {
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' },
},
// 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 },
},
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' },
},
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: '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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>')
return src
.split(/\n{2,}/)
.map((blk) => {
const lines = blk.split('\n')
if (lines.every((l) => /^[-*] /.test(l))) return `<ul>${lines.map((l) => `<li>${inline(l.slice(2))}</li>`).join('')}</ul>`
const h = /^(#{1,3}) (.*)$/.exec(blk)
if (h) return `<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`
return `<p>${inline(blk)}</p>`
})
.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 `<section class="sec sec--${esc(n.data.tone ?? 'light')}${n.data.padded ? ' sec--padded' : ''}" ${attrs} data-cms-container="true">${
n.data.title ? `<p class="sec__title">${esc(n.data.title)}</p>` : ''
}\n${kids()}\n</section>`
case 'heading': {
const l = n.data.level ?? 2
return `<h${l} ${attrs} data-cms-field="text" data-cms-editor="text">${esc(n.data.text)}</h${l}>`
}
case 'text':
return `<div class="prose" ${attrs} data-cms-field="body" data-cms-editor="markdown">${md(n.data.body ?? '')}</div>`
case 'layout': {
const d = n.data
const flow = ({ vertical: 'stack', horizontal: 'row', grid: 'row' } as Record<string, string>)[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 ?? {}
const resp = [['mobile', 'm'], ['tablet', 't'], ['desktop', 'd']]
.flatMap(([bp, c]) => [r[bp]?.flow ? ` k6n-layout--${c}-${r[bp].flow}` : '', r[bp]?.columns ? ` k6n-layout--${c}c-${r[bp].columns}` : ''])
.join('')
const tone = d.tone && d.tone !== 'none' ? ` k6n-${d.tone}` : ''
const cells = g.children(id).map((e) => `<div class="k6n-layout__cell">${render(g, e.to)}</div>`).join('\n')
return `<div class="k6n-layout k6n-layout--${flow} k6n-gap-${d.gap ?? 'md'}${sp}${tone}${resp}" style="--k6n-cols:${d.columns ?? 2}" ${attrs} data-cms-container="true">\n${cells}\n</div>`
}
case 'footer': {
const links = ((n.data.links as any[]) ?? []).map((l) => `<li><a href="${esc(l.href ?? '#')}">${esc(l.text)}</a></li>`).join('')
return `<footer class="foot" ${attrs}><strong>${esc(n.data.companyName)}</strong><ul>${links}</ul></footer>`
}
case 'button':
return `<p ${attrs}><a class="btn" href="${esc(n.data.href ?? '#')}" data-cms-field="label" data-cms-editor="text">${esc(n.data.label)}</a></p>`
default:
return `<div ${attrs}>${kids()}</div>`
}
}
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 `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title><style>${pageCss}</style></head><body><main>\n${body}\n</main></body></html>`
}
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { seedGraph } from './fixtures'
import { HttpError } from './graph'
const kids = (g: ReturnType<typeof seedGraph>, id: string) => g.children(id).map((e) => e.to)
describe('mock graph /api/edit semantics', () => {
it('insert at an index, and its inverse removes it again', () => {
const g = seedGraph()
const r = g.edit([{ op: 'insert', parent: 's1', type: 'text', index: 1, data: { body: 'x' } }])
expect(kids(g, 's1')).toEqual(['h1', r.results[0].node, 't1', 'b1'])
g.edit(r.inverse)
expect(kids(g, 's1')).toEqual(['h1', 't1', 'b1'])
expect(g.nodes.has(r.results[0].node!)).toBe(false)
})
it('set merges, null deletes, inverse restores (including absent keys)', () => {
const g = seedGraph()
const r = g.edit([{ op: 'set', id: 'h1', data: { text: 'New', extra: 1, level: null } }])
expect(g.node('h1').data).toEqual({ text: 'New', extra: 1 })
g.edit(r.inverse)
expect(g.node('h1').data).toEqual({ text: 'Edit this headline', level: 1 })
})
it('rejects a stale version with 409 and lifecycle keys with 400', () => {
const g = seedGraph()
g.edit([{ op: 'set', id: 'h1', data: { text: 'a' } }])
expect(() => g.edit([{ op: 'set', id: 'h1', data: { text: 'b' }, version: 1 }])).toThrow(expect.objectContaining({ status: 409 }))
expect(() => g.edit([{ op: 'set', id: 'h1', data: { status: 'x' } }])).toThrow(HttpError)
})
it('move reorders and re-parents; inverse puts it back', () => {
const g = seedGraph()
const r = g.edit([{ op: 'move', edge: 'e4', parent: 's2', index: 0 }])
expect(kids(g, 's2')).toEqual(['b1', 't2'])
expect(kids(g, 's1')).toEqual(['h1', 't1'])
g.edit(r.inverse)
expect(kids(g, 's1')).toEqual(['h1', 't1', 'b1'])
expect(kids(g, 's2')).toEqual(['t2'])
})
it('refuses cycles', () => {
const g = seedGraph()
g.edit([{ op: 'insert', parent: 's1', type: 'section', id: 'inner' }])
expect(() => g.edit([{ op: 'move', edge: 'e1', parent: 'inner' }])).toThrow(/cycle/)
})
it('delete takes the subtree; the inverse (restore) brings it back verbatim', () => {
const g = seedGraph()
const r = g.edit([{ op: 'delete', edge: 'e1' }])
expect(g.nodes.has('s1')).toBe(false)
expect(g.nodes.has('h1')).toBe(false)
g.edit(r.inverse)
expect(kids(g, 'p1')).toEqual(['s1', 's2', 'l1', 'f1'])
expect(kids(g, 's1')).toEqual(['h1', 't1', 'b1'])
})
it('duplicate deep-copies right after the original', () => {
const g = seedGraph()
const r = g.edit([{ op: 'duplicate', edge: 'e1' }])
const copy = r.results[0].node!
expect(kids(g, 'p1')).toEqual(['s1', copy, 's2', 'l1', 'f1'])
expect(kids(g, copy)).toHaveLength(3)
})
it('a failing op rolls the whole batch back and names the index', () => {
const g = seedGraph()
const before = JSON.stringify([...g.nodes])
expect(() => g.edit([{ op: 'set', id: 'h1', data: { text: 'changed' } }, { op: 'delete', edge: 'nope' }])).toThrow(/op 1/)
expect(JSON.stringify([...g.nodes])).toBe(before)
})
})
+196
View File
@@ -0,0 +1,196 @@
// An in-memory stand-in for the CMS: just enough of /api/edit, /api/nodes and
// /api/schemas to develop and test the editor with no server and no login.
// Semantics follow docs/onpage-editor.md (atomic batches, inverse ops, 409 on a
// stale version, patches = re-rendered subtrees). It is deliberately small - when
// real behaviour differs, fix the mock, then the editor.
export interface MNode { id: string; dataType: string; data: Record<string, any>; version: number; tags: string[] }
export interface MEdge { id: string; from: string; to: string; type: string; handle?: string; sort: number }
export class HttpError extends Error {
constructor(readonly status: number, message: string) {
super(message)
}
}
type Op = Record<string, any>
export class Graph {
nodes = new Map<string, MNode>()
edges = new Map<string, MEdge>()
private seq = 100
constructor(seed?: { nodes: Omit<MNode, 'version' | 'tags'>[]; edges: Omit<MEdge, 'type'>[] }) {
if (seed) {
for (const n of seed.nodes) this.nodes.set(n.id, { version: 1, tags: [], ...n })
for (const e of seed.edges) this.edges.set(e.id, { type: 'child', ...e })
}
}
private id(p: string) {
return `${p}${++this.seq}`
}
node(id: string): MNode {
const n = this.nodes.get(id)
if (!n) throw new HttpError(404, `node ${id} not found`)
return n
}
edge(id: string): MEdge {
const e = this.edges.get(id)
if (!e) throw new HttpError(404, `edge ${id} not found`)
return e
}
children(id: string): MEdge[] {
return [...this.edges.values()].filter((e) => e.from === id && e.type === 'child').sort((a, b) => a.sort - b.sort)
}
parents(id: string): MEdge[] {
return [...this.edges.values()].filter((e) => e.to === id)
}
edgesOf(id: string) {
return { from: this.children(id), into: this.parents(id) }
}
private renumber(parent: string) {
this.children(parent).forEach((e, i) => (e.sort = i))
}
/** Place edge `e` at `index` among its parent's children. */
private place(e: MEdge, index?: number) {
const sibs = this.children(e.from).filter((x) => x.id !== e.id)
const at = index === undefined || index > sibs.length ? sibs.length : Math.max(0, index)
sibs.splice(at, 0, e)
sibs.forEach((x, i) => (x.sort = i))
}
private subtree(edgeId: string): { nodes: MNode[]; edges: MEdge[] } {
const root = this.edge(edgeId)
const nodes: MNode[] = []
const edges: MEdge[] = [root]
const walk = (id: string) => {
nodes.push(this.node(id))
for (const c of this.children(id)) {
edges.push(c)
walk(c.to)
}
}
walk(root.to)
return { nodes, edges }
}
/** Apply a batch atomically. Returns results, inverse (already reversed) and touched ids. */
edit(ops: Op[]) {
if (!Array.isArray(ops) || ops.length < 1 || ops.length > 200) throw new HttpError(400, 'ops must hold 1-200 operations')
const snapshot = structuredClone({ nodes: [...this.nodes], edges: [...this.edges], seq: this.seq })
const results: Array<{ node?: string; edge?: string }> = []
const inverse: Op[] = []
const touched = new Set<string>()
try {
ops.forEach((op, i) => {
try {
const { result, inv, touch } = this.applyOne(op)
results.push(result)
inverse.unshift(...inv)
touch.forEach((t) => touched.add(t))
} catch (e) {
if (e instanceof HttpError) throw new HttpError(e.status, `op ${i} (${op.op}): ${e.message}`)
throw e
}
})
} catch (e) {
this.nodes = new Map(snapshot.nodes)
this.edges = new Map(snapshot.edges)
this.seq = snapshot.seq
throw e
}
return { results, inverse, touched: [...touched] }
}
private applyOne(op: Op): { result: { node?: string; edge?: string }; inv: Op[]; touch: string[] } {
switch (op.op) {
case 'insert': {
this.node(op.parent)
if (!op.type) throw new HttpError(400, 'type required')
const node: MNode = { id: op.id ?? this.id('n'), dataType: op.type, data: { ...(op.data ?? {}) }, version: 1, tags: [] }
this.nodes.set(node.id, node)
const edge: MEdge = { id: this.id('e'), from: op.parent, to: node.id, type: 'child', handle: op.handle, sort: 0 }
this.edges.set(edge.id, edge)
this.place(edge, op.index)
return { result: { node: node.id, edge: edge.id }, inv: [{ op: 'delete', edge: edge.id }], touch: [op.parent] }
}
case 'set': {
const n = this.node(op.id)
if (op.version !== undefined && op.version !== n.version) throw new HttpError(409, `stale version (have ${n.version}, got ${op.version})`)
const undo: Record<string, unknown> = {}
for (const [k, v] of Object.entries<any>(op.data ?? {})) {
if (['lastModified', 'status'].includes(k)) throw new HttpError(400, `lifecycle key ${k} cannot be set`)
undo[k] = k in n.data ? n.data[k] : null
if (v === null) delete n.data[k]
else n.data[k] = v
}
n.version++
return { result: { node: n.id }, inv: [{ op: 'set', id: n.id, data: undo }], touch: [n.id] }
}
case 'move': {
const e = this.edge(op.edge)
const before = { parent: e.from, handle: e.handle, index: this.children(e.from).findIndex((x) => x.id === e.id) }
const to = op.parent ?? e.from
if (this.isDescendant(to, e.to)) throw new HttpError(400, 'move would create a cycle')
const oldParent = e.from
e.from = to
if ('handle' in op) e.handle = op.handle
this.renumber(oldParent)
this.place(e, op.index)
return {
result: { edge: e.id },
inv: [{ op: 'move', edge: e.id, parent: before.parent, handle: before.handle, index: before.index }],
touch: [...new Set([oldParent, to])],
}
}
case 'delete': {
const e = this.edge(op.edge)
const snap = this.subtree(e.id)
for (const x of snap.edges) this.edges.delete(x.id)
// Subtree nodes survive only if still composed elsewhere (shared blocks).
for (const n of snap.nodes) if (this.parents(n.id).length === 0) this.nodes.delete(n.id)
this.renumber(e.from)
return { result: {}, inv: [{ op: 'restore', edge: e.id, nodes: structuredClone(snap.nodes), edges: structuredClone(snap.edges) }], touch: [e.from] }
}
case 'restore': {
for (const n of op.nodes ?? []) this.nodes.set(n.id, structuredClone(n))
for (const e of op.edges ?? []) this.edges.set(e.id, structuredClone(e))
const root = this.edge(op.edge)
this.place(root, root.sort)
return { result: { edge: root.id, node: root.to }, inv: [{ op: 'delete', edge: root.id }], touch: [root.from] }
}
case 'duplicate': {
const e = this.edge(op.edge)
const map = new Map<string, string>()
const clone = (id: string): string => {
const src = this.node(id)
const copy: MNode = { ...structuredClone(src), id: this.id('n'), version: 1 }
this.nodes.set(copy.id, copy)
map.set(id, copy.id)
for (const c of this.children(id)) {
const ce: MEdge = { ...c, id: this.id('e'), from: copy.id, to: clone(c.to) }
this.edges.set(ce.id, ce)
}
return copy.id
}
const nid = clone(e.to)
const ne: MEdge = { ...e, id: this.id('e'), to: nid }
this.edges.set(ne.id, ne)
this.place(ne, e.sort + 1)
return { result: { node: nid, edge: ne.id }, inv: [{ op: 'delete', edge: ne.id }], touch: [e.from] }
}
default:
throw new HttpError(400, `op "${op.op}" is not implemented by the mock`)
}
}
private isDescendant(id: string, ancestor: string): boolean {
if (id === ancestor) return true
return this.children(ancestor).some((c) => this.isDescendant(id, c.to))
}
/** Root = a node nobody composes. */
roots(): MNode[] {
return [...this.nodes.values()].filter((n) => this.parents(n.id).length === 0)
}
}
+140
View File
@@ -0,0 +1,140 @@
// Framework-neutral request handler for the mock CMS, used by the Vite plugin
// (dev:mock) and directly by tests (as an injectable `fetch`).
import { Graph, HttpError } from './graph'
import { blueprints, render, renderPage, schemas, seedGraph, types } from './fixtures'
export interface MockOptions {
/** Require this bearer token (to test the 401 path). Default: open. */
token?: string
/** Require sign-in through the mock device flow (GET/POST /api/auth/device*). Default: open. */
auth?: boolean
/** Auto-approve a device grant this long after it starts (dev:mock). Tests call approve(). */
approveAfterMs?: number
}
export function createMockCms(opts: MockOptions = {}) {
let graph = seedGraph()
const published: string[] = []
const requests: string[] = []
let previewApi = true
// --- mock go-auth (RFC 8628 device grant) ---
const access = new Set<string>()
let refreshTok: string | null = null
let approved = false
let n = 0
const issue = () => {
const a = `mock-access-${++n}`
access.add(a)
refreshTok = `mock-refresh-${n}`
return { access_token: a, token_type: 'Bearer', expires_in: 900, refresh_token: refreshTok }
}
const needsAuth = () => !!(opts.token || opts.auth)
const authorised = (req: Request) => {
const h = req.headers.get('authorization')
return !!h && (h === `Bearer ${opts.token}` || access.has(h.replace(/^Bearer /, '')))
}
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'x-cms-mock': '1' } })
async function handle(req: Request): Promise<Response> {
const url = new URL(req.url, 'http://mock')
const path = url.pathname
const m = req.method
requests.push(`${m} ${path}${url.search}`)
try {
// The mock is its own issuer (same origin), so the browser can complete the flow with no network.
if (path === '/device') {
return new Response(
`<!doctype html><meta charset="utf-8"><title>Mock approval</title><body style="font:16px system-ui;max-width:420px;margin:15vh auto;text-align:center"><h1>Approve sign-in?</h1><p>Code <b>MOCK-1234</b></p><button style="font:inherit;padding:10px 24px" onclick="fetch('/__mock/approve',{method:'POST'}).then(()=>{document.body.innerHTML='<h1>Approved</h1><p>You can close this tab.</p>';setTimeout(close,800)})">Approve</button>`,
{ headers: { 'content-type': 'text/html; charset=utf-8' } },
)
}
if (path === '/__mock/approve' && m === 'POST') {
approved = true
return new Response(null, { status: 204 })
}
if (path === '/api/auth/config') return json(200, needsAuth() ? { mode: 'oidc', issuer: url.origin, clientId: 'mock' } : { mode: 'none' })
if (path === '/api/auth/device' && m === 'POST') {
approved = false
if (opts.approveAfterMs !== undefined) setTimeout(() => (approved = true), opts.approveAfterMs)
return json(200, { device_code: 'mock-device', user_code: 'MOCK-1234', verification_uri: `${url.origin}/device`, verification_uri_complete: `${url.origin}/device?user_code=MOCK-1234`, expires_in: 600, interval: 1 })
}
if (path === '/api/auth/device/token' && m === 'POST') return approved ? json(200, issue()) : json(400, { error: 'authorization_pending' })
if (path === '/api/auth/device/refresh' && m === 'POST') {
const b = (await req.json()) as { refresh_token?: string }
return b.refresh_token && b.refresh_token === refreshTok ? json(200, issue()) : json(400, { error: 'invalid_grant' })
}
if (needsAuth() && path.startsWith('/api/') && !authorised(req)) return json(401, { error: 'unauthorized: auth: no credentials' })
if (path === '/api/auth/me') return json(200, { sub: 'mock-user', scopes: ['admin'] })
if (path === '/api/types') return json(200, { types, locales: [] })
if (path === '/api/blueprints') return json(200, blueprints)
let r: RegExpExecArray | null
if ((r = /^\/api\/schemas\/([^/]+)$/.exec(path))) {
const s = schemas[decodeURIComponent(r[1])]
return s ? json(200, s) : json(404, { error: 'unknown type' })
}
if ((r = /^\/api\/nodes\/([^/]+)\/edges$/.exec(path))) {
graph.node(r[1])
return json(200, graph.edgesOf(r[1]))
}
if ((r = /^\/api\/nodes\/([^/]+)$/.exec(path)) && m === 'GET') {
const n = graph.node(r[1])
return json(200, { id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version })
}
if ((r = /^\/api\/tree\/([^/]+)$/.exec(path)) && m === 'GET') {
// TreeNode { node, edge, children[] } - one request for the whole page's data
const walk = (id: string, edge?: unknown): unknown => {
const n = graph.node(id)
return { node: { id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version }, edge, children: graph.children(id).map((e) => walk(e.to, e)) }
}
return json(200, walk(r[1]))
}
if (path === '/api/edit' && m === 'POST') {
const body = (await req.json()) as { ops: any[] }
const res = graph.edit(body.ops)
const patches = res.touched.map((id) => {
if (graph.parents(id).length === 0) return { id, error: 'root cannot render standalone' } // like a real page node
return { id, html: render(graph, id) }
})
return json(200, { ...res, patches })
}
if (path === '/api/publish-roots' && m === 'POST') {
const body = (await req.json()) as { roots: string[] }
published.push(...body.roots)
return json(200, { published: body.roots })
}
if (path === '/api/preview' && !previewApi) return json(404, { error: 'no such route' })
if (path === '/api/preview' || (m === 'GET' && req.headers.get('accept')?.includes('text/html'))) {
return new Response(renderPage(graph), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-cms-page': '1' } })
}
return json(404, { error: `mock: no route for ${m} ${path}` })
} catch (e) {
if (e instanceof HttpError) return json(e.status, { error: e.message })
return json(500, { error: (e as Error).message })
}
}
return {
handle,
/** Back to the seed data (also exposed at POST /__mock/reset in dev). */
reset: () => void (graph = seedGraph()),
/** Approve the pending device grant (what the user does on the issuer's /device page). */
approve: () => void (approved = true),
/** Simulate access-token expiry: every issued access token starts returning 401. */
expireAccess: () => access.clear(),
/** Simulate revocation: the refresh token stops working too. */
revoke: () => {
access.clear()
refreshTok = null
},
get graph() {
return graph
},
published,
requests,
/** Simulate a CMS without GET /api/preview (the editor must fall back to the page URL). */
disablePreviewApi: () => void (previewApi = false),
fetch: ((input: RequestInfo | URL, init?: RequestInit) => handle(new Request(new URL(String(input), 'http://mock'), { ...init, signal: undefined }))) as typeof fetch,
}
}
+3617
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "cms-onpageeditor",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Injectable on-page editor for the go-graph-cms: side-loaded onto the statically rendered page, edits through POST /api/edit.",
"scripts": {
"dev": "vite",
"dev:mock": "CMS_MODE=mock vite",
"build": "tsc --noEmit && vite build --mode lib",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@mdi/js": "^7.4.47",
"@tiptap/core": "^3.27.1",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/markdown": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"marked": "^17.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.0",
"@types/node": "^22.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"jsdom": "^26.0.0",
"typescript": "^5.9.0",
"vite": "^7.0.0",
"vitest": "^3.2.0"
}
}
+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(() => []))
}
}
+313
View File
@@ -0,0 +1,313 @@
// Sign-in for the editor via go-auth's RFC 8628 device grant (see go-auth
// /dev-auth.md): start a grant, the user approves it on the issuer's /device
// page, we poll, then hold a short-lived access token and renew it with the
// rotating refresh token. The issuer allows CORS from the site and localhost:5173,
// so this runs straight from the page - no proxy.
//
// Token sources, in order: `window.__CMS_EDITOR_CONFIG__` (token/apiKey), tokens
// this manager obtained, the backoffice's own `auth.tokens` (same origin), else
// cookies only. Refresh tokens live in localStorage in dev, and only in
// sessionStorage in production (go-auth's guidance: never persist them there).
import { createStore } from './store'
import type { Store } from './store'
export interface Tokens {
accessToken: string
refreshToken?: string
/** epoch ms */
expiresAt: number
}
export type AuthStatus = 'unknown' | 'signed-out' | 'pending' | 'signed-in'
export interface AuthState {
status: AuthStatus
/** Display name of the signed-in principal. */
user?: string
userCode?: string
verifyUrl?: string
error?: string
}
export interface AuthOptions {
fetch?: typeof fetch
/** Where the issuer lives. Default: `issuer` from GET /api/auth/config. */
issuer?: () => Promise<string>
/**
* OAuth client to run the device grant as. Default: `clientId` from GET /api/auth/config - the
* backoffice's own client, so the token's `aud` is what the CMS API (OIDC_AUDIENCE) accepts.
* go-auth's clientless fallback client yields a token the CMS rejects with "wrong audience".
*/
clientId?: () => Promise<string | undefined>
/** Extra audience(s) to request (RFC 8707 `resource`), if the CMS audience differs from the client id. */
audience?: string[]
/** Survives reloads (dev). */
persistent?: Storage | null
/** Per tab. */
session?: Storage | null
/** Keep the refresh token in `persistent` (dev) instead of `session` (production). */
persistRefresh?: boolean
sleep?: (ms: number) => Promise<void>
now?: () => number
/** Called whenever the access token changes (dev uses it to tell the dev server). */
onTokens?: (t: Tokens | null) => void
scope?: string
}
const KEY = 'cms-editor.auth'
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
const SKEW = 30_000
const safe = (s?: () => Storage | null): Storage | null => {
try {
return s?.() ?? null
} catch {
return null
}
}
export class AuthManager {
readonly state: Store<AuthState> = createStore<AuthState>({ status: 'unknown' })
private tokens: Tokens | null = null
private refreshing: Promise<boolean> | null = null
private abort: AbortController | null = null
private issuerP?: Promise<string>
private configP?: Promise<{ issuer?: string; clientId?: string }>
private readonly doFetch: typeof fetch
private readonly sleep: (ms: number) => Promise<void>
private readonly now: () => number
private readonly persistent: Storage | null
private readonly session: Storage | null
constructor(private readonly opts: AuthOptions = {}) {
this.doFetch = opts.fetch ?? ((...a) => fetch(...a))
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)))
this.now = opts.now ?? Date.now
this.persistent = opts.persistent === undefined ? safe(() => localStorage) : opts.persistent
this.session = opts.session === undefined ? safe(() => sessionStorage) : opts.session
this.tokens = this.load()
}
// --- storage -----------------------------------------------------------------
private load(): Tokens | null {
try {
const raw = this.session?.getItem(KEY) ?? this.persistent?.getItem(KEY)
return raw ? (JSON.parse(raw) as Tokens) : null
} catch {
return null
}
}
private save(t: Tokens | null) {
this.tokens = t
for (const s of [this.session, this.persistent]) {
try {
if (!t) s?.removeItem(KEY)
else if (s === this.session) s?.setItem(KEY, JSON.stringify({ ...t, refreshToken: this.opts.persistRefresh ? undefined : t.refreshToken }))
else if (this.opts.persistRefresh) s?.setItem(KEY, JSON.stringify(t))
} catch {
/* storage full / blocked: stay in memory */
}
}
this.opts.onTokens?.(t)
}
/** Re-send the current tokens to `onTokens` (dev: tell a freshly started dev server). */
announce() {
if (this.tokens) this.opts.onTokens?.(this.tokens)
}
// --- using the token ----------------------------------------------------------
/** The token to send right now (does not refresh). */
private current(): { kind: 'bearer' | 'key'; value: string } | null {
const cfg = (globalThis as any).__CMS_EDITOR_CONFIG__ as { token?: string; apiKey?: string } | undefined
if (cfg?.apiKey) return { kind: 'key', value: cfg.apiKey }
if (cfg?.token) return { kind: 'bearer', value: cfg.token }
if (this.tokens) return { kind: 'bearer', value: this.tokens.accessToken }
try {
const t = JSON.parse(this.persistent?.getItem('auth.tokens') ?? 'null') as { accessToken?: string; expiresAt?: number } | null
if (t?.accessToken && (!t.expiresAt || t.expiresAt > this.now())) return { kind: 'bearer', value: t.accessToken }
} catch {
/* ignore */
}
return null
}
headers(): Record<string, string> {
const c = this.current()
return !c ? {} : c.kind === 'key' ? { 'X-API-Key': c.value } : { Authorization: `Bearer ${c.value}` }
}
/** Refresh first when the access token is (nearly) expired. */
async ensureFresh(): Promise<void> {
if (this.tokens && this.tokens.refreshToken && this.tokens.expiresAt - SKEW < this.now()) await this.refresh()
}
/** Exchange the refresh token. Concurrent callers share one request (refresh tokens rotate). */
refresh(): Promise<boolean> {
if (!this.tokens?.refreshToken) return Promise.resolve(false)
return (this.refreshing ??= this.doRefresh().finally(() => (this.refreshing = null)))
}
private async doRefresh(): Promise<boolean> {
try {
const resp = await this.doFetch(`${await this.issuer()}/api/auth/device/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: this.tokens!.refreshToken }),
})
if (!resp.ok) throw new Error(`refresh ${resp.status}`)
this.save(this.fromResponse(await resp.json(), this.tokens))
return true
} catch {
this.save(null) // revoked / expired: sign in again
return false
}
}
private fromResponse(b: any, prev?: Tokens | null): Tokens {
return {
accessToken: String(b.access_token),
refreshToken: b.refresh_token ? String(b.refresh_token) : prev?.refreshToken,
expiresAt: this.now() + (Number(b.expires_in) || 900) * 1000,
}
}
private config(): Promise<{ issuer?: string; clientId?: string }> {
return (this.configP ??= this.doFetch('/api/auth/config').then((r) => r.json()).catch((e) => {
this.configP = undefined
throw e
}))
}
private issuer(): Promise<string> {
const cfg = (globalThis as any).__CMS_EDITOR_CONFIG__ as { issuer?: string } | undefined
if (cfg?.issuer) return Promise.resolve(cfg.issuer.replace(/\/$/, ''))
return (this.issuerP ??= (this.opts.issuer
? this.opts.issuer()
: this.config().then((c) => {
if (!c.issuer) throw new Error('this CMS has no OIDC issuer configured')
return c.issuer
})
).then((i) => i.replace(/\/$/, '')))
}
private async clientId(): Promise<string | undefined> {
const cfg = (globalThis as any).__CMS_EDITOR_CONFIG__ as { clientId?: string } | undefined
if (cfg?.clientId) return cfg.clientId
if (this.opts.clientId) return this.opts.clientId()
return (await this.config().catch(() => ({}) as { clientId?: string })).clientId
}
private audiences(): string[] {
const cfg = (globalThis as any).__CMS_EDITOR_CONFIG__ as { audience?: string | string[] } | undefined
return [cfg?.audience ?? this.opts.audience ?? []].flat()
}
// --- session state -------------------------------------------------------------
/** Probe `GET /api/auth/me` with whatever credentials we have. */
async check(fetchMe: () => Promise<Record<string, any>>): Promise<AuthStatus> {
try {
await this.ensureFresh()
const me = await fetchMe()
this.state.set({ status: 'signed-in', user: String(me.name ?? me.email ?? me.sub ?? 'you'), error: undefined, userCode: undefined, verifyUrl: undefined })
} catch (e) {
if ((e as { status?: number }).status === 401 || (e as { status?: number }).status === 403) {
// Only explain when we actually sent credentials (e.g. "oidc: wrong audience"); a plain visitor needs no error.
this.state.set({ status: 'signed-out', user: undefined, error: Object.keys(this.headers()).length ? (e as Error).message : undefined })
}
else this.state.set({ status: 'signed-out', error: (e as Error).message })
}
return this.state.get().status
}
/** The API said 401 and a refresh did not help. */
markSignedOut(error?: string) {
if (this.state.get().status === 'pending') return
this.state.set({ status: 'signed-out', user: undefined, error })
}
// --- device grant ----------------------------------------------------------------
/**
* Run the whole device flow. `openWindow` is called with the approval URL as soon as it is known
* (the UI also shows it, in case popups are blocked). Resolves true when signed in.
*/
async login(openWindow?: (url: string) => void): Promise<boolean> {
this.abort?.abort()
const abort = (this.abort = new AbortController())
try {
const issuer = await this.issuer()
const clientId = await this.clientId()
const startBody = new URLSearchParams({ scope: this.opts.scope ?? 'openid offline_access' })
if (clientId) startBody.set('client_id', clientId)
for (const a of this.audiences()) startBody.append('resource', a)
const start = await this.doFetch(`${issuer}/api/auth/device`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: startBody,
signal: abort.signal,
})
if (!start.ok) throw new Error(`could not start sign-in (${start.status}): ${await start.text().catch(() => '')}`.trim())
const g = (await start.json()) as { device_code: string; user_code: string; verification_uri: string; verification_uri_complete?: string; interval?: number; expires_in?: number }
const url = g.verification_uri_complete ?? g.verification_uri
this.state.set({ status: 'pending', userCode: g.user_code, verifyUrl: url, error: undefined })
openWindow?.(url)
let interval = (g.interval ?? 5) * 1000
const deadline = this.now() + (g.expires_in ?? 600) * 1000
while (this.now() < deadline) {
await this.sleep(interval)
if (abort.signal.aborted) return false
const poll = await this.doFetch(`${issuer}/api/auth/device/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: DEVICE_GRANT, device_code: g.device_code, ...(clientId ? { client_id: clientId } : {}) }),
signal: abort.signal,
})
const body = (await poll.json().catch(() => ({}))) as any
if (poll.ok && body.access_token) {
this.save(this.fromResponse(body))
this.state.set({ status: 'unknown', userCode: undefined, verifyUrl: undefined })
return true
}
switch (body.error) {
case 'authorization_pending':
break
case 'slow_down':
interval += 5000
break
case 'access_denied':
throw new Error('sign-in was denied')
case 'expired_token':
throw new Error('the code expired - start again')
default:
throw new Error(body.error_description ?? body.error ?? `sign-in failed (${poll.status})`)
}
}
throw new Error('the code expired - start again')
} catch (e) {
if (abort.signal.aborted) return false
this.state.set({ status: 'signed-out', userCode: undefined, verifyUrl: undefined, error: (e as Error).message })
return false
}
}
cancel() {
this.abort?.abort()
this.state.set({ status: 'signed-out', userCode: undefined, verifyUrl: undefined })
}
/** Escape hatch: use a token the developer already has. */
useToken(token: string) {
this.save({ accessToken: token.trim(), expiresAt: this.now() + 15 * 60_000 })
}
logout() {
this.abort?.abort()
this.save(null)
try {
this.persistent?.removeItem('auth.tokens') // the backoffice's copy too: otherwise we'd silently stay signed in
} catch {
/* ignore */
}
this.state.set({ status: 'signed-out', user: undefined, userCode: undefined, verifyUrl: undefined, error: undefined })
}
}
+71
View File
@@ -0,0 +1,71 @@
// Mounts the editor: one host element in the page, the UI inside a shadow root
// (so editor CSS never leaks into the page and the page's CSS never reaches the
// editor). The page DOM itself stays server HTML; React only owns the shadow tree.
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
import type { Root } from 'react-dom/client'
import { Controller } from './controller'
import type { ControllerOptions } from './controller'
import { registerBuiltins } from './editors/builtin'
import * as registry from './registry'
import { App } from './ui/App'
import css from './ui/styles.css?inline'
import pageCss from './page-style.css?inline'
export const HOST_ID = 'cms-onpage-editor'
export interface EditorHandle {
ctrl: Controller
registry: typeof registry
destroy(): void
}
export interface BootOptions extends Partial<Pick<ControllerOptions, 'api' | 'reload'>> {
/** Start with the developer panel open. */
dev?: boolean
doc?: Document
}
export function boot(opts: BootOptions = {}): EditorHandle {
const doc = opts.doc ?? document
const existing = (doc.defaultView as any)?.__cmsEditor as EditorHandle | undefined
if (existing) return existing // idempotent: double-injection is harmless
const host = doc.createElement('div')
host.id = HOST_ID
// Sibling of <body>, so body morphs and body-level scans never see it.
doc.documentElement.appendChild(host)
const shadow = host.attachShadow({ mode: 'open' })
const style = doc.createElement('style')
style.textContent = css
const mount = doc.createElement('div')
mount.className = 'cms-root'
shadow.append(style, mount)
const pageStyle = doc.createElement('style')
pageStyle.id = `${HOST_ID}-page`
pageStyle.textContent = pageCss
doc.head.appendChild(pageStyle)
const offBuiltins = registerBuiltins()
const ctrl = new Controller({ host, doc, api: opts.api, reload: opts.reload })
if (opts.dev) ctrl.store.set({ dev: true })
ctrl.start()
const root: Root = createRoot(mount)
root.render(createElement(App, { ctrl }))
const handle: EditorHandle = {
ctrl,
registry,
destroy() {
root.unmount()
ctrl.destroy()
offBuiltins()
host.remove()
pageStyle.remove()
delete (doc.defaultView as any).__cmsEditor
},
}
;(doc.defaultView as any).__cmsEditor = Object.assign(handle, registry)
return handle
}
+1119
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
// Dev entry, injected by the Vite plugin into every proxied/mock page.
// Forces preview mode (the cookie makes the edge render /api/preview), opens the
// dev panel and boots the editor from source so HMR works.
import { Api } from './api'
import { AuthManager } from './auth'
import { boot } from './boot'
import './examples'
const hasCookie = () => /(?:^|;\s*)cms_preview=1/.test(document.cookie)
if (!hasCookie()) {
document.cookie = 'cms_preview=1; path=/; SameSite=Lax'
// The first response was the published page; refetch in preview mode (once).
if (hasCookie() && !sessionStorage.getItem('cms-dev-reloaded')) {
sessionStorage.setItem('cms-dev-reloaded', '1')
location.reload()
}
}
sessionStorage.removeItem('cms-dev-reloaded')
// Dev: refresh token in localStorage (go-auth's dev pattern), and tell the dev server the access token so
// its server-side page fetches (live preview HTML) are authenticated as you, too. Never done in production.
const auth = new AuthManager({
persistRefresh: true,
onTokens: (t) =>
void fetch('/__dev/token', t ? { method: 'POST', body: JSON.stringify({ accessToken: t.accessToken }) } : { method: 'DELETE' }).catch(() => {}),
})
auth.announce()
const handle = boot({ dev: true, api: new Api({ auth }) })
if (import.meta.hot) import.meta.hot.dispose(() => handle.destroy())
+8
View File
@@ -0,0 +1,8 @@
/** Build a `set` payload: changed keys carry the new value, removed keys are `null` (server deletes them). */
export function diffData(original: Record<string, any>, draft: Record<string, any>): Record<string, unknown> {
const out: Record<string, unknown> = {}
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
for (const k of Object.keys(draft)) if (!same(original[k], draft[k]) && draft[k] !== undefined) out[k] = draft[k]
for (const k of Object.keys(original)) if (!(k in draft) || draft[k] === undefined) out[k] = null
return out
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from 'react'
import type { Controller } from '../controller'
import { Icon } from '../ui/Icon'
const LABEL: Record<string, string> = { href: 'Link', src: 'Source', alt: 'Alt text', title: 'Title', placeholder: 'Placeholder', 'aria-label': 'Accessible label', value: 'Value' }
/** Tiny editor for a property that is rendered as an attribute (href, src, alt…), opened from its chip. */
export function AttrEditor({ id, path, attr, initial, ctrl, close }: { id: string; path: string; attr: string; initial: string; ctrl: Controller; close(): void }) {
const [v, setV] = useState(initial)
const input = useRef<HTMLInputElement>(null)
useEffect(() => input.current?.select(), [])
return (
<form
className="cms-form"
onSubmit={async (e) => {
e.preventDefault()
if (v !== initial) await ctrl.setPath(id, path, v)
close()
}}
>
<label className="cms-field">
<span className="cms-field__label">
{LABEL[attr] ?? attr} <span className="cms-muted">({path})</span>
</span>
<input ref={input} className="cms-input" value={v} onChange={(e) => setV(e.target.value)} />
</label>
<footer className="cms-actions">
<button type="button" className="cms-btn" onClick={close}>
Cancel
</button>
<button type="submit" className="cms-btn cms-btn--primary" disabled={v === initial}>
<Icon name="save" size={16} /> Save
</button>
</footer>
</form>
)
}
+253
View File
@@ -0,0 +1,253 @@
// Built-in editors. They go through the same registries as third-party ones, so
// replacing any of them is `registerEditor({ id: 'properties', ... })`.
import { useEffect, useMemo, useRef, useState } from 'react'
import { mountMarkdownEditor } from '@k6n/cms-editor'
import { diffData } from '../diff'
import { allowedChildren, sortGroups, typeName } from '../schema'
import { useEditorState } from '../ui/hooks'
import { getIn, parsePath } from '../page/infer'
import type { Blueprint } from '../api'
import type { Controller } from '../controller'
import { registerEditor, registerInlineEditor } from '../registry'
import { describeStyle } from '../style/model'
import { StylePanel } from '../style/StylePanel'
import type { EditorProps } from '../registry'
import { SchemaForm, JsonArea, validate } from '../form/Form'
import { Icon, TypeIcon } from '../ui/Icon'
// --- Properties: schema-driven form with explicit Save ------------------------
function Properties({ node, schema, ctrl, close }: EditorProps) {
const [draft, setDraft] = useState<Record<string, any>>(node.data ?? {})
const [saving, setSaving] = useState(false)
const patch = useMemo(() => diffData(node.data ?? {}, draft), [node, draft])
const form = useRef<HTMLFormElement>(null)
useEffect(() => void form.current?.querySelector<HTMLElement>('input:not([type=checkbox]), textarea, select')?.focus(), [])
const problems = useMemo(() => Object.entries(validate(schema, schema, draft)), [schema, draft])
const changed = Object.keys(patch).length > 0
const save = async () => {
setSaving(true)
const res = await ctrl.setData(node.id, patch, { version: node.version })
setSaving(false)
if (res) close()
}
return (
<form
ref={form}
className="cms-form"
onSubmit={(e) => {
e.preventDefault()
if (changed && !problems.length) void save()
}}
>
<SchemaForm schema={schema} value={draft} onChange={setDraft} node={node} ctrl={ctrl} />
<footer className="cms-actions">
<button type="button" className="cms-btn" onClick={() => setDraft(node.data ?? {})} disabled={!changed}>
<Icon name="reset" size={16} /> Reset
</button>
{problems.length > 0 && (
<span className="cms-error cms-actions__note" role="alert" title={problems.map(([p, m]) => `${p || 'form'}: ${m}`).join('\n')}>
{problems.length} problem{problems.length === 1 ? '' : 's'} to fix
</span>
)}
<button type="submit" className="cms-btn cms-btn--primary" disabled={!changed || saving || problems.length > 0}>
<Icon name="save" size={16} /> {saving ? 'Saving…' : 'Save'}
</button>
</footer>
</form>
)
}
// --- Raw JSON: escape hatch for anything the form can't express -----------------
function RawJson({ node, ctrl, close }: EditorProps) {
const [draft, setDraft] = useState<any>(node.data ?? {})
const patch = diffData(node.data ?? {}, draft && typeof draft === 'object' ? draft : {})
return (
<div className="cms-form">
<JsonArea value={node.data ?? {}} onChange={setDraft} />
<footer className="cms-actions">
<button
className="cms-btn cms-btn--primary"
disabled={!Object.keys(patch).length}
onClick={async () => (await ctrl.setData(node.id, patch, { version: node.version })) && close()}
>
<Icon name="save" size={16} /> Save
</button>
</footer>
</div>
)
}
// --- Insert picker: children allowed by x-graph + blueprints ---------------------
export function InsertPicker({ parentId, index, ctrl, close }: { parentId: string; index?: number; ctrl: Controller; close(): void }) {
const [state, setState] = useState<{ types: string[]; blueprints: Blueprint[] } | { error: string } | null>(null)
useEffect(() => {
let live = true
;(async () => {
try {
const parent = await ctrl.node(parentId)
const [schema, all, blueprints] = await Promise.all([ctrl.schema(parent.dataType), ctrl.api.types(), ctrl.api.blueprints()])
const types = allowedChildren(schema, all.map(typeName)).filter((t) => !ctrl.isStaticRoot(t))
if (live) setState({ types, blueprints })
void ctrl.loadSchemas(types) // for grouping
} catch (e) {
if (live) setState({ error: (e as Error).message })
}
})()
return () => {
live = false
}
}, [parentId, ctrl])
useEditorState((st) => st.schemasVersion)
if (!state) return <p className="cms-muted">Loading…</p>
if ('error' in state) return <p className="cms-error">{state.error}</p>
if (!state.types.length && !state.blueprints.length) return <p className="cms-muted">This block does not accept children.</p>
const byGroup = new Map<string, string[]>()
for (const t of state.types) byGroup.set(ctrl.groupOfType(t), [...(byGroup.get(ctrl.groupOfType(t)) ?? []), t])
const tile = (t: string) => (
<button
key={t}
className="cms-tile"
data-type={t}
title={t}
onClick={async () => {
close()
await ctrl.insert(parentId, t, index !== undefined ? { index } : {})
}}
>
<TypeIcon type={t} size={20} />
<span>{ctrl.typeTitle(t)}</span>
{ctrl.typeTitle(t) !== t && <small>{t}</small>}
</button>
)
return (
<div className="cms-picker">
{sortGroups(byGroup.keys()).map((g) => (
<section key={g}>
<h4>{g}</h4>
<div className="cms-grid">{byGroup.get(g)!.map(tile)}</div>
</section>
))}
{state.blueprints.length > 0 && <h4>Blueprints</h4>}
<div className="cms-grid">
{state.blueprints.map((b) => (
<button
key={b.id}
className="cms-tile"
title={b.description}
onClick={async () => {
close()
await ctrl.insertBlueprint(parentId, b.id, index)
}}
>
{b.name ?? b.id}
</button>
))}
</div>
</div>
)
}
function AddChild({ info, ctrl, close }: EditorProps) {
return <InsertPicker parentId={info.id} ctrl={ctrl} close={close} />
}
function AddAfter({ info, ctrl, close }: EditorProps) {
const [target, setTarget] = useState<{ parent: string; index: number } | null>(null)
useEffect(() => {
void (async () => {
const parent = await ctrl.parentIdOf(info.id)
const { index } = await ctrl.position(info.id)
setTarget({ parent, index: index + 1 })
})().catch((e) => ctrl.toast('error', (e as Error).message))
}, [info.id, ctrl])
return target ? <InsertPicker parentId={target.parent} index={target.index} ctrl={ctrl} close={close} /> : <p className="cms-muted">Loading…</p>
}
export function registerBuiltins(): () => void {
const off = [
registerEditor({ id: 'properties', label: 'Properties', icon: 'edit', match: '*', priority: 100, onDoubleClick: true, component: Properties }),
// Matches on the schema, not the node type: any type with x-style fields gets spacing/layout controls.
registerEditor({ id: 'style', label: 'Style', icon: 'style', match: (i) => describeStyle(i.schema).length > 0, priority: 90, size: 'md', component: StylePanel }),
registerEditor({ id: 'add-child', label: 'Add inside', icon: 'add-inside', match: (i) => i.container, priority: 50, size: 'sm', component: AddChild }),
registerEditor({ id: 'add-after', label: 'Add after', icon: 'add', match: '*', priority: 40, size: 'sm', component: AddAfter }),
registerEditor({ id: 'raw-json', label: 'JSON', icon: 'json', placement: 'menu', match: '*', priority: -100, size: 'lg', component: RawJson }),
registerInlineEditor({ id: 'text', start: startPlainText }),
registerInlineEditor({ id: 'markdown', start: startMarkdown }),
]
return () => off.forEach((f) => f())
}
// --- inline editors ------------------------------------------------------------
import type { InlineEditorDef } from '../registry'
const DEBOUNCE_MS = 350
// `field` is a dotted path ("title", "links.0.text"): explicit template markers use plain names, inferred ones may be nested.
const startPlainText: InlineEditorDef['start'] = ({ el, node, field, ctrl }) => {
const key = `inline:${node.id}:${field}`
const before = el.getAttribute('contenteditable')
el.setAttribute('contenteditable', 'plaintext-only')
if (el.contentEditable !== 'plaintext-only') el.setAttribute('contenteditable', 'true') // jsdom / old browsers
el.setAttribute('data-cms-editing', '')
el.focus()
try {
const sel = el.ownerDocument.defaultView!.getSelection()
const range = el.ownerDocument.createRange()
range.selectNodeContents(el)
sel?.removeAllRanges()
sel?.addRange(range) // select all: typing replaces, arrow keys place the caret
} catch {
/* no selection API */
}
let sent = el.textContent ?? ''
let timer: ReturnType<typeof setTimeout> | undefined
const flush = () => {
clearTimeout(timer)
const text = el.textContent ?? ''
if (text === sent) return
sent = text
void ctrl.setPath(node.id, field, text, { coalesce: key })
}
const onInput = () => {
clearTimeout(timer)
timer = setTimeout(flush, DEBOUNCE_MS)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !el.hasAttribute('data-cms-multiline')) {
e.preventDefault()
ctrl.stopInline()
}
}
const onBlur = () => ctrl.stopInline()
el.addEventListener('input', onInput)
el.addEventListener('keydown', onKey)
el.addEventListener('blur', onBlur)
return {
stop() {
el.removeEventListener('input', onInput)
el.removeEventListener('keydown', onKey)
el.removeEventListener('blur', onBlur)
flush()
if (before === null) el.removeAttribute('contenteditable')
else el.setAttribute('contenteditable', before)
el.removeAttribute('data-cms-editing')
},
}
}
const startMarkdown: InlineEditorDef['start'] = ({ el, node, field, ctrl }) => {
const key = `inline:${node.id}:${field}`
const handle = mountMarkdownEditor({
element: el,
markdown: String(getIn(node.data, parsePath(field)) ?? ''),
editorClass: el.className,
onChange: (md) => void ctrl.setPath(node.id, field, md, { coalesce: key }),
onExit: () => ctrl.stopInline(),
})
handle.focus()
return { stop: () => handle.destroy() }
}
+11
View File
@@ -0,0 +1,11 @@
// Examples of the extension points, active in dev only (imported from dev.ts).
// Copy one of these to add a real editor for a node type.
import { registerEditor } from './registry'
import type { EditorProps } from './registry'
function Inspect({ node }: EditorProps) {
return <pre style={{ margin: 0, fontSize: 12 }}>{JSON.stringify(node, null, 2)}</pre>
}
// A toolbar button + dialog on every block - the smallest possible editor.
registerEditor({ id: 'example-inspect', label: 'Inspect node', icon: 'inspect', placement: 'menu', match: '*', priority: -50, component: Inspect })
+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
}
+11
View File
@@ -0,0 +1,11 @@
// Production entry: served as /editor/onpage-editor.js and loaded by the page's
// own loader only when the preview cookie is set (docs/onpage-editor.md §2).
// It re-checks, so loading it by mistake never shows editor UI to visitors.
import { boot } from './boot'
export const previewEnabled = () => /(?:^|;\s*)cms_preview=1/.test(document.cookie)
if (previewEnabled()) boot()
export { boot }
export * from './registry'
+10
View File
@@ -0,0 +1,10 @@
/* Injected into the PAGE (not the shadow root): affordances for editing. */
html[data-cms-editing="edit"] [data-cms-field][data-cms-editor]:not([contenteditable]) { cursor: text; }
html[data-cms-editing="edit"] [data-cms] { cursor: default; }
[data-cms-field][contenteditable], [data-cms-field] .ProseMirror { outline: none; }
html[data-cms-editing="edit"] a { cursor: default; }
html[data-cms-dragging], html[data-cms-dragging] * { user-select: none !important; cursor: grabbing !important; }
/* every editable text field: a hint on hover (inferred ones only exist on the selected block) */
html[data-cms-editing="edit"] [data-cms-field][data-cms-editor]:not([contenteditable]):hover { outline: 1px dashed rgba(94,224,160,.9); outline-offset: 3px; }
html[data-cms-editing="edit"] [data-cms-inferred]:not([contenteditable]) { outline: 1px dotted rgba(94,224,160,.45); outline-offset: 3px; }
+139
View File
@@ -0,0 +1,139 @@
// Drop-target maths for drag & drop. Pure (rects in, target out) so it is testable without layout.
// - sortTarget / moveTarget: reordering an existing block snaps between its siblings (vertical stacks and
// horizontal rows; grids are treated as vertical).
// - dropTarget: "drop into whatever container is under the pointer", used for new blocks from the palette,
// for Alt-nesting, and when a block is dragged out of its parent.
export interface Rect {
x: number
y: number
w: number
h: number
}
export interface DndBlock {
id: string
parentId?: string
container: boolean
depth: number
rect: Rect
}
export interface DropTarget {
/** Container id, or '' for the page root. */
parentId: string
/** Index among the parent's children *excluding* the dragged block. */
index: number
/** Where to draw the insertion line (viewport coordinates). */
line: Rect
/** Sort mode: every candidate position between the siblings (the active one is `line`), to draw snap markers. */
snaps?: Rect[]
}
const inside = (r: Rect, p: { x: number; y: number }) => p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h
function descendantOf(blocks: DndBlock[], id: string, ancestor: string): boolean {
const byId = new Map(blocks.map((b) => [b.id, b]))
for (let cur = byId.get(id), n = 0; cur && n < 100; cur = cur.parentId ? byId.get(cur.parentId) : undefined, n++) {
if (cur.id === ancestor) return true
}
return false
}
export function dropTarget(blocks: DndBlock[], point: { x: number; y: number }, dragId: string | null, accepts?: (parentId: string) => boolean): DropTarget | null {
// Deepest container under the pointer that is not the dragged block or inside it.
const hit = blocks
.filter((b) => b.container && inside(b.rect, point) && !(dragId && descendantOf(blocks, b.id, dragId)) && (!accepts || accepts(b.id)))
.sort((a, b) => b.depth - a.depth)[0]
let parentId = hit?.id ?? ''
if (!hit) {
// Page level: only when the pointer is within the vertical extent of the top-level blocks.
const top = blocks.filter((b) => !b.parentId)
if (!top.length) return null
const y0 = Math.min(...top.map((b) => b.rect.y)) - 24
const y1 = Math.max(...top.map((b) => b.rect.y + b.rect.h)) + 24
const x0 = Math.min(...top.map((b) => b.rect.x)) - 24
const x1 = Math.max(...top.map((b) => b.rect.x + b.rect.w)) + 24
if (point.y < y0 || point.y > y1 || point.x < x0 || point.x > x1) return null // e.g. over the editor's own panels
parentId = ''
}
const kids = blocks.filter((b) => (b.parentId ?? '') === parentId && b.id !== dragId)
let index = kids.length
for (let i = 0; i < kids.length; i++) {
if (point.y < kids[i].rect.y + kids[i].rect.h / 2) {
index = i
break
}
}
const container = hit?.rect
let line: Rect
if (kids.length === 0) {
line = container ? { x: container.x + 8, y: container.y + 8, w: container.w - 16, h: 3 } : { x: 0, y: point.y, w: 0, h: 3 }
} else if (index < kids.length) {
const r = kids[index].rect
line = { x: r.x, y: r.y - 2, w: r.w, h: 3 }
} else {
const r = kids[kids.length - 1].rect
line = { x: r.x, y: r.y + r.h - 1, w: r.w, h: 3 }
}
return { parentId, index, line }
}
// --- sort mode: snap between siblings ---------------------------------------------------------------
/** Are the siblings laid out left-to-right (a row) rather than top-to-bottom? Grids count as vertical. */
function isRow(kids: Rect[]): boolean {
if (kids.length < 2) return false
return kids.every((k, i) => i === 0 || (k.x >= kids[i - 1].x + kids[i - 1].w - 1 && Math.min(k.y + k.h, kids[i - 1].y + kids[i - 1].h) - Math.max(k.y, kids[i - 1].y) > 0))
}
/**
* Reorder among the siblings of one parent: the target SNAPS to the nearest boundary between them
* (before the first, in each gap, after the last), however deep in a sibling the pointer is. That is what
* makes sorting root nodes (usually big containers) work: the pointer being "inside" a section no longer
* nests the block into it. `index` counts the siblings without the dragged block.
*/
export function sortTarget(blocks: DndBlock[], point: { x: number; y: number }, dragId: string | null, parentId: string): DropTarget | null {
const siblings = blocks.filter((b) => (b.parentId ?? '') === parentId)
const kids = siblings.filter((b) => b.id !== dragId).map((b) => b.rect)
if (!kids.length) return null
// The axis comes from the whole row, dragged block included: with one sibling left it cannot be inferred.
const row = isRow(siblings.map((b) => b.rect))
const x0 = Math.min(...kids.map((k) => k.x))
const y0 = Math.min(...kids.map((k) => k.y))
const w = Math.max(...kids.map((k) => k.x + k.w)) - x0
const h = Math.max(...kids.map((k) => k.y + k.h)) - y0
// boundary coordinate along the flow axis: before the first, centred in each gap, after the last
const pos = [kids[0][row ? 'x' : 'y'], ...kids.slice(1).map((k, i) => (row ? (kids[i].x + kids[i].w + k.x) / 2 : (kids[i].y + kids[i].h + k.y) / 2)), row ? kids[kids.length - 1].x + kids[kids.length - 1].w : kids[kids.length - 1].y + kids[kids.length - 1].h]
const lines = pos.map((p): Rect => (row ? { x: p - 1.5, y: y0, w: 3, h } : { x: x0, y: p - 1.5, w, h: 3 }))
const at = row ? point.x : point.y
let index = 0
pos.forEach((p, i) => {
if (Math.abs(p - at) < Math.abs(pos[index] - at)) index = i
})
return { parentId, index, line: lines[index], snaps: lines }
}
/** The page-level extent (all root blocks, plus a margin): outside it the pointer is over editor chrome. */
export function rootExtent(blocks: DndBlock[], margin = 24): Rect | null {
const top = blocks.filter((b) => !b.parentId)
if (!top.length) return null
const x0 = Math.min(...top.map((b) => b.rect.x)) - margin
const y0 = Math.min(...top.map((b) => b.rect.y)) - margin
return { x: x0, y: y0, w: Math.max(...top.map((b) => b.rect.x + b.rect.w)) + margin - x0, h: Math.max(...top.map((b) => b.rect.y + b.rect.h)) + margin - y0 }
}
/**
* Where an existing block lands when dragged. Default is sorting among its current siblings (snapping
* between them); holding Alt - or leaving the parent's area - switches to the general "drop into whatever
* container is under the pointer" behaviour.
*/
export function moveTarget(blocks: DndBlock[], point: { x: number; y: number }, drag: { id: string; parentId?: string }, opts: { nest?: boolean; accepts?: (parentId: string) => boolean } = {}): DropTarget | null {
const parentId = drag.parentId ?? ''
if (!opts.nest) {
const area = parentId ? blocks.find((b) => b.id === parentId)?.rect : rootExtent(blocks)
const pad = parentId ? 12 : 0
if (area && inside({ x: area.x - pad, y: area.y - pad, w: area.w + pad * 2, h: area.h + pad * 2 }, point)) {
const t = sortTarget(blocks, point, drag.id, parentId)
if (t) return t
}
}
return dropTarget(blocks, point, drag.id, opts.accepts)
}
+132
View File
@@ -0,0 +1,132 @@
// Make every string property editable where it is *rendered*.
//
// Templates only mark a few fields with `data-cms-field`. For the rest we bind the node's data to
// the DOM by value: a property whose text appears as exactly one leaf element of the block becomes
// an inline text field; one whose value appears as exactly one attribute (href, src, alt…) becomes an
// attribute chip. Matching is strict on purpose - ambiguous values (the same text twice, or two
// properties with the same value) are skipped rather than guessed, and those properties stay
// editable in the Properties dialog.
import type { JSONSchema } from '../api'
import { resolveSchema } from '../form/resolve'
export type Path = Array<string | number>
export interface StringLeaf {
path: Path
value: string
}
export interface AttrBinding {
el: HTMLElement
attr: string
path: string
value: string
}
export const pathKey = (p: Path) => p.join('.')
export const parsePath = (key: string): Path => key.split('.').map((s) => (/^\d+$/.test(s) ? Number(s) : s))
export function getIn(data: unknown, path: Path): unknown {
let cur: any = data
for (const k of path) cur = cur?.[k]
return cur
}
/**
* The `set` payload that writes `value` at `path`: a flat key for top-level fields, otherwise the whole
* top-level value rebuilt with the one leaf changed (`set` merges only at the top level).
*/
export function patchForPath(data: Record<string, any>, path: Path, value: unknown): Record<string, unknown> {
if (path.length === 1) return { [path[0]]: value }
const top = structuredClone(data[path[0]] ?? (typeof path[1] === 'number' ? [] : {}))
let cur: any = top
for (let i = 1; i < path.length - 1; i++) cur = cur[path[i]] ??= typeof path[i + 1] === 'number' ? [] : {}
cur[path[path.length - 1]] = value
return { [path[0]]: top }
}
const SKIP_EDITORS = new Set(['hidden', 'readonly', 'markdown', 'html', 'richtext', 'monaco', 'json', 'asset', 'node', 'dictionary'])
const LIFECYCLE = new Set(['lastModified', 'status', 'createdAt', 'updatedAt', 'id'])
/** Every editable string in `data`, with its path, skipping what cannot be edited as plain text. */
export function collectStrings(data: Record<string, any>, root?: JSONSchema): StringLeaf[] {
const out: StringLeaf[] = []
const walk = (value: unknown, path: Path, schema: JSONSchema | undefined, depth: number) => {
if (out.length >= 300 || depth > 6) return
const s = schema && root ? resolveSchema(root, schema).schema : undefined
if (s && (s.readOnly || s.enum || 'const' in s || SKIP_EDITORS.has(s['x-editor']))) return
if (typeof value === 'string') {
if (value.trim() !== '') out.push({ path, value })
} else if (Array.isArray(value)) {
value.forEach((v, i) => walk(v, [...path, i], s?.items && !Array.isArray(s.items) ? s.items : undefined, depth + 1))
} else if (value && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) {
if (depth === 0 && LIFECYCLE.has(k)) continue
const additional = s?.additionalProperties && typeof s.additionalProperties === 'object' ? s.additionalProperties : undefined
walk(v, [...path, k], s?.properties?.[k] ?? additional, depth + 1)
}
}
}
walk(data, [], root, 0)
return out
}
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
const ATTRS = ['href', 'src', 'alt', 'title', 'placeholder', 'aria-label', 'value']
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'SVG', 'NOSCRIPT', 'TEMPLATE', 'TEXTAREA', 'SELECT', 'OPTION'])
/** An element that is "just text": no element children (a `<br>` is fine). */
const isTextLeaf = (el: Element) => !SKIP_TAGS.has(el.tagName.toUpperCase()) && Array.from(el.children).every((c) => c.tagName === 'BR') && norm(el.textContent ?? '') !== ''
export interface Inferred {
text: Array<{ el: HTMLElement; path: string }>
attrs: AttrBinding[]
}
/**
* Match string leaves to the block's own DOM (`owns` excludes nested blocks, which bind their own data).
* Elements already carrying an explicit `data-cms-field` are left alone.
*/
export function inferBindings(block: HTMLElement, owns: (el: Element) => boolean, strings: StringLeaf[]): Inferred {
const els = Array.from(block.querySelectorAll<HTMLElement>('*')).filter((el) => owns(el))
// Only *template* markers count as explicit: our own earlier inference must stay re-inferable.
const explicit = (e: Element) => e.hasAttribute('data-cms-field') && !e.hasAttribute(INFERRED_ATTR)
const leaves = els.filter((el) => isTextLeaf(el) && !explicit(el))
const claimed = new Set(els.filter(explicit).map((e) => norm(e.textContent ?? '')))
// A value shared by several properties cannot be attributed to one of them.
const count = new Map<string, number>()
for (const s of strings) count.set(norm(s.value), (count.get(norm(s.value)) ?? 0) + 1)
const text: Inferred['text'] = []
const attrs: AttrBinding[] = []
for (const s of strings) {
const v = norm(s.value)
if (count.get(v) !== 1 || claimed.has(v)) continue
const hits = leaves.filter((el) => norm(el.textContent ?? '') === v)
if (hits.length === 1) {
text.push({ el: hits[0], path: pathKey(s.path) })
continue
}
if (hits.length > 1) continue
const found: AttrBinding[] = []
for (const el of els) for (const attr of ATTRS) if (el.getAttribute(attr) === s.value) found.push({ el, attr, path: pathKey(s.path), value: s.value })
if (found.length === 1) attrs.push(found[0])
}
return { text, attrs }
}
export const INFERRED_ATTR = 'data-cms-inferred'
/** Tag text leaves so the normal inline-edit machinery (click / double-click) picks them up. */
export function markInferred(block: HTMLElement, text: Inferred['text']) {
for (const el of block.querySelectorAll(`[${INFERRED_ATTR}]`)) {
el.removeAttribute('data-cms-field')
el.removeAttribute('data-cms-editor')
el.removeAttribute(INFERRED_ATTR)
}
for (const { el, path } of text) {
el.setAttribute('data-cms-field', path)
el.setAttribute('data-cms-editor', 'text')
el.setAttribute(INFERRED_ATTR, '')
}
}
+165
View File
@@ -0,0 +1,165 @@
// Extension points. Everything the editor can attach to a node is looked up
// here at runtime, so new dialogs/editors can be added (or replaced) by calling
// one function - from the bundle, from another script, or from the console via
// `window.__cmsEditor.registerEditor(...)`.
import type { ComponentType } from 'react'
import type { CmsNode, JSONSchema } from './api'
import type { Controller } from './controller'
/** What the editor knows about a block on the page. */
export interface BlockInfo {
id: string
type: string
el: HTMLElement
container: boolean
parentId?: string
/** The node type's JSON Schema, once loaded (undefined for a moment after a type first appears). */
schema?: JSONSchema
}
export interface Registration<T> {
def: T
/** Remove it again. */
dispose(): void
}
class Registry<T extends { id: string }> {
private items = new Map<string, T>()
private listeners = new Set<() => void>()
version = 0
add(def: T): () => void {
this.items.set(def.id, def) // same id replaces: that's how defaults are overridden
this.bump()
return () => {
if (this.items.get(def.id) === def) {
this.items.delete(def.id)
this.bump()
}
}
}
all(): T[] {
return [...this.items.values()]
}
get(id: string) {
return this.items.get(id)
}
subscribe(fn: () => void) {
this.listeners.add(fn)
return () => void this.listeners.delete(fn)
}
private bump() {
this.version++
this.listeners.forEach((f) => f())
}
}
// --- block editors: dialogs / popovers attached to a node -------------------
export interface EditorProps {
info: BlockInfo
node: CmsNode
schema: JSONSchema
ctrl: Controller
/** Close the dialog this editor is shown in. */
close(): void
}
export type Match = string | string[] | ((info: BlockInfo) => boolean)
export interface BlockEditorDef {
id: string
/** Toolbar tooltip / dialog title. */
label: string
/** Icon name from `ui/Icon` (e.g. 'edit', 'style'); falls back to the label's first letter. */
icon?: string
/** `menu` puts it under the toolbar's "…" overflow instead of as a button (rarely-used / developer tools). */
placement?: 'toolbar' | 'menu'
/** Node type(s), '*' for all, or a predicate (e.g. `info => info.el.matches('.hero')`). */
match: Match
component: ComponentType<EditorProps>
/** Higher = earlier in the toolbar. Default 0. */
priority?: number
/** Dialog size hint. */
size?: 'sm' | 'md' | 'lg'
/** Open on double-click of the block (the highest-priority editor with this wins). */
onDoubleClick?: boolean
}
export const blockEditors = new Registry<BlockEditorDef>()
export const registerEditor = (def: BlockEditorDef) => blockEditors.add(def)
export function matches(m: Match, info: BlockInfo): boolean {
if (typeof m === 'function') return m(info)
const list = Array.isArray(m) ? m : [m]
return list.includes('*') || list.includes(info.type)
}
export function editorsFor(info: BlockInfo): BlockEditorDef[] {
return blockEditors
.all()
.filter((d) => matches(d.match, info))
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
}
/**
* Attach an ad-hoc dialog to blocks without defining a type: by CSS selector,
* by node id, or by element. It shows up as a toolbar button on matching blocks.
*/
export function attachDialog(
target: string | HTMLElement | { nodeId: string },
component: ComponentType<EditorProps>,
opts: Partial<Omit<BlockEditorDef, 'component' | 'match'>> = {},
): () => void {
const match = (info: BlockInfo) =>
typeof target === 'string' ? info.el.matches(target) : target instanceof HTMLElement ? info.el === target : info.id === target.nodeId
return registerEditor({
id: opts.id ?? `attached:${Math.random().toString(36).slice(2, 8)}`,
label: opts.label ?? 'Edit',
match,
component,
...opts,
})
}
// --- field editors: widgets inside the schema form --------------------------
export interface FieldProps {
/** Resolved schema of this field. */
schema: JSONSchema
value: unknown
onChange(v: unknown): void
/** Property name / path, for labels and ids. */
path: string
node: CmsNode
ctrl: Controller
}
export interface FieldEditorDef {
id: string
/** Decide from the field schema (`x-editor`, `format`, name, ...) and its path. */
match(schema: JSONSchema, path: string, node: CmsNode): boolean
component: ComponentType<FieldProps>
priority?: number
}
export const fieldEditors = new Registry<FieldEditorDef>()
export const registerFieldEditor = (def: FieldEditorDef) => fieldEditors.add(def)
export const fieldEditorFor = (schema: JSONSchema, path: string, node: CmsNode) =>
fieldEditors
.all()
.filter((d) => d.match(schema, path, node))
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0]
// --- inline editors: edit a rendered field in place -------------------------
export interface InlineSession {
/** Stop editing, flushing pending changes. */
stop(): void
}
export interface InlineEditorDef {
/** Value of `data-cms-editor`. */
id: string
start(args: { el: HTMLElement; node: CmsNode; field: string; ctrl: Controller }): InlineSession
}
export const inlineEditors = new Registry<InlineEditorDef>()
export const registerInlineEditor = (def: InlineEditorDef) => inlineEditors.add(def)
+97
View File
@@ -0,0 +1,97 @@
// Helpers over the CMS's JSON Schemas (`GET /api/schemas/{type}`), including the
// `x-graph` extension that says what a node may contain.
import type { JSONSchema } from './api'
export interface XGraph {
children?: string[]
connections?: Record<string, unknown>
accepts?: string[]
[k: string]: unknown
}
export const xGraph = (schema?: JSONSchema): XGraph => (schema?.['x-graph'] as XGraph) ?? {}
/** A type entry from `GET /api/types`, tolerant of the field it calls the name. */
export const typeName = (t: unknown): string => {
if (typeof t === 'string') return t
const o = (t ?? {}) as Record<string, unknown>
return String(o.type ?? o.name ?? o.dataType ?? o.id ?? '')
}
/**
* Node types that may be inserted as children of a node with this schema. The real schemas say
* `x-graph.children: true` (anything), a list of type names, or `['*']`; `accepts` is the older spelling.
*/
export function allowedChildren(schema: JSONSchema | undefined, allTypes: string[]): string[] {
const g = xGraph(schema)
const rule = (g.children ?? g.accepts) as unknown
if (rule === true) return allTypes
if (!Array.isArray(rule)) return []
return rule.includes('*') ? allTypes : (rule as string[])
}
/** Name of the property that titles a node of this type (`x-graph.label`), e.g. "title". */
export const labelKey = (schema?: JSONSchema): string | undefined => {
const l = xGraph(schema).label
return typeof l === 'string' && l ? l : undefined
}
/** Category of a node type (`x-graph.group`), e.g. "Commerce". */
export const groupOf = (schema?: JSONSchema): string | undefined => {
const g = xGraph(schema).group
return typeof g === 'string' && g ? g : undefined
}
export const OTHER_GROUP = 'Other'
/** Group names in display order: alphabetical, with the catch-all last. */
export const sortGroups = (names: Iterable<string>): string[] => [...new Set(names)].sort((a, b) => (a === OTHER_GROUP ? 1 : b === OTHER_GROUP ? -1 : a.localeCompare(b)))
/** Can a node of `type` be dropped inside a container of this schema? Unknown schema = optimistic. */
export function acceptsChild(schema: JSONSchema | undefined, type: string, allTypes: string[]): boolean {
if (!schema) return true
return allowedChildren(schema, allTypes).includes(type)
}
/** Resolve a local `$ref` ("#/$defs/x") against the root schema. */
export function deref(root: JSONSchema, s: JSONSchema | undefined): JSONSchema {
let cur = s ?? {}
for (let i = 0; i < 8 && typeof cur.$ref === 'string'; i++) {
const path = cur.$ref.replace(/^#\//, '').split('/')
let t: any = root
for (const p of path) t = t?.[p]
cur = { ...t, ...Object.fromEntries(Object.entries(cur).filter(([k]) => k !== '$ref')) }
}
return cur
}
export type FieldKind = 'string' | 'text' | 'markdown' | 'number' | 'boolean' | 'enum' | 'object' | 'array' | 'json'
/** Pick the widget for a (resolved) schema node. */
export function fieldKind(s: JSONSchema): FieldKind {
if (s.enum) return 'enum'
const editor = s['x-editor'] ?? s.format
if (editor === 'markdown') return 'markdown'
if (editor === 'textarea' || editor === 'text' || editor === 'html') return 'text'
const t = Array.isArray(s.type) ? s.type.find((x: string) => x !== 'null') : s.type
switch (t) {
case 'string':
return s.maxLength && s.maxLength > 200 ? 'text' : 'string'
case 'integer':
case 'number':
return 'number'
case 'boolean':
return 'boolean'
case 'object':
return s.properties ? 'object' : 'json'
case 'array':
return 'json'
default:
return s.properties ? 'object' : 'json'
}
}
export const labelOf = (key: string, s: JSONSchema) => s.title ?? key.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
/** Properties the user shouldn't edit: lifecycle keys are rejected by the server. */
export const READONLY_KEYS = new Set(['lastModified', 'status', 'createdAt', 'updatedAt', 'id'])
+44
View File
@@ -0,0 +1,44 @@
import { useRef, useSyncExternalStore } from 'react'
export interface Store<T> {
get(): T
set(patch: Partial<T> | ((s: T) => Partial<T>)): void
subscribe(fn: () => void): () => void
}
/** Minimal external store; `useStore` binds it to React. */
export function createStore<T extends object>(initial: T): Store<T> {
let state = initial
const subs = new Set<() => void>()
return {
get: () => state,
set(patch) {
const next = typeof patch === 'function' ? patch(state) : patch
state = { ...state, ...next }
subs.forEach((f) => f())
},
subscribe(fn) {
subs.add(fn)
return () => void subs.delete(fn)
},
}
}
const shallowEqual = (a: unknown, b: unknown): boolean => {
if (Object.is(a, b)) return true
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false
const ka = Object.keys(a)
return ka.length === Object.keys(b).length && ka.every((k) => Object.is((a as any)[k], (b as any)[k]))
}
/** Subscribe to a slice. Selectors may return fresh objects: results are shallow-compared and memoised. */
export function useStore<T extends object, S>(store: Store<T>, select: (s: T) => S): S {
const last = useRef<{ v: S } | null>(null)
const get = () => {
const next = select(store.get())
if (last.current && shallowEqual(last.current.v, next)) return last.current.v
last.current = { v: next }
return next
}
return useSyncExternalStore(store.subscribe, get)
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Block } from '../controller'
import { useCtrl, useEditorState } from '../ui/hooks'
import { computeHandles, dragPx, previewProperty } from './handles'
import type { Box, Handle, Rect } from './handles'
import { describeStyle, effective } from './model'
import { pxOf, snap } from './tokens'
const px = (v: string) => parseFloat(v) || 0
const boxOf = (cs: CSSStyleDeclaration, p: 'padding' | 'margin'): Box => ({ top: px(cs.getPropertyValue(`${p}-top`)), right: px(cs.getPropertyValue(`${p}-right`)), bottom: px(cs.getPropertyValue(`${p}-bottom`)), left: px(cs.getPropertyValue(`${p}-left`)) })
const rectOf = (el: Element): Rect => {
const r = el.getBoundingClientRect()
return { x: r.x, y: r.y, w: r.width, h: r.height }
}
const css = (r: Rect) => ({ left: r.x, top: r.y, width: r.w, height: r.h })
/**
* Padding / margin / gap bands drawn over the selected block (like devtools), each with a grip you
* drag. Dragging snaps to the field's token scale, previews live, and commits one coalesced `set`.
* Only sides the schema can express get a grip - a block with no spacing fields shows nothing.
*/
export function BoxHandles({ block }: { block: Block }) {
const ctrl = useCtrl()
useEditorState((s) => s.tick)
useEditorState((s) => s.schemasVersion)
const fields = useMemo(() => describeStyle(block.schema).filter((f) => f.options.length > 0 && (f.kind === 'spacing' || f.kind === 'gap')), [block.schema])
const [drag, setDrag] = useState<{ handle: Handle; token: string; label: string } | null>(null)
if (!fields.length || !block.el.isConnected) return null
const cs = block.el.ownerDocument.defaultView!.getComputedStyle(block.el)
const handles = computeHandles({
rect: rectOf(block.el),
padding: boxOf(cs, 'padding'),
margin: boxOf(cs, 'margin'),
children: Array.from(block.el.children).map(rectOf),
fields,
})
return (
<>
{handles.map((h) => (
<div key={`b-${h.id}`} className={`cms-band cms-band--${h.kind}`} style={css(h.band)} aria-hidden="true" />
))}
{handles.map((h) => (
<Grip key={h.id} handle={h} block={block} active={drag?.handle.id === h.id} onDrag={setDrag} />
))}
{drag && (
<div className="cms-tip" style={{ left: drag.handle.grip.x + drag.handle.grip.w + 8, top: drag.handle.grip.y - 4 }}>
{drag.label} · <b>{drag.token}</b>
</div>
)}
</>
)
}
function Grip({ handle: h, block, active, onDrag }: { handle: Handle; block: Block; active: boolean; onDrag(d: { handle: Handle; token: string; label: string } | null): void }) {
const ctrl = useCtrl()
// px is captured once at pointer-down: the live handle's px includes our own preview and would compound.
const start = useRef<{ x: number; y: number; px: number; value?: string } | null>(null)
const [hover, setHover] = useState(false)
useEffect(() => () => ctrl.clearPreview(), [ctrl])
const tokenAt = (e: React.PointerEvent) => {
const s = start.current!
const scale = ctrl.scaleFor(h.field)
const now = dragPx({ axis: h.axis, sign: h.sign, px: s.px }, e.clientX - s.x, e.clientY - s.y)
const token = snap(scale, s.value, s.px, now - s.px)
return { token, scale }
}
return (
<div
className={`cms-grip-h cms-grip-h--${h.kind} cms-grip-h--${h.axis}${active ? ' is-active' : ''}`}
style={css(h.grip)}
role="slider"
aria-label={`${h.label}: ${Math.round(h.px)}px`}
aria-valuenow={Math.round(h.px)}
title={`${h.label} · ${Math.round(h.px)}px (drag)`}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
e.currentTarget.setPointerCapture?.(e.pointerId)
start.current = { x: e.clientX, y: e.clientY, px: h.px }
// The starting token is only needed for unmeasured (ordinal) scales; resolve it without blocking the drag.
void ctrl.node(block.id).then((n) => {
if (start.current) start.current.value = String(effective(n.data ?? {}, ctrl.styleFields(block.id), h.field).value ?? '') || undefined
})
}}
onPointerMove={(e) => {
if (!start.current) return
const { token, scale } = tokenAt(e)
const target = pxOf(scale, token)
if (target !== null) ctrl.previewStyle(block.id, previewProperty(h.field, h.edge), target)
onDrag({ handle: h, token, label: h.label })
}}
onPointerUp={async (e) => {
if (!start.current) return
const { token } = tokenAt(e)
const moved = Math.abs(e.clientX - start.current.x) + Math.abs(e.clientY - start.current.y) > 2
start.current = null
onDrag(null)
if (moved) await ctrl.setStyle(block.id, h.field, token)
ctrl.clearPreview()
}}
onPointerCancel={() => {
start.current = null
ctrl.clearPreview()
onDrag(null)
}}
data-hover={hover || undefined}
/>
)
}
+247
View File
@@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { EditorProps } from '../registry'
import { useEditorState } from '../ui/hooks'
import { BREAKPOINTS, effective, patchFor, readField } from './model'
import type { Breakpoint, StyleField } from './model'
import { pxOf } from './tokens'
import type { Scale } from './tokens'
import { Icon } from '../ui/Icon'
import { describeStyle, fieldForSide } from './model'
type Data = Record<string, any>
const LEGACY_FLOW = ['vertical', 'horizontal', 'grid']
const ICON: Record<string, Record<string, string>> = {
flow: { row: 'flow-row', stack: 'flow-stack', actions: 'flow-actions', vertical: 'flow-stack', horizontal: 'flow-row', grid: 'flow-grid' },
align: { start: 'align-start', center: 'align-center', end: 'align-end', left: 'text-left', right: 'text-right' },
justify: { start: 'align-start', center: 'align-center', end: 'align-end' },
}
const cap = (o: string) => o[0].toUpperCase() + o.slice(1)
/** Apply a `set` payload to local data the way the server will (null deletes). */
const applyLocal = (data: Data, patch: Record<string, unknown>): Data => {
const next = { ...data }
for (const [k, v] of Object.entries(patch)) v === null ? delete next[k] : (next[k] = v)
return next
}
/**
* The "Style" dialog: controls for every schema field tagged `x-style` (or inferred from naming),
* grouped by what they do. Nothing here knows a node type - add an `x-style` field to any schema
* and it shows up. Spacing is a box-model diagram; breakpoints edit `responsive.<bp>.*`.
*/
export function StylePanel({ node, schema, ctrl }: EditorProps) {
const fields = useMemo(() => describeStyle(schema), [schema])
const [data, setData] = useState<Data>(node.data ?? {})
const [bp, setBp] = useState<Breakpoint | undefined>()
const rev = useEditorState((s) => s.editRev)
useEffect(() => {
if (rev) void ctrl.node(node.id, true).then((n) => setData(n.data ?? {})).catch(() => {})
}, [rev, ctrl, node.id])
const hasResponsive = fields.some((f) => f.responsive.length)
const shown = bp ? fields.filter((f) => f.responsive.includes(bp)) : fields
const set = (f: StyleField, v: unknown) => {
setData((d) => applyLocal(d, patchFor(d, f, v, bp)))
void ctrl.setStyle(node.id, f, v, { bp })
}
const get = (f: StyleField) => readField(data, f, bp)
const of = (...kinds: StyleField['kind'][]) => shown.filter((f) => kinds.includes(f.kind))
const flow = fields.find((f) => f.kind === 'flow')
const flowValue = flow ? String(readField(data, flow, bp) ?? data[flow.name] ?? '') : ''
const showColumns = !flow || !flowValue || ['row', 'grid', 'horizontal'].includes(flowValue)
return (
<div className="cms-style">
{hasResponsive && (
<div className="cms-tabs" role="tablist" aria-label="Breakpoint">
{[undefined, ...BREAKPOINTS].map((b) => (
<button key={b ?? 'base'} role="tab" aria-selected={bp === b} className={bp === b ? 'is-on' : ''} onClick={() => setBp(b)}>
{b ? b[0].toUpperCase() + b.slice(1) : 'All sizes'}
</button>
))}
</div>
)}
{bp && <p className="cms-muted">Overrides for {bp === 'mobile' ? 'small screens' : bp === 'tablet' ? 'tablets' : 'desktops'}; unset values inherit from “All sizes”.</p>}
{of('flow', 'columns', 'align', 'justify').length > 0 && (
<Section title="Layout">
{of('flow', 'columns', 'align', 'justify')
.filter((f) => f.kind !== 'columns' || showColumns)
.map((f) => (
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
))}
</Section>
)}
{!bp && fields.some((f) => f.kind === 'spacing' || f.kind === 'gap') && (
<Section title="Spacing">
<BoxModel fields={fields} data={data} onChange={set} ctrl={ctrl} />
{fields.filter((f) => f.kind === 'gap').map((f) => (
<Control key={f.name} f={f} value={data[f.name]} onChange={(v) => set(f, v)} scale={f.options.length ? ctrl.scaleFor(f) : undefined} />
))}
</Section>
)}
{of('tone', 'border').length > 0 && (
<Section title="Surface">
{of('tone', 'border').map((f) => (
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
))}
</Section>
)}
{of('reveal', 'choice').length > 0 && (
<Section title="More">
{of('reveal', 'choice').map((f) => (
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
))}
</Section>
)}
{fields.some((f) => f.inferred) && <p className="cms-muted cms-style__note">Controls detected from field names. Add <code>x-style</code> to the schema to make this explicit.</p>}
</div>
)
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="cms-style__section">
<h4>{title}</h4>
{children}
</section>
)
}
// --- controls ------------------------------------------------------------------------
function Control({ f, value, onChange, scale }: { f: StyleField; value: unknown; onChange(v: unknown): void; scale?: Scale }) {
const set = value !== undefined && value !== null
return (
<div className="cms-ctl" data-style-field={f.name}>
<div className="cms-ctl__head">
<span className="cms-ctl__label" title={f.description}>
{f.label}
</span>
{set && (
<button type="button" className="cms-ctl__clear" aria-label={`Reset ${f.label}`} title="Reset (inherit)" onClick={() => onChange(undefined)}>
<Icon name="refresh" size={14} />
</button>
)}
</div>
<Widget f={f} value={value} onChange={onChange} scale={scale} />
</div>
)
}
function Widget({ f, value, onChange, scale }: { f: StyleField; value: unknown; onChange(v: unknown): void; scale?: Scale }) {
if (f.kind === 'columns') return <Columns f={f} value={value} onChange={onChange} />
const icons = ICON[f.kind] ?? {}
let options = f.options.map(String)
if (f.kind === 'flow' && !LEGACY_FLOW.includes(String(value))) options = options.filter((o) => !LEGACY_FLOW.includes(o))
if (options.length > 6) {
return (
<select className="cms-input" value={value === undefined ? '' : String(value)} onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}>
<option value="">—</option>
{options.map((o) => (
<option key={o}>{o}</option>
))}
</select>
)
}
return <Chips options={options} value={value as string | undefined} onChange={onChange} icons={icons} scale={scale} />
}
function Chips({ options, value, onChange, icons = {}, scale }: { options: string[]; value?: string; onChange(v: string): void; icons?: Record<string, string>; scale?: Scale }) {
return (
<div className="cms-chips" role="radiogroup">
{options.map((o) => {
const px = scale ? pxOf(scale, o) : null
return (
<button key={o} type="button" role="radio" aria-checked={value === o} className={`cms-chip${value === o ? ' is-on' : ''}`} title={px !== null ? `${Math.round(px)}px` : undefined} onClick={() => onChange(o)}>
{icons[o] && <Icon name={icons[o]} size={15} />}
{icons[o] ? cap(o) : o}
</button>
)
})}
</div>
)
}
function Columns({ f, value, onChange }: { f: StyleField; value: unknown; onChange(v: unknown): void }) {
if (f.options.length) return <Chips options={f.options.map(String)} value={value === undefined ? undefined : String(value)} onChange={(v) => onChange(Number(v))} />
const min = f.min ?? 1
const max = f.max ?? 12
const n = typeof value === 'number' ? value : undefined
return (
<div className="cms-stepper">
<button type="button" className="cms-ico" aria-label="Fewer columns" disabled={(n ?? min) <= min} onClick={() => onChange(Math.max(min, (n ?? min + 1) - 1))}>
−
</button>
<input className="cms-input" type="number" aria-label="Columns" min={min} max={max} value={n ?? ''} placeholder="auto" onChange={(e) => onChange(e.target.value === '' ? undefined : Math.min(max, Math.max(min, Number(e.target.value))))} />
<button type="button" className="cms-ico" aria-label="More columns" disabled={(n ?? 0) >= max} onClick={() => onChange(Math.min(max, (n ?? min - 1) + 1))}>
+
</button>
</div>
)
}
// --- box model ---------------------------------------------------------------------------
type Edge = 'top' | 'right' | 'bottom' | 'left'
/** margin ⟶ padding diagram: one token select per side the schema can express. */
function BoxModel({ fields, data, onChange, ctrl }: { fields: StyleField[]; data: Data; onChange(f: StyleField, v: unknown): void; ctrl: EditorProps['ctrl'] }) {
const boxes = (['margin', 'padding'] as const).filter((b) => fields.some((f) => f.kind === 'spacing' && f.box === b && f.options.length))
if (!boxes.length) return null
const cell = (box: 'margin' | 'padding', edge: Edge, seenAll: Set<string>) => {
const f = fieldForSide(fields, box, edge)
if (!f) return <span className="cms-box__none">–</span>
if (f.side === 'all' || f.side === 'x' || f.side === 'y') {
// One field covers several edges: put its select on the first, mark the rest as linked.
if (seenAll.has(f.name)) return <span className="cms-box__link" title={`${f.label} (shared)`}>
<Icon name="link" size={12} /> {f.side === 'all' ? 'all' : f.side}
</span>
seenAll.add(f.name)
}
const { value, from } = effective(data, fields, f)
const own = data[f.name]
const scale = ctrl.scaleFor(f)
return (
<select
className={`cms-box__sel${own === undefined || own === null ? ' is-inherited' : ''}`}
aria-label={`${box} ${f.side === 'all' ? 'all sides' : edge}`}
value={own === undefined || own === null ? '' : String(own)}
onChange={(e) => onChange(f, e.target.value === '' ? undefined : e.target.value)}
title={f.description}
>
<option value="">{value !== undefined && from !== f ? `${String(value)} (${from.name})` : '—'}</option>
{f.options.map((o) => {
const px = pxOf(scale, String(o))
return (
<option key={String(o)} value={String(o)}>
{String(o)}
{px !== null ? ` · ${Math.round(px)}` : ''}
</option>
)
})}
</select>
)
}
const render = (i: number): ReactNode => {
const box = boxes[i]
const seen = new Set<string>()
return (
<div className={`cms-box cms-box--${box}`} key={box}>
<span className="cms-box__title">{box}</span>
<div className="cms-box__top">{cell(box, 'top', seen)}</div>
<div className="cms-box__left">{cell(box, 'left', seen)}</div>
<div className="cms-box__mid">{i + 1 < boxes.length ? render(i + 1) : <span className="cms-box__content">content</span>}</div>
<div className="cms-box__right">{cell(box, 'right', seen)}</div>
<div className="cms-box__bottom">{cell(box, 'bottom', seen)}</div>
</div>
)
}
return <div className="cms-boxmodel">{render(0)}</div>
}
+177
View File
@@ -0,0 +1,177 @@
// On-page spacing handles: pure geometry (rects in, handles out) and drag maths,
// so it is testable without layout. The overlay component only draws these and
// feeds pointer deltas back in.
import { fieldForSide, gapField } from './model'
import type { StyleField } from './model'
export interface Rect {
x: number
y: number
w: number
h: number
}
export interface Box {
top: number
right: number
bottom: number
left: number
}
export type Edge = 'top' | 'right' | 'bottom' | 'left'
export interface Handle {
id: string
kind: 'padding' | 'margin' | 'gap'
/** The coloured band showing the current size (viewport coordinates). */
band: Rect
/** The grab affordance, always GRIP px thick so a 0px value can still be grown. */
grip: Rect
axis: 'x' | 'y'
/** +1: dragging towards positive x/y grows it; -1: shrinks it. */
sign: 1 | -1
field: StyleField
/** Which CSS side/edge this handle sits on (for previews). */
edge: Edge | 'gap'
/** Current computed size in px. */
px: number
/** "padding top" / "gap": for the tooltip. */
label: string
/** True when the field covers more than this edge (editing "all sides"). */
shared: boolean
}
export const GRIP = 14
/** Row/column gaps between a container's direct children, from their rects. */
export function gapsBetween(children: Rect[]): Array<{ rect: Rect; axis: 'x' | 'y' }> {
const out: Array<{ rect: Rect; axis: 'x' | 'y' }> = []
const seen = new Set<string>()
const push = (rect: Rect, axis: 'x' | 'y') => {
const key = `${axis}:${Math.round(rect.x)}:${Math.round(rect.y)}:${Math.round(rect.w)}:${Math.round(rect.h)}`
if (rect.w > 0 && rect.h > 0 && !seen.has(key)) {
seen.add(key)
out.push({ rect, axis })
}
}
const overlapX = (a: Rect, b: Rect) => Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
const overlapY = (a: Rect, b: Rect) => Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
const sorted = [...children].sort((a, b) => a.y - b.y || a.x - b.x)
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) {
const a = sorted[i]
const b = sorted[j]
if (overlapX(a, b) > 0 && b.y >= a.y + a.h) {
// b sits below a in the same column: a horizontal band, but only for the nearest row
const blocked = sorted.some((c) => c !== a && c !== b && overlapX(a, c) > 0 && c.y >= a.y + a.h && c.y < b.y)
if (!blocked) push({ x: Math.max(a.x, b.x), y: a.y + a.h, w: overlapX(a, b), h: b.y - (a.y + a.h) }, 'y')
} else if (overlapY(a, b) > 0 && b.x >= a.x + a.w) {
const blocked = sorted.some((c) => c !== a && c !== b && overlapY(a, c) > 0 && c.x >= a.x + a.w && c.x < b.x)
if (!blocked) push({ x: a.x + a.w, y: Math.max(a.y, b.y), w: b.x - (a.x + a.w), h: overlapY(a, b) }, 'x')
}
}
}
return out
}
export interface HandleInput {
/** Border box of the block (viewport coordinates). */
rect: Rect
padding: Box
margin: Box
/** Rects of the direct children (cells), for gap bands. */
children: Rect[]
fields: StyleField[]
}
/** Only sides the schema can express get a handle (a node with no padding field gets none). */
export function computeHandles({ rect, padding, margin, children, fields }: HandleInput): Handle[] {
const out: Handle[] = []
const edges: Edge[] = ['top', 'right', 'bottom', 'left']
for (const box of ['padding', 'margin'] as const) {
const sizes = box === 'padding' ? padding : margin
const inner = box === 'padding'
for (const edge of edges) {
const field = fieldForSide(fields, box, edge)
if (!field) continue
const px = Math.max(sizes[edge], 0)
const vertical = edge === 'top' || edge === 'bottom'
// The band is inside the border box for padding, outside it for margin.
let band: Rect
let free: number // coordinate of the band edge where the size "ends" (the drag handle sits here)
if (edge === 'top') {
band = inner ? { x: rect.x, y: rect.y, w: rect.w, h: px } : { x: rect.x, y: rect.y - px, w: rect.w, h: px }
free = inner ? rect.y + px : rect.y - px
} else if (edge === 'bottom') {
band = inner ? { x: rect.x, y: rect.y + rect.h - px, w: rect.w, h: px } : { x: rect.x, y: rect.y + rect.h, w: rect.w, h: px }
free = inner ? rect.y + rect.h - px : rect.y + rect.h + px
} else if (edge === 'left') {
band = inner ? { x: rect.x, y: rect.y, w: px, h: rect.h } : { x: rect.x - px, y: rect.y, w: px, h: rect.h }
free = inner ? rect.x + px : rect.x - px
} else {
band = inner ? { x: rect.x + rect.w - px, y: rect.y, w: px, h: rect.h } : { x: rect.x + rect.w, y: rect.y, w: px, h: rect.h }
free = inner ? rect.x + rect.w - px : rect.x + rect.w + px
}
const len = Math.min(48, vertical ? rect.w : rect.h)
const grip: Rect = vertical ? { x: rect.x + rect.w / 2 - len / 2, y: free - GRIP / 2, w: len, h: GRIP } : { x: free - GRIP / 2, y: rect.y + rect.h / 2 - len / 2, w: GRIP, h: len }
// Padding grows when its handle moves towards the middle of the box, margin when it moves away.
const towardsMiddle = edge === 'top' || edge === 'left' ? 1 : -1
const shared = field.side !== edge
out.push({
id: `${box}-${edge}`,
kind: box,
band,
grip,
axis: vertical ? 'y' : 'x',
sign: (inner ? towardsMiddle : -towardsMiddle) as 1 | -1,
field,
edge,
px,
label: `${box} ${field.side === 'all' ? '(all sides)' : shared ? `(${field.side})` : edge}`,
shared,
})
}
}
const gap = gapField(fields)
if (gap) {
for (const g of gapsBetween(children)) {
const vertical = g.axis === 'y'
const len = Math.min(48, vertical ? g.rect.w : g.rect.h)
const cx = g.rect.x + g.rect.w / 2
const cy = g.rect.y + g.rect.h / 2
out.push({
id: `gap-${g.axis}-${Math.round(g.rect.x)}-${Math.round(g.rect.y)}`,
kind: 'gap',
band: g.rect,
grip: vertical ? { x: cx - len / 2, y: cy - GRIP / 2, w: len, h: GRIP } : { x: cx - GRIP / 2, y: cy - len / 2, w: GRIP, h: len },
axis: g.axis,
sign: 1,
field: gap,
edge: 'gap',
px: vertical ? g.rect.h : g.rect.w,
label: 'gap',
shared: false,
})
}
}
return out
}
/** New size in px after the pointer moved by (dx, dy) from where the drag started. */
export function dragPx(h: Pick<Handle, 'axis' | 'sign' | 'px'>, dx: number, dy: number): number {
return Math.max(0, h.px + h.sign * (h.axis === 'y' ? dy : dx))
}
/** CSS property used to live-preview a drag for a field. */
export function previewProperty(f: StyleField, edge: Edge | 'gap'): string {
if (f.kind === 'gap') return 'gap'
const box = f.box ?? 'padding'
switch (f.side) {
case 'all':
return box
case 'x':
return `${box}-inline`
case 'y':
return `${box}-block`
default:
return `${box}-${f.side ?? (edge === 'gap' ? 'top' : edge)}`
}
}
+141
View File
@@ -0,0 +1,141 @@
// Style fields: which properties of a node type are *visual* controls, and which
// visual dimension each one is. Driven by the schema annotation `x-style`
// (see docs/styling.md), with a clearly separated fallback that infers the same
// descriptor from the property name + enum for schemas that are not annotated
// yet (e.g. the live site before the annotated schema is deployed).
import type { JSONSchema } from '../api'
import { resolveSchema } from '../form/resolve'
export type StyleKind = 'spacing' | 'gap' | 'flow' | 'columns' | 'align' | 'justify' | 'tone' | 'border' | 'reveal' | 'choice'
export type Side = 'all' | 'top' | 'bottom' | 'left' | 'right' | 'x' | 'y'
export type Breakpoint = 'mobile' | 'tablet' | 'desktop'
export const BREAKPOINTS: Breakpoint[] = ['mobile', 'tablet', 'desktop']
export interface StyleField {
/** Property name in the node's data. */
name: string
kind: StyleKind
/** spacing only */
box?: 'padding' | 'margin'
side?: Side
/** This field wins over `overrides` for the sides it covers (paddingTop over padding). */
overrides?: string
/** CSS custom property template that resolves an enum value to a length, e.g. `--k6n-sp-{value}`. */
token?: string
options: unknown[]
min?: number
max?: number
label: string
description?: string
/** true when derived from naming conventions rather than an `x-style` annotation */
inferred: boolean
/** Breakpoints whose `responsive.<bp>` object declares the same property. */
responsive: Breakpoint[]
}
const SCALE = ['xs', 'sm', 'md', 'lg', 'xl']
const isScale = (opts: unknown[]) => opts.includes('none') && SCALE.filter((x) => opts.includes(x)).length >= 3
const SIDE_SUFFIX: Record<string, Side> = { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right', X: 'x', Y: 'y', '': 'all' }
const labelOf = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
/** Fallback: the descriptor a property would carry if it were annotated, from its name and enum. */
function infer(name: string, s: JSONSchema): Partial<StyleField> | null {
const opts = Array.isArray(s.enum) ? s.enum : []
const m = /^(padding|margin)(Top|Bottom|Left|Right|X|Y)?$/.exec(name)
if (m && isScale(opts)) {
const side = SIDE_SUFFIX[m[2] ?? '']
return { kind: 'spacing', box: m[1] as 'padding' | 'margin', side, overrides: side === 'all' ? undefined : m[1] }
}
if (name === 'gap' && isScale(opts)) return { kind: 'gap' }
if (name === 'flow' && opts.length) return { kind: 'flow' }
if (name === 'columns' && (s.type === 'integer' || s.type === 'number')) return { kind: 'columns' }
if (name === 'align' && opts.length) return { kind: 'align' }
if (name === 'justify' && opts.length) return { kind: 'justify' }
if (name === 'tone' && opts.length) return { kind: 'tone' }
if (name === 'border' && opts.length) return { kind: 'border' }
if (name === 'reveal' && opts.length) return { kind: 'reveal' }
return null
}
export function describeStyle(root: JSONSchema | undefined): StyleField[] {
if (!root) return []
const top = resolveSchema(root, root).schema
const props: Record<string, JSONSchema> = top.properties ?? {}
const bpProps = Object.fromEntries(
BREAKPOINTS.map((bp) => [bp, resolveSchema(root, resolveSchema(root, props.responsive).schema.properties?.[bp]).schema.properties ?? {}] as const),
) as Record<Breakpoint, Record<string, JSONSchema>>
const out: StyleField[] = []
for (const [name, raw] of Object.entries(props)) {
const s = resolveSchema(root, raw).schema
const ann = s['x-style'] as Partial<StyleField> | undefined
const spec = ann?.kind ? ann : infer(name, s)
if (!spec?.kind) continue
out.push({
name,
kind: spec.kind,
box: spec.box,
side: spec.side ?? (spec.kind === 'spacing' ? 'all' : undefined),
overrides: spec.overrides,
token: spec.token,
options: Array.isArray(s.enum) ? s.enum : [],
min: s.minimum,
max: s.maximum,
label: s.title ?? labelOf(name),
description: s.description,
inferred: !ann?.kind,
responsive: BREAKPOINTS.filter((bp) => name in bpProps[bp]),
})
}
return out
}
/** Can blocks of this type be wrapped in it to style them? (`x-style.wrapper` on the schema root.) */
export const isWrapperSchema = (root: JSONSchema | undefined) => !!root?.['x-style']?.wrapper
// --- values ----------------------------------------------------------------------------
type Data = Record<string, any>
export function readField(data: Data, f: StyleField, bp?: Breakpoint): unknown {
return bp ? data.responsive?.[bp]?.[f.name] : data[f.name]
}
/** Effective value for a side, honouring `overrides` (paddingTop ?? padding). */
export function effective(data: Data, fields: StyleField[], f: StyleField): { value: unknown; from: StyleField } {
const own = data[f.name]
if (own !== undefined && own !== null) return { value: own, from: f }
const base = f.overrides ? fields.find((x) => x.name === f.overrides) : undefined
if (base) {
const v = data[base.name]
if (v !== undefined && v !== null) return { value: v, from: base }
}
return { value: undefined, from: f }
}
/**
* The `set` payload that writes `value` to field `f` (clearing it when undefined).
* Base fields are a flat key; breakpoint overrides rebuild the whole `responsive` object,
* since `set` merges only at the top level.
*/
export function patchFor(data: Data, f: StyleField, value: unknown, bp?: Breakpoint): Record<string, unknown> {
if (!bp) return { [f.name]: value === undefined ? null : value }
const responsive = structuredClone(data.responsive ?? {}) as Record<string, Record<string, unknown>>
const cur = { ...(responsive[bp] ?? {}) }
if (value === undefined) delete cur[f.name]
else cur[f.name] = value
if (Object.keys(cur).length) responsive[bp] = cur
else delete responsive[bp]
return { responsive: Object.keys(responsive).length ? responsive : null }
}
/** Which field controls `side` of `box`: exact side, then x/y, then all. */
export function fieldForSide(fields: StyleField[], box: 'padding' | 'margin', side: 'top' | 'bottom' | 'left' | 'right'): StyleField | undefined {
const axis: Side = side === 'top' || side === 'bottom' ? 'y' : 'x'
const mine = fields.filter((f) => f.kind === 'spacing' && f.box === box)
return mine.find((f) => f.side === side) ?? mine.find((f) => f.side === axis) ?? mine.find((f) => f.side === 'all')
}
export const gapField = (fields: StyleField[]) => fields.find((f) => f.kind === 'gap')
+87
View File
@@ -0,0 +1,87 @@
// Token scales: map the enum values of a spacing field ("md") to real pixels, and
// snap a dragged distance back to the nearest token. Pixels come from the page's
// own CSS (a hidden probe resolving `var(--k6n-sp-md)`), so the editor never
// hard-codes a design system and fluid tokens (clamp/vw) measure correctly.
import type { StyleField } from './model'
export interface Scale {
values: string[]
/** px per value, or null when it could not be measured (then drags step ordinally). */
px: Array<number | null>
}
/** Dragging without px data: one token step per this many pixels. */
export const ORDINAL_STEP_PX = 28
/**
* Find the spacing custom-property prefix by scanning the page's stylesheets for `--<x>-sp-md`.
* Only a fallback for schemas without `x-style.token`.
*/
export function discoverSpacingVar(doc: Document): string | undefined {
for (const sheet of Array.from(doc.styleSheets)) {
let rules: CSSRuleList
try {
rules = sheet.cssRules
} catch {
continue // cross-origin sheet
}
for (const rule of Array.from(rules)) {
const style = (rule as CSSStyleRule).style
if (!style) continue
for (let i = 0; i < style.length; i++) {
const m = /^(--[\w-]*?-sp)-md$/.exec(style[i])
if (m) return `${m[1]}-{value}`
}
}
}
return undefined
}
export type Measure = (cssVar: string) => number | null
/** Resolve a custom property to px with a throw-away probe element. */
export function probeMeasure(doc: Document): Measure {
return (cssVar) => {
const probe = doc.createElement('div')
probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;padding-left:var(${cssVar});`
doc.body.appendChild(probe)
const px = parseFloat(doc.defaultView!.getComputedStyle(probe).paddingLeft)
probe.remove()
return Number.isFinite(px) ? px : null
}
}
export function measureScale(field: StyleField, measure: Measure, doc?: Document): Scale {
const template = field.token ?? (doc ? discoverSpacingVar(doc) : undefined)
const values = field.options.map(String)
return { values, px: values.map((v) => (template ? measure(template.replace('{value}', v)) : null)) }
}
const measured = (s: Scale) => s.px.filter((p) => p !== null).length >= 2
export const pxOf = (s: Scale, value: string): number | null => s.px[s.values.indexOf(value)] ?? null
/**
* The token to use after dragging from `startValue` by `deltaPx` (positive = bigger).
* Measured scales snap to the nearest real size; unmeasured ones step ordinally.
*/
export function snap(scale: Scale, startValue: string | undefined, startPx: number, deltaPx: number): string {
const { values, px } = scale
if (measured(scale)) {
const target = Math.max(0, startPx + deltaPx)
let best = 0
let bestD = Infinity
px.forEach((p, i) => {
if (p === null) return
const d = Math.abs(p - target)
if (d < bestD) {
bestD = d
best = i
}
})
return values[best]
}
const from = Math.max(0, startValue ? values.indexOf(startValue) : 0)
const idx = Math.min(values.length - 1, Math.max(0, from + Math.round(deltaPx / ORDINAL_STEP_PX)))
return values[idx]
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect } from 'react'
import type { Controller } from '../controller'
import { CtrlContext } from './hooks'
import { Overlay } from './Overlay'
import { Dock, Toast } from './Dock'
import { Dialogs } from './Dialogs'
import { Rail } from './Rail'
import { LoginPanel } from './LoginPanel'
import { DevPanel } from './DevPanel'
export function App({ ctrl }: { ctrl: Controller }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement
if (e.key === '`' && !t.isContentEditable && !/^(input|textarea|select)$/i.test(t.tagName)) ctrl.store.set((s) => ({ dev: !s.dev }))
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [ctrl])
return (
<CtrlContext.Provider value={ctrl}>
<Overlay />
<Rail />
<Dialogs />
<DevPanel />
<LoginPanel />
<Dock />
<Toast />
</CtrlContext.Provider>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react'
import type { CmsNode, JSONSchema } from '../api'
import { blockEditors, fieldEditors, inlineEditors } from '../registry'
import { Icon } from './Icon'
import { useCtrl, useEditorState, useRegistryVersion } from './hooks'
type Tab = 'requests' | 'selection' | 'registry'
/** Developer panel: what the editor sent, what it knows, and what is registered. Toggle with ` (backtick). */
export function DevPanel() {
const ctrl = useCtrl()
const open = useEditorState((s) => s.dev)
const [tab, setTab] = useState<Tab>('requests')
if (!open) return null
return (
<aside className="cms-dev" aria-label="Developer panel">
<header>
{(['requests', 'selection', 'registry'] as Tab[]).map((t) => (
<button key={t} className={t === tab ? 'is-on' : ''} onClick={() => setTab(t)}>
{t}
</button>
))}
<button className="cms-ico" onClick={() => ctrl.store.set({ dev: false })} aria-label="Close developer panel">
<Icon name="close" />
</button>
</header>
<div className="cms-dev__body">{tab === 'requests' ? <Requests /> : tab === 'selection' ? <Selection /> : <Registry />}</div>
</aside>
)
}
function Requests() {
const log = useEditorState((s) => s.log)
const [open, setOpen] = useState<number | null>(null)
return (
<>
{[...log].reverse().map((e) => (
<div key={e.n} className="cms-log">
<button onClick={() => setOpen(open === e.n ? null : e.n)}>
<span className={`cms-status s${Math.floor(e.status / 100)}`}>{e.status || 'ERR'}</span> <b>{e.method}</b> {e.url} <i>{e.ms}ms</i>
</button>
{open === e.n && <pre>{JSON.stringify({ request: e.request, response: e.response }, null, 2)}</pre>}
</div>
))}
{!log.length && <p className="cms-muted">No requests yet.</p>}
</>
)
}
function Selection() {
const ctrl = useCtrl()
const id = useEditorState((s) => s.selected)
const [data, setData] = useState<{ node: CmsNode; schema: JSONSchema } | null>(null)
useEffect(() => {
setData(null)
if (!id) return
void ctrl.node(id, true).then(async (node) => setData({ node, schema: await ctrl.schema(node.dataType) })).catch(() => {})
}, [id, ctrl])
if (!id) return <p className="cms-muted">Select a block.</p>
return data ? <pre>{JSON.stringify(data, null, 2)}</pre> : <p className="cms-muted">Loading {id}…</p>
}
function Registry() {
useRegistryVersion()
return (
<pre>
{JSON.stringify(
{
blockEditors: blockEditors.all().map((d) => ({ id: d.id, label: d.label, priority: d.priority ?? 0 })),
fieldEditors: fieldEditors.all().map((d) => d.id),
inlineEditors: inlineEditors.all().map((d) => d.id),
console: 'window.__cmsEditor.registerEditor({...})',
},
null,
2,
)}
</pre>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react'
import type { DialogInstance } from '../controller'
import { Icon } from './Icon'
import { useCtrl, useEditorState } from './hooks'
const WIDTH = { sm: 320, md: 420, lg: 620 }
const BASE_Z = 2147483001
export function Dialogs() {
const dialogs = useEditorState((s) => s.dialogs)
return (
<>
{dialogs.map((d, i) => (
<Dialog key={d.key} d={d} index={i} />
))}
</>
)
}
/** Non-modal, draggable window: the page stays visible and clickable behind it. */
function Dialog({ d, index }: { d: DialogInstance; index: number }) {
const ctrl = useCtrl()
const w = WIDTH[d.size]
const [pos, setPos] = useState(() => {
const x = Math.min((d.anchor?.x ?? 80) + 16, window.innerWidth - w - 12)
return { x: Math.max(12, x) + index * 18, y: Math.max(12, Math.min(d.anchor?.y ?? 80, window.innerHeight - 320)) + index * 18 }
})
const drag = useRef<{ dx: number; dy: number } | null>(null)
const el = useRef<HTMLElement>(null)
const [pulsing, setPulsing] = useState(false)
// Asked to open again: bring it back into view, flash it, and put the cursor in its first field.
useEffect(() => {
if (!d.pulse) return
setPos((p) => ({ x: Math.min(Math.max(8, p.x), Math.max(8, window.innerWidth - w - 8)), y: Math.min(Math.max(8, p.y), Math.max(8, window.innerHeight - 120)) }))
el.current?.querySelector<HTMLElement>('input:not([type=checkbox]), textarea, select, button:not([aria-label="Close"])')?.focus()
setPulsing(true)
const t = setTimeout(() => setPulsing(false), 700)
return () => clearTimeout(t)
}, [d.pulse, w])
return (
<section
ref={el}
className={`cms-dialog${pulsing ? ' is-pulsing' : ''}`}
style={{ left: pos.x, top: pos.y, width: w, zIndex: BASE_Z + d.z }}
role="dialog"
aria-label={d.title}
onPointerDownCapture={() => ctrl.raiseDialog(d.key)}
>
<header
className="cms-dialog__head"
onPointerDown={(e) => {
if ((e.target as HTMLElement).closest('button')) return
drag.current = { dx: e.clientX - pos.x, dy: e.clientY - pos.y }
e.currentTarget.setPointerCapture?.(e.pointerId)
}}
onPointerMove={(e) => drag.current && setPos({ x: e.clientX - drag.current.dx, y: Math.max(0, e.clientY - drag.current.dy) })}
onPointerUp={() => (drag.current = null)}
>
<strong>{d.title}</strong>
<button className="cms-ico" aria-label="Close" onClick={() => ctrl.closeDialog(d.key)}>
<Icon name="close" />
</button>
</header>
<div className="cms-dialog__body">{d.content}</div>
</section>
)
}
+105
View File
@@ -0,0 +1,105 @@
import { createElement, useEffect } from 'react'
import { InsertPicker } from '../editors/builtin'
import { Icon } from './Icon'
import { useCtrl, useEditorState } from './hooks'
export function Dock() {
const ctrl = useCtrl()
const s = useEditorState((s) => ({ mode: s.mode, canUndo: s.canUndo, canRedo: s.canRedo, busy: s.busy, dirty: s.dirty, rail: s.rail, dev: s.dev, auth: s.auth }))
const out = (
<button
className="cms-ico"
title="Leave preview mode"
aria-label="Leave preview mode"
onClick={() => {
document.cookie = 'cms_preview=; path=/; max-age=0'
location.reload()
}}
>
<Icon name="exit" />
</button>
)
if (s.auth.status !== 'signed-in') {
return (
<nav className="cms-dock" aria-label="Page editor">
<span className="cms-user">{s.auth.status === 'unknown' ? 'Checking session…' : 'Not signed in'}</span>
{out}
</nav>
)
}
const addToPage = async () => {
try {
const parentId = await ctrl.pageRoot()
let key = 0
key = ctrl.openDialog({ id: 'insert:page', title: 'Add to page', size: 'sm', content: createElement(InsertPicker, { parentId, ctrl, close: () => ctrl.closeDialog(key) }) })
} catch (e) {
ctrl.toast('error', (e as Error).message)
}
}
const rail = (which: 'outline' | 'blocks') => ctrl.store.set({ rail: s.rail === which ? null : which })
return (
<nav className="cms-dock" aria-label="Page editor">
<button className={`cms-pill${s.mode === 'edit' ? ' is-on' : ''}`} onClick={() => ctrl.setMode(s.mode === 'edit' ? 'browse' : 'edit')} title="Toggle editing (links work in browse mode)">
<Icon name={s.mode === 'edit' ? 'edit' : 'preview'} size={16} /> {s.mode === 'edit' ? 'Editing' : 'Browsing'}
</button>
<span className="cms-dock__sep" />
<button className="cms-ico" disabled={!s.canUndo} onClick={() => void ctrl.undo()} title="Undo (Ctrl+Z)" aria-label="Undo (Ctrl+Z)">
<Icon name="undo" />
</button>
<button className="cms-ico" disabled={!s.canRedo} onClick={() => void ctrl.redo()} title="Redo (Ctrl+Shift+Z)" aria-label="Redo (Ctrl+Shift+Z)">
<Icon name="redo" />
</button>
<span className="cms-dock__sep" />
<button className={`cms-ico${s.rail === 'blocks' ? ' is-on' : ''}`} onClick={() => rail('blocks')} title="Blocks: drag onto the page" aria-label="Blocks">
<Icon name="blocks" />
</button>
<button className={`cms-ico${s.rail === 'outline' ? ' is-on' : ''}`} onClick={() => rail('outline')} title="Outline" aria-label="Outline">
<Icon name="outline" />
</button>
<button className="cms-ico" onClick={() => void addToPage()} title="Add a block to the page" aria-label="Add a block to the page">
<Icon name="add" />
</button>
<span className={`cms-busy${s.busy ? ' is-busy' : ''}`} aria-live="polite" title={s.busy ? 'Saving…' : 'Saved'} />
<button className="cms-pill cms-pill--primary" onClick={() => void ctrl.publish()} title="Publish this page's static output">
<Icon name="publish" size={16} /> Publish{s.dirty ? ' •' : ''}
</button>
<span className="cms-dock__sep" />
<button className={`cms-ico${s.dev ? ' is-on' : ''}`} onClick={() => ctrl.store.set({ dev: !s.dev })} title="Developer panel (`)" aria-label="Developer panel">
<Icon name="settings" />
</button>
<button className="cms-ico" onClick={() => ctrl.signOut()} title={`Signed in as ${s.auth.user}. Click to sign out`} aria-label="Sign out">
<Icon name="sign-out" />
</button>
{out}
</nav>
)
}
export function Toast() {
const toast = useEditorState((s) => s.toast)
const ctrl = useCtrl()
useEffect(() => {
if (!toast) return
const t = setTimeout(() => ctrl.store.set({ toast: null }), toast.kind === 'error' ? 8000 : toast.action ? 6000 : 2500)
return () => clearTimeout(t)
}, [toast, ctrl])
if (!toast) return null
return (
<div className={`cms-toast cms-toast--${toast.kind}`} role="status" onClick={() => ctrl.store.set({ toast: null })}>
<Icon name={toast.kind === 'error' ? 'warning' : 'check'} size={16} />
<span>{toast.text}</span>
{toast.action && (
<button
className="cms-toast__action"
onClick={(e) => {
e.stopPropagation()
toast.action!.run()
ctrl.store.set({ toast: null })
}}
>
{toast.action.label}
</button>
)}
</div>
)
}
+153
View File
@@ -0,0 +1,153 @@
// Inline SVG icons (Material Design Icons paths via @mdi/js). Inline on purpose: an icon *font* would
// need an @font-face in the page (they do not load from inside a shadow root), a network fetch and a
// CSP exception; SVG paths are bundled, themeable via currentColor and tree-shaken to what is used.
import {
mdiAlignHorizontalCenter,
mdiAlignHorizontalLeft,
mdiAlignHorizontalRight,
mdiArrowDown,
mdiArrowLeftRight,
mdiArrowUp,
mdiCheck,
mdiChevronRight,
mdiClose,
mdiCloudUploadOutline,
mdiCodeJson,
mdiCogOutline,
mdiContentCopy,
mdiDotsHorizontal,
mdiDragVertical,
mdiEyeOutline,
mdiFileTreeOutline,
mdiFlash,
mdiFormatAlignCenter,
mdiFormatAlignLeft,
mdiFormatAlignRight,
mdiFormatHeader1,
mdiFormatListBulleted,
mdiGestureTapButton,
mdiImageOutline,
mdiInformationOutline,
mdiLinkVariant,
mdiLogout,
mdiMagnify,
mdiPageLayoutFooter,
mdiPageLayoutHeader,
mdiPaletteOutline,
mdiPencilOutline,
mdiPlus,
mdiPlusBoxOutline,
mdiPower,
mdiRedo,
mdiRefresh,
mdiSelectGroup,
mdiShapeOutline,
mdiShapePlusOutline,
mdiText,
mdiTrashCanOutline,
mdiUndo,
mdiViewAgendaOutline,
mdiViewColumnOutline,
mdiViewDashboardOutline,
mdiViewGridOutline,
mdiAlertCircleOutline,
mdiContentSaveOutline,
mdiLoginVariant,
mdiOpenInNew,
mdiRestore,
} from '@mdi/js'
export const ICONS = {
edit: mdiPencilOutline,
preview: mdiEyeOutline,
add: mdiPlus,
'add-inside': mdiPlusBoxOutline,
duplicate: mdiContentCopy,
delete: mdiTrashCanOutline,
up: mdiArrowUp,
down: mdiArrowDown,
close: mdiClose,
undo: mdiUndo,
redo: mdiRedo,
outline: mdiFileTreeOutline,
blocks: mdiShapePlusOutline,
settings: mdiCogOutline,
exit: mdiPower,
'sign-out': mdiLogout,
style: mdiPaletteOutline,
wrap: mdiSelectGroup,
json: mdiCodeJson,
inspect: mdiMagnify,
more: mdiDotsHorizontal,
grip: mdiDragVertical,
chevron: mdiChevronRight,
publish: mdiCloudUploadOutline,
link: mdiLinkVariant,
check: mdiCheck,
refresh: mdiRefresh,
info: mdiInformationOutline,
warning: mdiAlertCircleOutline,
flash: mdiFlash,
save: mdiContentSaveOutline,
reset: mdiRestore,
'sign-in': mdiLoginVariant,
external: mdiOpenInNew,
// style kinds
'flow-row': mdiViewColumnOutline,
'flow-stack': mdiViewAgendaOutline,
'flow-actions': mdiArrowLeftRight,
'flow-grid': mdiViewGridOutline,
'align-start': mdiAlignHorizontalLeft,
'align-center': mdiAlignHorizontalCenter,
'align-end': mdiAlignHorizontalRight,
'text-left': mdiFormatAlignLeft,
'text-center': mdiFormatAlignCenter,
'text-right': mdiFormatAlignRight,
// node types
heading: mdiFormatHeader1,
text: mdiText,
button: mdiGestureTapButton,
image: mdiImageOutline,
layout: mdiViewDashboardOutline,
footer: mdiPageLayoutFooter,
header: mdiPageLayoutHeader,
list: mdiFormatListBulleted,
shape: mdiShapeOutline,
} as const
export type IconName = keyof typeof ICONS
export function Icon({ name, size = 18, className, title, path }: { name?: IconName | string; size?: number; className?: string; title?: string; path?: string }) {
const d = path ?? (ICONS as Record<string, string>)[name ?? ''] ?? ICONS.shape
return (
<svg className={`cms-svg${className ? ` ${className}` : ''}`} width={size} height={size} viewBox="0 0 24 24" aria-hidden={title ? undefined : true} role={title ? 'img' : undefined} focusable="false">
{title && <title>{title}</title>}
<path d={d} fill="currentColor" />
</svg>
)
}
// --- node-type icons: extensible, with sensible guesses from the type name --------------------------
const typeIcons = new Map<string, string>()
/** Give a node type its own icon (an MDI path string), e.g. in a site-specific extension. */
export const registerTypeIcon = (type: string, path: string) => void typeIcons.set(type, path)
const GUESS: Array<[RegExp, IconName]> = [
[/heading|title|headline|typing/i, 'heading'],
[/button|cta|action/i, 'button'],
[/image|media|logo|photo|gallery/i, 'image'],
[/footer/i, 'footer'],
[/header|nav|menu/i, 'header'],
[/layout|grid|row|section|band|container|column/i, 'layout'],
[/list|points|faq|steps|stats|cards|items/i, 'list'],
[/text|prose|copy|statement|stream|quote|html/i, 'text'],
]
/** Icon for a node type (registered > guessed from the name > generic shape). */
export function TypeIcon({ type, size = 16 }: { type: string; size?: number }) {
const custom = typeIcons.get(type)
if (custom) return <Icon path={custom} size={size} />
const name = GUESS.find(([re]) => re.test(type))?.[1] ?? 'shape'
return <Icon name={name} size={size} />
}
+71
View File
@@ -0,0 +1,71 @@
import { useState } from 'react'
import { Icon } from './Icon'
import { Summary } from './Summary'
import { useCtrl, useEditorState } from './hooks'
/** Shown whenever there is no valid session: sign in with go-auth's device flow, or paste a token. */
export function LoginPanel() {
const ctrl = useCtrl()
const auth = useEditorState((s) => s.auth)
const [token, setToken] = useState('')
const [busy, setBusy] = useState(false)
if (auth.status === 'signed-in' || auth.status === 'unknown') return null
if (auth.status === 'pending') {
return (
<section className="cms-login" role="dialog" aria-label="Approve sign-in">
<h3>Approve this sign-in</h3>
<p className="cms-muted">Check that the code below matches the one on the approval page.</p>
<div className="cms-code" aria-label="Device code">
{auth.userCode}
</div>
<a className="cms-btn cms-btn--primary cms-login__link" href={auth.verifyUrl} target="_blank" rel="noopener noreferrer">
<Icon name="external" size={16} /> Open approval page
</a>
<p className="cms-muted cms-waiting">
<span className="cms-spinner" /> Waiting for approval…
</p>
<button className="cms-btn" onClick={() => ctrl.auth.cancel()}>
Cancel
</button>
</section>
)
}
return (
<section className="cms-login" role="dialog" aria-label="Sign in">
<h3>Sign in to edit this page</h3>
<p className="cms-muted">You are viewing the page as a visitor. Sign in to switch to live preview and edit.</p>
{auth.error && (
<p className="cms-error" role="alert">
{auth.error}
</p>
)}
<button
className="cms-btn cms-btn--primary"
disabled={busy}
onClick={async () => {
setBusy(true)
await ctrl.signIn()
setBusy(false)
}}
>
<Icon name="sign-in" size={16} /> Sign in
</button>
<details className="cms-login__paste">
<Summary>Use an access token instead</Summary>
<form
onSubmit={(e) => {
e.preventDefault()
if (token.trim()) void ctrl.signInWithToken(token)
}}
>
<textarea className="cms-input cms-mono" rows={3} placeholder="Paste a bearer token" value={token} onChange={(e) => setToken(e.target.value)} spellCheck={false} />
<button className="cms-btn" type="submit" disabled={!token.trim()}>
Use token
</button>
</form>
</details>
</section>
)
}
+270
View File
@@ -0,0 +1,270 @@
import { createElement, useEffect, useRef, useState } from 'react'
import type { Block } from '../controller'
import { AttrEditor } from '../editors/AttrEditor'
import { InsertPicker } from '../editors/builtin'
import { getIn, parsePath } from '../page/infer'
import { editorsFor } from '../registry'
import { BoxHandles } from '../style/BoxHandles'
import { describeStyle } from '../style/model'
import { Icon, TypeIcon } from './Icon'
import { useCtrl, useEditorState, useRegistryVersion } from './hooks'
const rectOf = (el: HTMLElement) => el.getBoundingClientRect()
/** Hover + selection frames drawn over the page, the selected block's toolbar, drop ghost, slots and chips. */
export function Overlay() {
const { hovered, selected, mode, blocks } = useEditorState((s) => ({ hovered: s.hovered, selected: s.selected, mode: s.mode, blocks: s.blocks }))
useEditorState((s) => s.tick) // re-measure on scroll/resize/mutation
useRegistryVersion()
useEditorState((s) => s.schemasVersion)
useEditorState((s) => s.labelsVersion)
const ctrl = useCtrl()
const inline = useEditorState((s) => s.inline)
const signedIn = useEditorState((s) => s.auth.status === 'signed-in')
const drag = useEditorState((s) => s.drag)
if (mode !== 'edit' || !signedIn) return null
const sel = blocks.filter((b) => b.id === selected)
const hov = hovered && hovered !== selected ? blocks.filter((b) => b.id === hovered) : []
const idle = !inline && !drag
return (
<div className="cms-overlay">
{hov.map((b, i) => (
<Frame key={`h${b.id}-${i}`} el={b.el} kind="hover" type={b.type} title={ctrl.labelFor(b.id)} group={ctrl.groupOfType(b.type)} />
))}
{sel.map((b, i) => (
<Frame key={`s${b.id}-${i}`} el={b.el} kind={inline ? 'editing' : 'selected'} type={b.type} />
))}
{idle && sel[0] && <BoxHandles block={sel[0]} />}
{idle && <InsertSlots blocks={blocks} selected={selected} hovered={hovered} />}
{idle && sel[0] && <AttrChips />}
{idle && sel[0] && <ActionBar block={sel[0]} />}
{drag && <DropGhost />}
</div>
)
}
function Frame({ el, kind, type, title, group }: { el: HTMLElement; kind: string; type: string; title?: string; group?: string }) {
const r = rectOf(el)
return (
<div className={`cms-frame cms-frame--${kind}`} style={{ left: r.left, top: r.top, width: r.width, height: r.height }}>
{kind === 'hover' && (
<span className="cms-frame__tag">
<TypeIcon type={type} size={12} /> {title ?? type}
{title && <i> {type}</i>}
{group && group !== 'Other' && <em>{group}</em>}
</span>
)}
</div>
)
}
// --- toolbar ----------------------------------------------------------------------------------
function ActionBar({ block }: { block: Block }) {
const ctrl = useCtrl()
const r = rectOf(block.el)
const defs = editorsFor(block)
const toolbar = defs.filter((d) => d.placement !== 'menu')
const menu = defs.filter((d) => d.placement === 'menu')
const [pos, setPos] = useState<{ index: number; count: number } | null>(null)
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
let live = true
setPos(null)
setOpen(false)
ctrl
.position(block.id)
.then((p) => live && setPos(p))
.catch(() => {})
return () => {
live = false
}
}, [block.id, ctrl, block.el])
useEffect(() => {
if (!open) return
const away = (e: Event) => !e.composedPath().includes(menuRef.current!) && setOpen(false)
document.addEventListener('pointerdown', away, true)
return () => document.removeEventListener('pointerdown', away, true)
}, [open])
const wrapper = ctrl.wrapperType()
const canWrap = !!wrapper && !!block.schema && block.type !== wrapper && describeStyle(block.schema).length === 0
const below = r.top < 48
const style = { left: Math.max(4, Math.min(r.left, window.innerWidth - 420)), top: below ? Math.min(r.bottom + 6, window.innerHeight - 44) : r.top - 42 }
return (
<div className="cms-bar" style={style} role="toolbar" aria-label={`${block.type} actions`}>
<span
className="cms-bar__type cms-bar__grip"
title="Drag to move"
aria-label="Drag to move"
onPointerDown={(e) => {
e.preventDefault()
ctrl.beginDrag(block.id)
}}
>
<Icon name="grip" size={16} />
<TypeIcon type={block.type} size={15} />
<span className="cms-bar__name" title={`${block.type} · ${ctrl.groupOfType(block.type)}`}>
{ctrl.labelFor(block.id) ?? block.type}
</span>
</span>
{toolbar.map((d) => (
<button key={d.id} className="cms-ico" title={d.label} aria-label={d.label} onClick={() => void ctrl.openEditor(d, block.id)}>
<Icon name={d.icon ?? 'edit'} />
</button>
))}
{canWrap && (
<button className="cms-ico" title={`Add spacing & layout (wrap in ${wrapper})`} aria-label="Wrap in layout" onClick={() => void ctrl.wrapInLayout(block.id)}>
<Icon name="wrap" />
</button>
)}
<span className="cms-bar__sep" />
<button className="cms-ico" title="Move up" aria-label="Move up" disabled={!pos || pos.index <= 0} onClick={() => void ctrl.nudge(block.id, -1)}>
<Icon name="up" />
</button>
<button className="cms-ico" title="Move down" aria-label="Move down" disabled={!pos || pos.index >= pos.count - 1} onClick={() => void ctrl.nudge(block.id, 1)}>
<Icon name="down" />
</button>
<button className="cms-ico" title="Duplicate" aria-label="Duplicate" onClick={() => void ctrl.duplicate(block.id)}>
<Icon name="duplicate" />
</button>
<button className="cms-ico cms-ico--danger" title="Delete" aria-label="Delete" onClick={() => void ctrl.remove(block.id)}>
<Icon name="delete" />
</button>
<div className="cms-menuwrap" ref={menuRef}>
<button className={`cms-ico${open ? ' is-on' : ''}`} title="More" aria-label="More actions" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
<Icon name="more" />
</button>
{open && (
<div className="cms-menu" role="menu">
{menu.map((d) => (
<button
key={d.id}
role="menuitem"
onClick={() => {
setOpen(false)
void ctrl.openEditor(d, block.id)
}}
>
<Icon name={d.icon ?? 'edit'} size={16} /> {d.label}
</button>
))}
<button
role="menuitem"
onClick={() => {
setOpen(false)
void navigator.clipboard?.writeText(block.id).then(() => ctrl.toast('info', 'Node id copied'))
}}
>
<Icon name="duplicate" size={16} /> Copy node id
</button>
</div>
)}
</div>
</div>
)
}
// --- drop ghost: the "virtual element" that shows where a dragged / new block will land ---------
function DropGhost() {
const drag = useEditorState((s) => s.drag)!
const t = drag.target
const vertical = !!t && t.line.h > t.line.w // a vertical line = siblings side by side
return (
<>
{t?.snaps?.map((r, i) => (
<div key={i} className={`cms-snap${r === t.line ? ' is-active' : ''}`} style={{ left: r.x, top: r.y, width: r.w, height: r.h }} />
))}
{t && (
<>
<div className="cms-dropline" style={{ left: t.line.x, top: t.line.y, width: t.line.w, height: t.line.h }} />
<div className="cms-ghost" style={vertical ? { left: t.line.x + 10, top: t.line.y + Math.min(t.line.h / 2, 120) - 17, width: 130 } : { left: t.line.x, top: t.line.y + 6, width: Math.max(t.line.w, 120) }}>
<TypeIcon type={drag.type ?? drag.label} size={16} />
<span>{drag.source === 'palette' ? `New ${drag.label}` : `Move ${drag.label}`}</span>
</div>
</>
)}
{drag.pointer && (
<div className={`cms-dragchip${t ? '' : ' is-refused'}`} style={{ left: drag.pointer.x + 14, top: drag.pointer.y + 14 }}>
<TypeIcon type={drag.type ?? drag.label} size={14} /> {drag.label}
{!t ? ' · not here' : drag.source === 'block' ? (drag.nesting ? ' · nesting (release Alt to sort)' : t.snaps ? ' · sorting · Alt to nest' : '') : ''}
</div>
)}
</>
)
}
// --- insertion slots: "+" between a container's children (and inside an empty one) ----------------
function InsertSlots({ blocks, selected, hovered }: { blocks: Block[]; selected: string | null; hovered: string | null }) {
const ctrl = useCtrl()
const containers = [selected, hovered].filter((id, i, a): id is string => !!id && a.indexOf(id) === i).map((id) => blocks.find((b) => b.id === id)).filter((b): b is Block => !!b && b.container)
const open = (parentId: string, index: number | undefined, x: number, y: number) => {
let key = 0
key = ctrl.openDialog({ id: `insert:${parentId}:${index ?? 'end'}`, title: 'Add block', size: 'sm', anchor: { x, y }, content: createElement(InsertPicker, { parentId, index, ctrl, close: () => ctrl.closeDialog(key) }) })
}
return (
<>
{containers.map((c) => {
const r = rectOf(c.el)
const kids = blocks.filter((b) => b.parentId === c.id).map((b) => ({ b, r: rectOf(b.el) }))
// Only vertical flows get between-slots; grids/rows would put the "+" in the wrong place.
const vertical = kids.every((k, i) => i === 0 || k.r.top >= kids[i - 1].r.bottom - 1)
if (!kids.length) {
return (
<button key={c.id} className="cms-slot cms-slot--empty" style={{ left: r.left + r.width / 2, top: r.top + Math.min(r.height / 2, 40) }} aria-label="Add block inside" onClick={() => open(c.id, undefined, r.left + r.width / 2, r.top)}>
<Icon name="add" size={16} /> Add block
</button>
)
}
const slots = vertical
? [...kids.map((k, i) => ({ index: i, y: i === 0 ? k.r.top : (kids[i - 1].r.bottom + k.r.top) / 2 })), { index: kids.length, y: kids[kids.length - 1].r.bottom }]
: [{ index: kids.length, y: r.bottom }]
return slots.map((s) => (
<button key={`${c.id}-${s.index}`} className="cms-slot" style={{ left: r.left + r.width / 2, top: s.y }} aria-label={`Add block at position ${s.index + 1}`} title="Add block here" onClick={() => open(c.id, s.index, r.left + r.width / 2, s.y)}>
<Icon name="add" size={14} />
</button>
))
})}
</>
)
}
// --- attribute chips: href / src / alt … of the selected block, editable where they are used ------------
const ATTR_ICON: Record<string, string> = { href: 'link', src: 'image', alt: 'info', title: 'info' }
function AttrChips() {
const ctrl = useCtrl()
const bindings = useEditorState((s) => s.bindings)
const selected = useEditorState((s) => s.selected)
return (
<>
{bindings.map((b) => {
if (!b.el.isConnected) return null
const r = rectOf(b.el)
return (
<button
key={`${b.path}:${b.attr}`}
className="cms-attrchip"
style={{ left: Math.max(4, r.right - 6), top: Math.max(4, r.top - 6) }}
title={`${b.attr}: ${b.value}`}
aria-label={`Edit ${b.attr}`}
onClick={async () => {
if (!selected) return
const node = await ctrl.node(selected, true)
const initial = String(getIn(node.data, parsePath(b.path)) ?? b.value)
let key = 0
const rr = b.el.getBoundingClientRect()
key = ctrl.openDialog({ id: `attr:${selected}:${b.path}`, title: `${b.attr} · ${b.path}`, size: 'sm', anchor: { x: rr.right, y: rr.top }, content: createElement(AttrEditor, { id: selected, path: b.path, attr: b.attr, initial, ctrl, close: () => ctrl.closeDialog(key) }) })
}}
>
<Icon name={ATTR_ICON[b.attr] ?? 'edit'} size={13} />
</button>
)
})}
</>
)
}
+204
View File
@@ -0,0 +1,204 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Blueprint, TypeInfo } from '../api'
import { OTHER_GROUP, allowedChildren, sortGroups, typeName } from '../schema'
import { Icon, TypeIcon } from './Icon'
import { Summary } from './Summary'
import { useCtrl, useEditorState } from './hooks'
/** Left rail: the page outline, and the palette of blocks you can drag onto the page. */
export function Rail() {
const ctrl = useCtrl()
const rail = useEditorState((s) => s.rail)
if (!rail) return null
return (
<aside className="cms-rail" aria-label={rail === 'outline' ? 'Outline' : 'Blocks'}>
<header>
<div className="cms-tabs" role="tablist">
{(['outline', 'blocks'] as const).map((t) => (
<button key={t} role="tab" aria-selected={rail === t} className={rail === t ? 'is-on' : ''} onClick={() => ctrl.store.set({ rail: t })}>
<Icon name={t} size={16} /> {t === 'outline' ? 'Outline' : 'Blocks'}
</button>
))}
</div>
<button className="cms-ico" onClick={() => ctrl.store.set({ rail: null })} aria-label="Close panel">
<Icon name="close" />
</button>
</header>
{rail === 'outline' ? <Outline /> : <Palette />}
</aside>
)
}
/** Page structure from the DOM scan: no API calls, always in sync with what is rendered. */
function Outline() {
const ctrl = useCtrl()
const { blocks, selected } = useEditorState((s) => ({ blocks: s.blocks, selected: s.selected }))
useEditorState((s) => s.labelsVersion)
if (!blocks.length) return <p className="cms-muted cms-pad">No editable blocks on this page.</p>
return (
<ul className="cms-outline">
{blocks.map((b, i) => (
<li key={`${b.id}-${i}`}>
<button
className={b.id === selected ? 'is-sel' : ''}
style={{ paddingLeft: 8 + b.depth * 14 }}
onMouseEnter={() => ctrl.store.set({ hovered: b.id })}
onMouseLeave={() => ctrl.store.set({ hovered: null })}
onClick={() => {
ctrl.setMode('edit')
ctrl.select(b.id, { scroll: true })
}}
>
<TypeIcon type={b.type} size={15} />
{/* title (x-graph.label) when the type has one, otherwise the type name and a text snippet */}
{(() => {
const title = ctrl.labelFor(b.id)
return title ? (
<>
<span className="cms-outline__type">{title}</span>
<span className="cms-outline__text">{b.type}</span>
</>
) : (
<>
<span className="cms-outline__type">{b.type || '?'}</span>
<span className="cms-outline__text">{(b.el.textContent ?? '').trim().slice(0, 40)}</span>
</>
)
})()}
</button>
</li>
))}
</ul>
)
}
const DRAG_THRESHOLD = 5
type Item = { key: string; label: string; /** the type id, shown under the title when it differs */ sub?: string; type?: string; blueprint?: string; group: string; description?: string }
/**
* Every block type (and blueprint) that fits somewhere on this page. Drag one onto the page - a ghost
* shows where it will land, and containers that do not accept it refuse the drop - or click to add it
* to the selection (inside a container, otherwise after the selected block, otherwise at the page end).
*/
function Palette() {
const ctrl = useCtrl()
const [types, setTypes] = useState<TypeInfo[] | null>(null)
const [blueprints, setBlueprints] = useState<Blueprint[]>([])
const [q, setQ] = useState('')
const { selected, blocks } = useEditorState((s) => ({ selected: s.selected, blocks: s.blocks }))
const schemasVersion = useEditorState((s) => s.schemasVersion)
const suppressClick = useRef(false)
useEffect(() => {
void ctrl.api
.types()
.then((ts) => {
ctrl.ingestTypes(ts) // /api/types includes every schema: titles + groups with no further requests
setTypes(ts)
void ctrl.loadSchemas(ts.map(typeName)) // only for entries that arrived without a schema
})
.catch(() => setTypes([]))
void ctrl.api.blueprints().then(setBlueprints).catch(() => {})
}, [ctrl])
// Types some container on the page (or the page itself) can hold - the rest would only ever be refused.
const items = useMemo<Item[]>(() => {
if (!types) return []
const all = types.map(typeName).filter(Boolean)
const allowed = new Set<string>()
for (const b of blocks) if (b.container) allowedChildren(b.schema, all).forEach((t) => allowed.add(t))
const rootAllowed = !allowed.size // schemas not loaded yet: show everything rather than nothing
return [
...types
.map((t) => ({ t, name: typeName(t) }))
.filter(({ name }) => name && !ctrl.isStaticRoot(name) && (rootAllowed || allowed.has(name)))
.map(({ name }) => {
const title = ctrl.typeTitle(name)
return { key: `t:${name}`, label: title, sub: title !== name ? name : undefined, type: name, group: ctrl.groupOfType(name), description: ctrl.schemaOf(name)?.description as string | undefined }
}),
...blueprints.map((b) => ({ key: `b:${b.id}`, label: b.name ?? b.id, blueprint: b.id, group: 'Blueprints', description: b.description })),
]
}, [types, blueprints, blocks, ctrl, schemasVersion])
const needle = q.toLowerCase()
const shown = items.filter((i) => i.label.toLowerCase().includes(needle) || (i.type ?? '').toLowerCase().includes(needle))
const groups = sortGroups(shown.map((i) => i.group).filter((g) => g !== 'Blueprints'))
if (shown.some((i) => i.group === 'Blueprints')) groups.push('Blueprints')
const addToSelection = async (item: Item) => {
const sel = ctrl.block(selected)
try {
let parent: string
let index: number | undefined
if (sel && sel.container && (!item.type || ctrl.accepts(sel.id, item.type))) parent = sel.id
else if (sel) {
parent = await ctrl.parentIdOf(sel.id)
index = (await ctrl.position(sel.id)).index + 1
} else parent = await ctrl.pageRoot()
if (item.blueprint) await ctrl.insertBlueprint(parent, item.blueprint, index)
else if (item.type) await ctrl.insert(parent, item.type, index !== undefined ? { index } : {})
} catch (e) {
ctrl.toast('error', (e as Error).message)
}
}
const onPointerDown = (e: React.PointerEvent, item: Item) => {
if (e.button !== 0) return
const x0 = e.clientX
const y0 = e.clientY
const doc = e.currentTarget.ownerDocument
const move = (m: PointerEvent) => {
if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return
stop()
suppressClick.current = true
ctrl.setMode('edit')
ctrl.beginPaletteDrag({ type: item.type, blueprint: item.blueprint, label: item.label })
}
const stop = () => {
doc.removeEventListener('pointermove', move, true)
doc.removeEventListener('pointerup', stop, true)
}
doc.addEventListener('pointermove', move, true)
doc.addEventListener('pointerup', stop, true)
}
return (
<div className="cms-palette">
<div className="cms-pad">
<input className="cms-input" placeholder="Search blocks…" aria-label="Search blocks" value={q} onChange={(e) => setQ(e.target.value)} />
<p className="cms-muted cms-hint">Drag onto the page, or click to add to the selection.</p>
</div>
{!types && <p className="cms-muted cms-pad">Loading…</p>}
{groups.map((g) => (
<details key={g} className="cms-group-sec" open>
<Summary>
{g} <span className="cms-muted">{shown.filter((i) => i.group === g).length}</span>
</Summary>
<div className="cms-palette__grid">
{shown
.filter((i) => i.group === g)
.map((i) => (
<button
key={i.key}
className="cms-block"
data-type={i.type}
title={i.description ?? `Add ${i.label}`}
onPointerDown={(e) => onPointerDown(e, i)}
onClick={() => {
if (suppressClick.current) return void (suppressClick.current = false)
void addToSelection(i)
}}
>
<TypeIcon type={i.type ?? i.label} size={22} />
<span>{i.label}</span>
{i.sub && <small>{i.sub}</small>}
</button>
))}
</div>
</details>
))}
{types && !shown.length && <p className="cms-muted cms-pad">Nothing matches.</p>}
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import type { ReactNode } from 'react'
import { Icon } from './Icon'
/** `<summary>` with an icon chevron that rotates when the `<details>` is open (replaces the native text marker). */
export function Summary({ children, className }: { children: ReactNode; className?: string }) {
return (
<summary className={className}>
<Icon name="chevron" size={16} className="cms-chev" />
{children}
</summary>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { createContext, useContext, useSyncExternalStore } from 'react'
import type { Controller, EditorState } from '../controller'
import { useStore } from '../store'
import { blockEditors, fieldEditors } from '../registry'
export const CtrlContext = createContext<Controller>(null as never)
export const useCtrl = () => useContext(CtrlContext)
export function useEditorState<S>(select: (s: EditorState) => S): S {
return useStore(useCtrl().store, select)
}
export function useRegistryVersion(): number {
const subscribe = (f: () => void) => {
const a = blockEditors.subscribe(f)
const b = fieldEditors.subscribe(f)
return () => (a(), b())
}
return useSyncExternalStore(subscribe, () => blockEditors.version + fieldEditors.version)
}
+275
View File
@@ -0,0 +1,275 @@
:host { all: initial; }
* { box-sizing: border-box; }
.cms-root { font: 13px/1.4 Inter, system-ui, sans-serif; color: #e8e9ee; }
button { font: inherit; color: inherit; cursor: pointer; }
button:disabled { opacity: .35; cursor: default; }
.cms-muted { color: #8b8fa0; margin: 4px 0; }
.cms-error { color: #ff8a8a; }
.cms-mono { font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 12px; }
/* overlay frames never take pointer events: the page owns hit-testing */
.cms-overlay { position: fixed; inset: 0; pointer-events: none; z-index: 2147483000; }
.cms-frame { position: fixed; border: 1px solid #6aa7ff; background: rgba(106,167,255,.07); border-radius: 2px; }
.cms-frame--selected { border: 2px solid #d2a24c; background: rgba(210,162,76,.08); }
.cms-frame--editing { border: 2px dashed #5ee0a0; background: none; }
.cms-frame__tag { position: absolute; top: -18px; left: -1px; padding: 1px 6px; font-size: 11px; background: #6aa7ff; color: #07080b; border-radius: 3px 3px 0 0; }
.cms-bar, .cms-dock, .cms-dialog, .cms-outline, .cms-dev, .cms-toast {
pointer-events: auto; position: fixed; z-index: 2147483001;
background: #14161d; border: 1px solid #2a2d3a; border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.45);
}
.cms-bar { display: flex; align-items: center; gap: 2px; padding: 3px 4px; border-radius: 8px; }
.cms-bar__type { padding: 0 8px; color: #d2a24c; font-weight: 600; }
.cms-bar__sep { width: 1px; height: 18px; background: #2a2d3a; margin: 0 3px; }
.cms-ico { background: none; border: 0; border-radius: 6px; min-width: 28px; height: 28px; padding: 0 6px; }
.cms-ico:hover:not(:disabled), .cms-pill:hover { background: #242836; }
.cms-ico.is-on { background: #2b3350; }
.cms-ico--danger:hover:not(:disabled) { background: #5a2330; }
.cms-dock { left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 4px; padding: 5px 8px; border-radius: 999px; }
.cms-pill { background: none; border: 1px solid #2a2d3a; border-radius: 999px; padding: 4px 12px; height: 28px; }
.cms-pill.is-on { background: #2b3350; border-color: #4a5a9a; }
.cms-pill--primary { background: #d2a24c; border-color: #d2a24c; color: #07080b; font-weight: 600; }
.cms-pill--primary:hover { background: #e0b25c; }
.cms-busy { width: 8px; height: 8px; border-radius: 50%; background: #3b3f52; margin: 0 4px; }
.cms-busy.is-busy { background: #5ee0a0; animation: cms-pulse .8s infinite alternate; }
@keyframes cms-pulse { to { opacity: .2; } }
.cms-dialog { max-height: calc(100vh - 24px); display: flex; flex-direction: column; }
.cms-dialog__head { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 6px 14px; border-bottom: 1px solid #2a2d3a; cursor: grab; user-select: none; }
.cms-dialog__body { padding: 12px 14px; overflow: auto; }
.cms-form { display: flex; flex-direction: column; gap: 10px; }
.cms-field { display: flex; flex-direction: column; gap: 3px; }
.cms-field__label { font-weight: 500; color: #c3c6d4; }
.cms-field__label b { color: #ff8a8a; }
.cms-field__hint { color: #8b8fa0; font-size: 11.5px; }
.cms-input { width: 100%; background: #0c0d12; color: inherit; border: 1px solid #2a2d3a; border-radius: 6px; padding: 6px 8px; font: inherit; }
.cms-input:focus { outline: 2px solid #4a5a9a; outline-offset: -1px; }
.cms-input--area { resize: vertical; }
.cms-input--bad { border-color: #ff6b6b; }
.cms-check { width: 16px; height: 16px; }
.cms-nested { border: 1px solid #2a2d3a; border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 10px; margin: 0; }
.cms-extra summary { cursor: pointer; color: #8b8fa0; margin-bottom: 6px; }
.cms-actions { display: flex; justify-content: flex-end; gap: 8px; position: sticky; bottom: 0; }
.cms-btn { background: #242836; border: 1px solid #343850; border-radius: 6px; padding: 5px 14px; }
.cms-btn--primary { background: #d2a24c; border-color: #d2a24c; color: #07080b; font-weight: 600; }
.cms-picker h4 { margin: 10px 0 6px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8b8fa0; }
.cms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 6px; }
.cms-tile { background: #1d2029; border: 1px solid #2a2d3a; border-radius: 8px; padding: 10px 8px; text-align: left; }
.cms-tile:hover { border-color: #d2a24c; }
.cms-dev header { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 6px 12px; border-bottom: 1px solid #2a2d3a; }
.cms-outline li button { display: flex; gap: 8px; width: 100%; background: none; border: 0; border-radius: 5px; padding: 3px 8px; text-align: left; overflow: hidden; white-space: nowrap; }
.cms-outline li button:hover, .cms-outline li button.is-sel { background: #242836; }
.cms-outline li button.is-sel { color: #d2a24c; }
.cms-outline__type { font-weight: 600; }
.cms-outline__text { color: #8b8fa0; overflow: hidden; text-overflow: ellipsis; }
.cms-dev { right: 12px; top: 12px; bottom: 70px; width: 420px; display: flex; flex-direction: column; overflow: hidden; }
.cms-dev header { justify-content: flex-start; gap: 4px; }
.cms-dev header button:not(.cms-ico) { background: none; border: 0; padding: 4px 10px; border-radius: 6px; text-transform: capitalize; }
.cms-dev header button.is-on { background: #2b3350; }
.cms-dev header .cms-ico { margin-left: auto; }
.cms-dev__body { overflow: auto; padding: 8px; }
.cms-dev pre { margin: 4px 0; font: 11.5px/1.45 "JetBrains Mono", monospace; white-space: pre-wrap; word-break: break-all; color: #b9bdd0; }
.cms-log > button { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 3px 4px; font: 11.5px "JetBrains Mono", monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cms-log > button:hover { background: #1d2029; }
.cms-log i { color: #8b8fa0; font-style: normal; }
.cms-status { padding: 0 4px; border-radius: 3px; }
.cms-status.s2 { background: #1f4d36; } .cms-status.s4, .cms-status.s5, .cms-status.s0 { background: #5a2330; }
.cms-toast { left: 50%; bottom: 68px; transform: translateX(-50%); padding: 8px 14px; max-width: 80vw; cursor: pointer; }
.cms-toast--error { border-color: #a33a4a; background: #2a1219; }
.cms-bar__grip { cursor: grab; user-select: none; }
.cms-bar__grip:active { cursor: grabbing; }
.cms-dropline { position: fixed; background: #5ee0a0; border-radius: 2px; box-shadow: 0 0 0 2px rgba(94,224,160,.25); }
.cms-user { padding: 0 10px; color: #8b8fa0; }
.cms-toast__action { margin-left: 12px; background: none; border: 0; color: #d2a24c; font-weight: 600; text-decoration: underline; }
.cms-login { pointer-events: auto; position: fixed; z-index: 2147483002; left: 50%; bottom: 72px; transform: translateX(-50%); width: 340px; max-width: calc(100vw - 24px);
background: #14161d; border: 1px solid #2a2d3a; border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,.5); padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; }
.cms-login h3 { margin: 0; font-size: 15px; }
.cms-login p { margin: 0; }
.cms-login__link { text-align: center; text-decoration: none; display: block; }
.cms-login__paste summary { cursor: pointer; color: #8b8fa0; margin-bottom: 8px; }
.cms-login__paste form { display: flex; flex-direction: column; gap: 8px; }
.cms-code { font: 600 26px/1 "JetBrains Mono", monospace; letter-spacing: .14em; text-align: center; padding: 14px 0; background: #0c0d12; border: 1px dashed #4a5a9a; border-radius: 8px; user-select: all; }
.cms-waiting { display: flex; align-items: center; gap: 8px; }
.cms-spinner { width: 12px; height: 12px; border: 2px solid #3b3f52; border-top-color: #d2a24c; border-radius: 50%; animation: cms-spin .8s linear infinite; }
@keyframes cms-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .cms-spinner, .cms-busy.is-busy { animation: none; } }
/* form: arrays, maps, variants, groups */
.cms-field.has-error > .cms-input, .cms-field.has-error .cms-input { border-color: #ff6b6b; }
.cms-field__error { color: #ff8a8a; font-size: 11.5px; }
.cms-inline { display: inline-flex; align-items: center; gap: 6px; }
.cms-checks { display: flex; flex-wrap: wrap; gap: 6px 14px; }
.cms-array, .cms-map, .cms-variant { display: flex; flex-direction: column; gap: 6px; }
.cms-array__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.cms-item { border: 1px solid #2a2d3a; border-radius: 8px; background: #101219; }
.cms-item.is-over { border-color: #5ee0a0; box-shadow: 0 0 0 2px rgba(94,224,160,.2); }
.cms-item__head { display: flex; align-items: center; gap: 4px; padding: 3px 4px 3px 2px; }
.cms-item__title { flex: 1; min-width: 0; text-align: left; background: none; border: 0; padding: 4px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: 6px; }
.cms-item__title:hover { background: #1d2029; }
.cms-item__value { flex: 1; min-width: 0; }
.cms-item__actions { display: flex; flex-shrink: 0; }
.cms-item__body { padding: 8px 10px 10px; border-top: 1px solid #2a2d3a; display: flex; flex-direction: column; gap: 10px; }
.cms-object { display: flex; flex-direction: column; gap: 10px; }
.cms-chev { display: inline-block; transition: transform .12s; color: #8b8fa0; }
.cms-chev.is-open { transform: rotate(90deg); }
.cms-grip { cursor: grab; color: #5b6075; padding: 0 4px; user-select: none; }
.cms-array__foot { display: flex; align-items: center; gap: 10px; }
.cms-map__row { display: flex; align-items: flex-start; gap: 6px; }
.cms-map__key { width: 34%; flex-shrink: 0; padding-top: 6px; color: #c3c6d4; }
input.cms-map__key { padding-top: 6px; }
.cms-map__value { flex: 1; min-width: 0; }
.cms-map__add { display: flex; gap: 6px; }
.cms-group { border: 1px solid #2a2d3a; border-radius: 8px; padding: 6px 10px; }
.cms-group > summary { cursor: pointer; font-weight: 600; padding: 2px 0; }
.cms-group[open] > summary { margin-bottom: 8px; }
.cms-group[open] { display: flex; flex-direction: column; gap: 10px; }
.cms-readonly { margin: 0; padding: 6px 8px; background: #0c0d12; border-radius: 6px; font: 12px/1.4 "JetBrains Mono", monospace; white-space: pre-wrap; color: #9aa0b5; }
.cms-thumb { max-width: 160px; max-height: 90px; object-fit: contain; border-radius: 6px; border: 1px solid #2a2d3a; }
.cms-actions__note { margin-right: auto; align-self: center; }
/* style: handles, bands, panel */
.cms-band { position: fixed; pointer-events: none; }
.cms-band--padding { background: rgba(110,231,160,.22); outline: 1px solid rgba(110,231,160,.5); }
.cms-band--margin { background: rgba(243,168,95,.22); outline: 1px solid rgba(243,168,95,.5); }
.cms-band--gap { background: rgba(180,140,255,.28); outline: 1px solid rgba(180,140,255,.55); }
.cms-grip-h { position: fixed; pointer-events: auto; z-index: 2; border-radius: 99px; background: #fff; border: 2px solid currentColor; box-shadow: 0 1px 4px rgba(0,0,0,.4); touch-action: none; opacity: .85; }
.cms-grip-h:hover, .cms-grip-h.is-active { opacity: 1; transform: scale(1.06); }
.cms-grip-h--y { cursor: ns-resize; } .cms-grip-h--x { cursor: ew-resize; }
.cms-grip-h--padding { color: #2fbf71; } .cms-grip-h--margin { color: #e8872b; } .cms-grip-h--gap { color: #9a6cf0; }
.cms-tip { position: fixed; pointer-events: none; background: #14161d; border: 1px solid #2a2d3a; border-radius: 6px; padding: 3px 8px; font-size: 12px; white-space: nowrap; z-index: 3; }
.cms-tip b { color: #d2a24c; }
.cms-style { display: flex; flex-direction: column; gap: 14px; }
.cms-style__section { display: flex; flex-direction: column; gap: 10px; }
.cms-style__section h4 { margin: 0; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8b8fa0; }
.cms-style__note { font-size: 11.5px; }
.cms-tabs { display: flex; gap: 2px; border-bottom: 1px solid #2a2d3a; }
.cms-tabs button { background: none; border: 0; border-bottom: 2px solid transparent; padding: 6px 10px; color: #8b8fa0; }
.cms-tabs button.is-on { color: #e8e9ee; border-color: #d2a24c; }
.cms-ctl { display: flex; flex-direction: column; gap: 4px; }
.cms-ctl__head { display: flex; align-items: center; justify-content: space-between; }
.cms-ctl__label { color: #c3c6d4; font-weight: 500; }
.cms-ctl__clear { background: none; border: 0; color: #8b8fa0; padding: 0 4px; }
.cms-ctl__clear:hover { color: #e8e9ee; }
.cms-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.cms-chip { background: #1d2029; border: 1px solid #2a2d3a; border-radius: 6px; padding: 4px 9px; }
.cms-chip:hover { border-color: #4a5a9a; }
.cms-chip.is-on { background: #2b3350; border-color: #6a7fe0; color: #fff; }
.cms-stepper { display: flex; align-items: center; gap: 4px; }
.cms-stepper .cms-input { width: 64px; text-align: center; }
.cms-boxmodel { display: flex; justify-content: center; }
.cms-box { display: grid; grid-template-columns: 74px 1fr 74px; grid-template-rows: auto auto auto; gap: 4px; padding: 6px; border-radius: 8px; position: relative; width: 100%; }
.cms-box--margin { background: rgba(243,168,95,.12); border: 1px dashed rgba(243,168,95,.55); }
.cms-box--padding { background: rgba(110,231,160,.12); border: 1px dashed rgba(110,231,160,.55); }
.cms-box__title { position: absolute; top: 2px; left: 8px; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: #8b8fa0; }
.cms-box__top { grid-column: 2; grid-row: 1; text-align: center; padding-top: 10px; }
.cms-box__bottom { grid-column: 2; grid-row: 3; text-align: center; }
.cms-box__left { grid-column: 1; grid-row: 2; align-self: center; }
.cms-box__right { grid-column: 3; grid-row: 2; align-self: center; text-align: right; }
.cms-box__mid { grid-column: 2; grid-row: 2; min-width: 0; }
.cms-box__content { display: block; text-align: center; padding: 14px 0; border-radius: 6px; background: #0c0d12; color: #5b6075; font-size: 11px; }
.cms-box__sel { width: 100%; background: #0c0d12; color: inherit; border: 1px solid #2a2d3a; border-radius: 6px; padding: 3px 4px; font: inherit; }
.cms-box__sel.is-inherited { color: #8b8fa0; }
.cms-box__link, .cms-box__none { color: #5b6075; font-size: 11px; }
/* ---- polish: icons, toolbar, menu, rail, palette, drag ---- */
:host { --cms-accent: #d2a24c; --cms-line: #2a2d3a; --cms-panel: #14161d; }
.cms-svg { display: block; flex-shrink: 0; }
.cms-ico { display: inline-flex; align-items: center; justify-content: center; gap: 4px; color: #c9ccda; transition: background .12s, color .12s; }
.cms-ico:hover:not(:disabled) { color: #fff; }
.cms-ico.is-on { color: var(--cms-accent); }
.cms-bar { padding: 4px 6px; gap: 2px; border-radius: 10px; backdrop-filter: blur(6px); }
.cms-bar__type { display: inline-flex; align-items: center; gap: 6px; padding: 0 10px 0 4px; height: 28px; color: var(--cms-accent); font-weight: 600; border-radius: 6px; }
.cms-bar__type:hover { background: #242836; }
.cms-bar__sep, .cms-dock__sep { width: 1px; height: 18px; background: var(--cms-line); margin: 0 4px; }
.cms-frame__tag { display: inline-flex; align-items: center; gap: 4px; }
.cms-frame--selected::after { content: ''; position: absolute; inset: -2px; border-radius: 3px; box-shadow: 0 0 0 4px rgba(210,162,76,.18); pointer-events: none; }
.cms-dock { padding: 6px 10px; gap: 2px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
.cms-pill { display: inline-flex; align-items: center; gap: 6px; }
.cms-toast { display: flex; align-items: center; gap: 8px; }
.cms-toast--error svg { color: #ff8a8a; } .cms-toast svg { color: #5ee0a0; }
.cms-chip { display: inline-flex; align-items: center; gap: 6px; }
.cms-tile { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.cms-tile svg { color: var(--cms-accent); }
.cms-menuwrap { position: relative; }
.cms-menu { position: absolute; right: 0; top: calc(100% + 8px); min-width: 180px; background: var(--cms-panel); border: 1px solid var(--cms-line); border-radius: 10px; box-shadow: 0 12px 36px rgba(0,0,0,.55); padding: 4px; display: flex; flex-direction: column; z-index: 5; }
.cms-menu button { display: flex; align-items: center; gap: 10px; background: none; border: 0; text-align: left; padding: 7px 10px; border-radius: 6px; white-space: nowrap; }
.cms-menu button:hover { background: #242836; }
/* drag: ghost = the virtual element showing where the block lands */
.cms-ghost { position: fixed; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px; border: 2px dashed #5ee0a0; border-radius: 8px; background: rgba(94,224,160,.14); color: #bff5d9; font-weight: 600; pointer-events: none; box-shadow: 0 6px 24px rgba(0,0,0,.35); animation: cms-pop .12s ease-out; }
@keyframes cms-pop { from { transform: scale(.97); opacity: 0; } }
.cms-dragchip { position: fixed; display: inline-flex; align-items: center; gap: 6px; background: var(--cms-panel); border: 1px solid #5ee0a0; border-radius: 999px; padding: 3px 10px; font-size: 12px; pointer-events: none; z-index: 4; }
.cms-dragchip.is-refused { border-color: #a33a4a; color: #ff8a8a; }
/* insertion slots */
.cms-slot { position: fixed; pointer-events: auto; transform: translate(-50%, -50%); display: inline-flex; align-items: center; gap: 4px; min-width: 22px; height: 22px; padding: 0 3px; border-radius: 999px; border: 1px solid #4a5a9a; background: #2b3350; color: #dfe5ff; opacity: .0; transition: opacity .12s, transform .12s; z-index: 1; }
.cms-overlay:hover .cms-slot, .cms-slot:focus-visible, .cms-slot--empty { opacity: .9; }
.cms-slot:hover { opacity: 1; transform: translate(-50%, -50%) scale(1.12); background: #3a4672; }
.cms-slot--empty { padding: 0 12px; height: 30px; border-style: dashed; background: rgba(43,51,80,.85); }
.cms-attrchip { position: fixed; pointer-events: auto; transform: translate(-100%, -100%); display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 999px; border: 1px solid #5ee0a0; background: #14161d; color: #5ee0a0; z-index: 1; }
.cms-attrchip:hover { background: #1f3a2e; }
/* left rail: outline + block palette */
.cms-rail { pointer-events: auto; position: fixed; z-index: 2147483001; left: 12px; top: 12px; bottom: 76px; width: 280px; display: flex; flex-direction: column; overflow: hidden; background: var(--cms-panel); border: 1px solid var(--cms-line); border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
.cms-rail > header { display: flex; align-items: center; justify-content: space-between; padding: 4px 6px 0 6px; border-bottom: 1px solid var(--cms-line); }
.cms-rail .cms-tabs { border: 0; } .cms-rail .cms-tabs button { display: inline-flex; align-items: center; gap: 6px; }
.cms-pad { padding: 10px 12px 4px; }
.cms-hint { font-size: 11.5px; margin-top: 6px; }
.cms-outline { list-style: none; margin: 0; padding: 4px; overflow: auto; }
.cms-outline li button { display: flex; align-items: center; gap: 8px; width: 100%; background: none; border: 0; border-radius: 6px; padding: 4px 8px; text-align: left; overflow: hidden; white-space: nowrap; }
.cms-outline li button:hover, .cms-outline li button.is-sel { background: #242836; }
.cms-outline li button.is-sel { color: var(--cms-accent); }
.cms-outline__type { font-weight: 600; } .cms-outline__text { color: #8b8fa0; overflow: hidden; text-overflow: ellipsis; }
.cms-palette { overflow: auto; padding-bottom: 12px; }
.cms-palette h4 { margin: 10px 12px 6px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8b8fa0; }
.cms-palette__grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; padding: 0 10px; }
.cms-block { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 6px 8px; background: #1a1d26; border: 1px solid var(--cms-line); border-radius: 10px; cursor: grab; touch-action: none; user-select: none; text-align: center; }
.cms-block svg { color: var(--cms-accent); } .cms-block span { font-size: 12px; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.cms-block:hover { border-color: #4a5a9a; background: #20243a; }
.cms-block:active { cursor: grabbing; }
.cms-dev header .cms-ico { margin-left: auto; }
/* sort snapping: faint markers at every candidate position, the active one animates between them */
.cms-snap { position: fixed; pointer-events: none; background: rgba(94,224,160,.28); border-radius: 2px; }
.cms-snap.is-active { background: transparent; }
.cms-dropline { transition: left .09s ease-out, top .09s ease-out, width .09s ease-out, height .09s ease-out; }
@media (prefers-reduced-motion: reduce) { .cms-dropline { transition: none; } }
/* titles + groups */
.cms-frame__tag i { font-style: normal; opacity: .7; font-weight: 400; }
.cms-frame__tag em { font-style: normal; margin-left: 6px; padding: 0 5px; border-radius: 99px; background: rgba(7,8,11,.25); font-size: 10px; }
.cms-bar__name { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cms-group-sec > summary { cursor: pointer; list-style: none; margin: 10px 12px 6px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8b8fa0; display: flex; gap: 8px; align-items: center; }
.cms-group-sec > summary::-webkit-details-marker { display: none; }
.cms-block small, .cms-tile small { display: block; font-size: 10.5px; color: #8b8fa0; font-family: "JetBrains Mono", monospace; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.cms-tile span { font-weight: 500; }
/* disclosure chevrons are icons now (rotate when open) */
summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 4px; }
summary::-webkit-details-marker { display: none; }
summary .cms-chev { transition: transform .12s; color: #8b8fa0; }
details[open] > summary > .cms-chev { transform: rotate(90deg); }
.cms-box__link { display: inline-flex; align-items: center; gap: 3px; }
.cms-item__head .cms-ico, .cms-map__row .cms-ico { padding: 0 4px; }
.cms-grip { display: inline-flex; align-items: center; }
.cms-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
/* a dialog asked for again: flash it instead of opening a duplicate */
.cms-dialog.is-pulsing { animation: cms-pulse-ring .7s ease-out; }
@keyframes cms-pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(210,162,76,.9), 0 8px 30px rgba(0,0,0,.45); } 100% { box-shadow: 0 0 0 14px rgba(210,162,76,0), 0 8px 30px rgba(0,0,0,.45); } }
@media (prefers-reduced-motion: reduce) { .cms-dialog.is-pulsing { animation: none; outline: 2px solid #d2a24c; } }
+188
View File
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
import { AuthManager } from '../src/auth'
let h: Harness
afterEach(() => h?.cleanup())
const until = async (cond: () => boolean, ms = 1500) => {
const t0 = Date.now()
while (!cond()) {
if (Date.now() - t0 > ms) throw new Error('timed out waiting')
await act(async () => void (await new Promise((r) => setTimeout(r, 10))))
}
}
const status = () => h.editor.ctrl.store.get().auth.status
const signIn = async () => {
await until(() => status() === 'signed-out')
await act(async () => void h.shadow.querySelector<HTMLElement>('[aria-label="Sign in"] .cms-btn--primary')!.click())
await until(() => status() === 'pending')
h.cms.approve()
await until(() => status() === 'signed-in')
await h.settle()
}
describe('sign-in (device grant)', () => {
it('shows the login panel and blocks editing while signed out', async () => {
h = await setup({ auth: true })
await until(() => status() === 'signed-out')
expect(h.shadow.querySelector('[aria-label="Sign in"]')).not.toBeNull()
await act(async () => void click(byId('t1')))
expect(h.editor.ctrl.store.get().selected).toBeNull()
const ev = new MouseEvent('click', { bubbles: true, cancelable: true })
byId('b1').querySelector('a')!.dispatchEvent(ev)
expect(ev.defaultPrevented).toBe(false) // the page behaves like a normal page
})
it('walks through user code -> approval -> signed in, then editing works', async () => {
h = await setup({ auth: true })
await until(() => status() === 'signed-out')
await act(async () => void h.shadow.querySelector<HTMLElement>('[aria-label="Sign in"] .cms-btn--primary')!.click())
await until(() => status() === 'pending')
expect(h.shadow.querySelector('.cms-code')!.textContent).toBe('MOCK-1234')
expect(h.shadow.querySelector<HTMLAnchorElement>('.cms-login__link')!.href).toContain('/device?user_code=MOCK-1234')
h.cms.approve()
await until(() => status() === 'signed-in')
await h.settle()
expect(h.shadow.querySelector('[aria-label="Sign in"]')).toBeNull()
expect(h.editor.ctrl.store.get().toast!.text).toBe('Signed in as mock-user')
await act(async () => void click(byId('t1')))
expect(h.editor.ctrl.store.get().selected).toBe('t1')
})
it('refreshes transparently when the access token expires', async () => {
h = await setup({ auth: true })
await signIn()
const before = h.auth.headers().Authorization
h.cms.expireAccess()
await act(async () => void click(byId('t1')))
await h.editor.ctrl.nudge('t1', 1)
await h.settle()
expect(h.cms.graph.children('s1').map((e) => e.to)).toEqual(['h1', 'b1', 't1'])
expect(h.auth.headers().Authorization).not.toBe(before) // a new access token was minted via the refresh token
expect(status()).toBe('signed-in')
})
it('a revoked session drops back to the login panel', async () => {
h = await setup({ auth: true })
await signIn()
await act(async () => void click(byId('t1')))
h.cms.revoke()
await h.editor.ctrl.nudge('t1', 1)
await h.settle()
expect(status()).toBe('signed-out')
expect(h.editor.ctrl.store.get().selected).toBeNull()
expect(h.shadow.querySelector('[aria-label="Sign in"]')).not.toBeNull()
})
it('sign out clears the session; pasted tokens work', async () => {
h = await setup({ auth: true, token: 'static-token' })
await until(() => status() === 'signed-out')
await h.editor.ctrl.signInWithToken('static-token')
await h.settle()
expect(status()).toBe('signed-in')
h.editor.ctrl.signOut()
expect(status()).toBe('signed-out')
})
})
describe('AuthManager', () => {
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status })
const grant = { device_code: 'd', user_code: 'U', verification_uri: 'https://i/device', interval: 1, expires_in: 600 }
function manager(poll: Array<Response>, extra: Partial<ConstructorParameters<typeof AuthManager>[0]> = {}) {
const sleeps: number[] = []
const calls: string[] = []
const fetch = (async (url: string) => {
calls.push(String(url))
if (String(url).endsWith('/api/auth/device')) return json(200, grant)
if (String(url).endsWith('/device/token')) return poll.shift()!
throw new Error('unexpected ' + url)
}) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, issuer: async () => 'https://i', persistent: null, session: null, sleep: async (ms) => void sleeps.push(ms), ...extra })
return { auth, sleeps, calls }
}
it('honours slow_down by backing off 5s', async () => {
const { auth, sleeps } = manager([json(400, { error: 'authorization_pending' }), json(400, { error: 'slow_down' }), json(400, { error: 'authorization_pending' }), json(200, { access_token: 'a', refresh_token: 'r', expires_in: 900 })])
expect(await auth.login()).toBe(true)
expect(sleeps).toEqual([1000, 1000, 6000, 6000])
expect(auth.headers()).toEqual({ Authorization: 'Bearer a' })
})
it('runs the grant as the CMS client from /api/auth/config so the token audience matches', async () => {
const bodies: string[] = []
const fetch = (async (url: string, init?: RequestInit) => {
const u = String(url)
if (u === '/api/auth/config') return json(200, { issuer: 'https://i', clientId: 'k6n-admin' })
bodies.push(`${u.replace('https://i', '')} ${String(init?.body)}`)
if (u.endsWith('/api/auth/device')) return json(200, grant)
return json(200, { access_token: 'a', expires_in: 900 })
}) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, persistent: null, session: null, sleep: async () => {} })
expect(await auth.login()).toBe(true)
expect(bodies[0]).toContain('client_id=k6n-admin') // start
expect(bodies[1]).toContain('client_id=k6n-admin') // poll: must be the same client
})
it('surfaces the server reason (e.g. wrong audience) when a token is rejected', async () => {
const fetch = (async () => json(401, { error: 'unauthorized: oidc: wrong audience' })) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, persistent: null, session: null })
auth.useToken('t')
await auth.check(async () => {
throw Object.assign(new Error('unauthorized: oidc: wrong audience'), { status: 401 })
})
expect(auth.state.get()).toMatchObject({ status: 'signed-out', error: expect.stringContaining('wrong audience') })
})
it('reports denial and expiry', async () => {
const denied = manager([json(400, { error: 'access_denied' })])
expect(await denied.auth.login()).toBe(false)
expect(denied.auth.state.get()).toMatchObject({ status: 'signed-out', error: 'sign-in was denied' })
const expired = manager([json(400, { error: 'expired_token' })])
await expired.auth.login()
expect(expired.auth.state.get().error).toMatch(/expired/)
})
it('cancel stops polling quietly', async () => {
let auth!: AuthManager
const m = manager([json(400, { error: 'authorization_pending' }), json(400, { error: 'authorization_pending' })], { sleep: async () => auth.cancel() })
auth = m.auth
expect(await auth.login()).toBe(false)
expect(auth.state.get()).toMatchObject({ status: 'signed-out', error: undefined })
})
it('production keeps the refresh token out of localStorage', async () => {
const mk = () => {
const m = new Map<string, string>()
return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => void m.set(k, v), removeItem: (k: string) => void m.delete(k), data: m } as unknown as Storage & { data: Map<string, string> }
}
for (const persistRefresh of [false, true]) {
const local = mk()
const session = mk()
const { auth } = manager([json(200, { access_token: 'a', refresh_token: 'secret-refresh', expires_in: 900 })], { persistent: local, session, persistRefresh })
await auth.login()
expect([...local.data.values()].join('').includes('secret-refresh')).toBe(persistRefresh)
expect([...session.data.values()].join('').includes('secret-refresh')).toBe(!persistRefresh)
}
})
it('shares one refresh between concurrent callers (refresh tokens rotate)', async () => {
let refreshes = 0
const fetch = (async (url: string) => {
if (String(url).endsWith('/device/refresh')) {
refreshes++
return json(200, { access_token: 'new', refresh_token: 'r2', expires_in: 900 })
}
return json(200, grant)
}) as unknown as typeof globalThis.fetch
const auth = new AuthManager({ fetch, issuer: async () => 'https://i', persistent: null, session: null })
auth.useToken('old')
;(auth as any).tokens = { accessToken: 'old', refreshToken: 'r1', expiresAt: 0 }
await Promise.all([auth.refresh(), auth.refresh(), auth.ensureFresh()])
expect(refreshes).toBe(1)
expect(auth.headers()).toEqual({ Authorization: 'Bearer new' })
})
})
+150
View File
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
let h: Harness
afterEach(() => h?.cleanup())
const select = async (id: string) => {
await act(async () => void click(byId(id)))
await h.settle()
}
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
const qa = (sel: string) => [...h.shadow.querySelectorAll<HTMLElement>(sel)]
const dialogs = () => qa('.cms-dialog')
const z = (el: HTMLElement) => Number(el.style.zIndex)
describe('one dialog per thing: opening again focuses the existing one', () => {
it('Properties opened twice for the same block is one dialog, raised, pulsing and focused', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Properties')
expect(dialogs()).toHaveLength(1)
const first = dialogs()[0]
const z0 = z(first)
await act(async () => void (document.activeElement as HTMLElement | null)?.blur?.())
await h.press('Properties') // again
expect(dialogs()).toHaveLength(1)
expect(dialogs()[0]).toBe(first) // the same element, not a replacement (form state is kept)
expect(z(first)).toBeGreaterThan(z0)
expect(first.className).toContain('is-pulsing')
expect(h.shadow.activeElement).toBe(first.querySelector('input'))
})
it('keeps unsaved edits when the dialog is asked for again', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Properties')
const input = q('[data-path="title"] input') as HTMLInputElement
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(input, 'Unsaved draft')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await h.press('Properties')
expect((q('[data-path="title"] input') as HTMLInputElement).value).toBe('Unsaved draft')
})
it('a double click before the first dialog has loaded still opens only one', async () => {
h = await setup()
await h.settle()
await Promise.all([h.editor.ctrl.openEditor('properties', 's1'), h.editor.ctrl.openEditor('properties', 's1')])
await h.settle()
expect(dialogs()).toHaveLength(1)
})
it('different blocks or editors get their own dialogs; reopening the older one raises it above the newer', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Properties')
await select('l1')
await h.press('Properties')
await h.press('Style')
expect(dialogs()).toHaveLength(3)
const [s1Props, l1Props, l1Style] = dialogs()
expect(z(l1Style)).toBeGreaterThan(z(l1Props))
await select('s1')
await h.press('Properties') // the oldest dialog comes forward
expect(dialogs()).toHaveLength(3)
expect(z(s1Props)).toBeGreaterThan(z(l1Style))
})
it('clicking a dialog brings it to the front; Escape closes the topmost', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Properties')
await select('t1')
await h.press('JSON').catch(() => {}) // under the … menu: open via the controller instead
await h.editor.ctrl.openEditor('raw-json', 't1')
await h.settle()
const [a, b] = dialogs()
expect(z(b)).toBeGreaterThan(z(a))
await act(async () => void a.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, composed: true })))
expect(z(a)).toBeGreaterThan(z(b))
await act(async () => void document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
expect(dialogs()).toHaveLength(1)
expect(dialogs()[0]).toBe(b) // a was on top, so a closed
})
it('the same insert slot is one picker; another slot opens another', async () => {
h = await setup()
await h.settle()
await select('s1')
await act(async () => void qa('.cms-slot')[1].click())
await h.settle()
await act(async () => void qa('.cms-slot')[1].click())
await h.settle()
expect(dialogs()).toHaveLength(1)
await act(async () => void qa('.cms-slot')[2].click())
await h.settle()
expect(dialogs()).toHaveLength(2)
})
it('an attribute chip opens one editor per property', async () => {
h = await setup()
await h.settle()
await select('f1')
const chip = () => qa('[aria-label="Edit href"]')[0]
await act(async () => void chip().click())
await h.settle()
await act(async () => void chip().click())
await h.settle()
expect(dialogs()).toHaveLength(1)
await act(async () => void qa('[aria-label="Edit href"]')[1].click())
await h.settle()
expect(dialogs()).toHaveLength(2)
})
it('after closing, the same dialog can be opened again', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Properties')
await h.press('Close')
expect(dialogs()).toHaveLength(0)
await h.press('Properties')
expect(dialogs()).toHaveLength(1)
})
})
describe('the property editor uses icons, not text glyphs', () => {
it('array, map and group controls are svg icons', async () => {
h = await setup()
await h.settle()
await select('f1')
await h.press('Properties')
const form = q('.cms-dialog').textContent!
expect(form).not.toMatch(/[↑↓⧉✕+⠿▸↔↺]/)
for (const label of ['Move item 1 down', 'Duplicate item 1', 'Remove item 1', 'Add item']) {
expect(q(`[aria-label="${label}"] svg path`), label).not.toBeNull()
}
expect(q('[aria-label="Add entry"] svg')).not.toBeNull()
expect(q('.cms-group > summary svg.cms-chev')).not.toBeNull()
expect(q('.cms-actions .cms-btn--primary svg')).not.toBeNull()
})
})
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { dropTarget } from '../src/page/dnd'
import type { DndBlock } from '../src/page/dnd'
const r = (y: number, h = 100, x = 0, w = 600) => ({ x, y, w, h })
// page: A (container: a1, a2) , B (container, empty), C (leaf)
const blocks: DndBlock[] = [
{ 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: 'a2', parentId: 'A', container: false, depth: 1, rect: r(120, 100, 10, 580) },
{ id: 'B', container: true, depth: 0, rect: r(310, 100) },
{ id: 'C', container: false, depth: 0, rect: r(420, 100) },
]
describe('dropTarget', () => {
it('picks the index from the pointer relative to sibling midpoints', () => {
expect(dropTarget(blocks, { x: 50, y: 20 }, 'C')).toMatchObject({ parentId: 'A', index: 0 })
expect(dropTarget(blocks, { x: 50, y: 100 }, 'C')).toMatchObject({ parentId: 'A', index: 1 })
expect(dropTarget(blocks, { x: 50, y: 250 }, 'C')).toMatchObject({ parentId: 'A', index: 2 })
})
it('excludes the dragged block from the index maths', () => {
// dragging a1 within A: pointer below a2's midpoint -> after a2 = index 1 among [a2]
expect(dropTarget(blocks, { x: 50, y: 200 }, 'a1')).toMatchObject({ parentId: 'A', index: 1 })
})
it('drops into an empty container', () => {
const t = dropTarget(blocks, { x: 50, y: 350 }, 'C')!
expect(t).toMatchObject({ parentId: 'B', index: 0 })
expect(t.line.y).toBeGreaterThanOrEqual(310)
})
it('never targets the dragged block or its descendants', () => {
expect(dropTarget(blocks, { x: 50, y: 20 }, 'A')?.parentId).toBe('') // A is not a drop target for itself -> page level
})
it('page level uses the root sentinel and refuses far-away points', () => {
expect(dropTarget(blocks, { x: 50, y: 460 }, 'a1')).toMatchObject({ parentId: '', index: 2 })
expect(dropTarget(blocks, { x: 50, y: 500 }, 'a1')).toMatchObject({ parentId: '', index: 3 })
expect(dropTarget(blocks, { x: 50, y: 900 }, 'a1')).toBeNull()
})
})
+226
View File
@@ -0,0 +1,226 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
let h: Harness
afterEach(() => h?.cleanup())
const selectBlock = async (id: string) => {
await act(async () => void click(byId(id)))
await h.settle()
}
const order = (parent: string) => [...byId(parent).querySelectorAll(':scope > [data-cms]')].map((e) => e.getAttribute('data-cms'))
describe('on-page editor against the mock CMS', () => {
it('scans blocks and selects on click without navigating', async () => {
h = await setup()
expect(h.editor.ctrl.store.get().blocks.map((b) => b.id)).toEqual(['s1', 'h1', 't1', 'b1', 's2', 't2', 'l1', 'x1', 'x2', 'f1'])
const ev = new MouseEvent('click', { bubbles: true, cancelable: true })
await act(async () => void byId('b1').querySelector('a')!.dispatchEvent(ev))
expect(ev.defaultPrevented).toBe(true)
expect(h.editor.ctrl.store.get().selected).toBe('b1')
})
it('browse mode leaves the page alone', async () => {
h = await setup()
await h.press('Toggle editing (links work in browse mode)')
const ev = new MouseEvent('click', { bubbles: true, cancelable: true })
await act(async () => void byId('b1').querySelector('a')!.dispatchEvent(ev))
expect(ev.defaultPrevented).toBe(false)
expect(h.editor.ctrl.store.get().selected).toBeNull()
})
it('resolves the edge through the API (no data-cms-edge in the markup) and moves a block', async () => {
h = await setup()
await selectBlock('t1')
await h.editor.ctrl.nudge('t1', 1)
await h.settle()
expect(order('s1')).toEqual(['h1', 'b1', 't1']) // morphed from the server patch
expect(h.cms.graph.children('s1').map((e) => e.to)).toEqual(['h1', 'b1', 't1'])
})
it('undo/redo replay the server inverse and re-morph the page', async () => {
h = await setup()
await selectBlock('t1')
await h.editor.ctrl.nudge('t1', 1)
await h.settle()
await h.press('Undo (Ctrl+Z)')
expect(order('s1')).toEqual(['h1', 't1', 'b1'])
await h.press('Redo (Ctrl+Shift+Z)')
expect(order('s1')).toEqual(['h1', 'b1', 't1'])
})
it('delete + undo restores the subtree and DOM', async () => {
h = await setup()
await h.editor.ctrl.remove('s1')
await h.settle()
expect(document.querySelector('[data-cms="s1"]')).toBeNull()
await h.press('Undo (Ctrl+Z)')
expect(byId('h1').textContent).toBe('Edit this headline')
})
it('insert goes through the picker, uses x-graph children, and selects the new block', async () => {
h = await setup()
await selectBlock('s1')
await h.press('Add inside')
const tiles = [...h.shadow.querySelectorAll<HTMLElement>('.cms-tile')].map((t) => t.dataset.type)
expect(tiles).toEqual(expect.arrayContaining(['heading', 'text', 'button', 'section']))
expect(tiles).not.toContain('page')
await act(async () => void h.shadow.querySelector<HTMLElement>('.cms-tile[data-type="button"]')!.click())
await h.settle()
expect(h.cms.graph.children('s1')).toHaveLength(4)
expect(h.editor.ctrl.store.get().selected).toBe(h.cms.graph.children('s1')[3].to)
})
it('page-level insert resolves the root from the top block, and a root patch error falls back to a soft reload', async () => {
h = await setup()
await selectBlock('s1')
const root = await h.editor.ctrl.parentIdOf('s1')
expect(root).toBe('p1')
await h.editor.ctrl.insert(root, 'heading', { data: { text: 'Top-level' }, index: 0 })
await h.settle()
// The server could not render the page node standalone: the editor re-fetched and morphed.
expect(document.querySelector('[data-cms-type="heading"]')!.textContent).toBe('Top-level')
})
it('properties dialog edits through the schema form and saves only the diff', async () => {
h = await setup()
await selectBlock('s1')
await h.press('Properties')
const input = h.shadow.querySelector<HTMLInputElement>('[data-path="title"] input')!
expect(input.value).toBe('Welcome')
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(input, 'Hello')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => void h.shadow.querySelector<HTMLElement>('.cms-btn--primary')!.click())
await h.settle()
expect(h.cms.graph.node('s1').data).toMatchObject({ title: 'Hello', tone: 'dark', padded: true })
expect(byId('s1').querySelector('.sec__title')!.textContent).toBe('Hello')
const edit = h.editor.ctrl.store.get().log.find((l) => l.url === '/api/edit')!
expect((edit.request as any).ops[0]).toMatchObject({ op: 'set', id: 's1', data: { title: 'Hello' }, version: 1 })
})
it('array editors save through the dialog as a minimal diff, and invalid data blocks Save', async () => {
h = await setup()
await selectBlock('f1')
await h.press('Properties')
const links = h.shadow.querySelector('[data-path="links"]')!
await act(async () => void links.querySelector<HTMLElement>('[aria-label="Add item"]')!.click())
const save = () => h.shadow.querySelector<HTMLButtonElement>('.cms-btn--primary')!
expect(save().disabled).toBe(true) // new link has an empty required "text", and nothing else changed yet
expect(h.shadow.querySelector('.cms-actions__note')!.textContent).toContain('1 problem')
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
const text = h.shadow.querySelector<HTMLInputElement>('[data-path="links.2.text"] input')!
await act(async () => {
set.call(text, 'Contact')
text.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(save().disabled).toBe(false)
await act(async () => void save().click())
await h.settle()
expect(h.cms.graph.node('f1').data.links.map((l: any) => l.text)).toEqual(['About', 'Careers', 'Contact'])
const op = h.editor.ctrl.store.get().log.find((l) => l.url === '/api/edit')!.request as any
expect(Object.keys(op.ops[0].data)).toEqual(['links']) // companyName, usps, social... untouched
})
it('a stale version shows a conflict instead of overwriting', async () => {
h = await setup()
await selectBlock('s1')
await h.press('Properties')
h.cms.graph.edit([{ op: 'set', id: 's1', data: { title: 'Someone else' } }]) // concurrent editor
const input = h.shadow.querySelector<HTMLInputElement>('[data-path="title"] input')!
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(input, 'Mine')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => void h.shadow.querySelector<HTMLElement>('.cms-btn--primary')!.click())
await h.settle()
expect(h.cms.graph.node('s1').data.title).toBe('Someone else')
expect(h.editor.ctrl.store.get().toast?.text).toMatch(/Someone else changed/)
})
it('inline text editing sends debounced, coalesced sets (one undo step)', async () => {
h = await setup()
const field = byId('h1')
await act(async () => void field.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, composed: true })))
await h.settle()
expect(field.getAttribute('contenteditable')).toBeTruthy()
for (const t of ['Hel', 'Hello', 'Hello world']) {
field.textContent = t
field.dispatchEvent(new Event('input', { bubbles: true }))
}
h.editor.ctrl.stopInline() // flushes
await h.settle()
expect(h.cms.graph.node('h1').data.text).toBe('Hello world')
expect(field.hasAttribute('contenteditable')).toBe(false)
await h.press('Undo (Ctrl+Z)')
expect(h.cms.graph.node('h1').data.text).toBe('Edit this headline')
expect(byId('h1').textContent).toBe('Edit this headline')
})
it('delete offers an Undo toast that restores the block', async () => {
h = await setup()
await h.editor.ctrl.remove('t1')
await h.settle()
const toast = h.editor.ctrl.store.get().toast!
expect(toast.text).toBe('Block deleted')
toast.action!.run()
await h.settle()
expect(byId('t1')).not.toBeNull()
})
it('dropBlock re-parents with one move op and the page follows', async () => {
h = await setup()
await h.editor.ctrl.dropBlock('t1', { parentId: 's2', index: 0, line: { x: 0, y: 0, w: 0, h: 0 } })
await h.settle()
expect(order('s2')).toEqual(['t1', 't2'])
expect(order('s1')).toEqual(['h1', 'b1'])
// dropping where it already is does nothing
const before = h.editor.ctrl.store.get().log.length
await h.editor.ctrl.dropBlock('t1', { parentId: 's2', index: 0, line: { x: 0, y: 0, w: 0, h: 0 } })
expect(h.editor.ctrl.store.get().log.filter((l) => l.url === '/api/edit')).toHaveLength(1)
expect(h.editor.ctrl.store.get().log.length).toBeGreaterThanOrEqual(before)
})
it('the base page is swapped for the latest preview on load, via /api/preview', async () => {
h = await setup({ stale: true })
await h.settle()
expect(byId('h1').textContent).toBe('Latest from the store')
expect(h.cms.requests.some((r) => r.startsWith('GET /api/preview?path=%2F'))).toBe(true)
})
it('falls back to the page URL with the preview header when /api/preview is missing', async () => {
h = await setup({ stale: true, noPreviewApi: true })
await h.settle()
expect(byId('h1').textContent).toBe('Latest from the store')
expect(h.cms.requests.filter((r) => r.startsWith('GET /api/preview')).length).toBeGreaterThan(0)
})
it('does not touch the page while signed out (visitors keep the published file)', async () => {
h = await setup({ stale: true, auth: true })
await h.settle()
expect(byId('h1').textContent).toBe('Edit this headline')
expect(h.cms.requests.some((r) => r.includes('/api/preview'))).toBe(false)
})
it('refreshes when the tab becomes visible again (other editors\' changes)', async () => {
h = await setup()
await h.settle()
h.cms.graph.edit([{ op: 'set', id: 'h1', data: { text: 'Edited elsewhere' } }])
;(h.editor.ctrl as any).lastPreview = 0 // skip the 5s throttle
document.dispatchEvent(new Event('visibilitychange'))
await h.settle()
expect(byId('h1').textContent).toBe('Edited elsewhere')
})
it('exposes window.__cmsEditor and is idempotent', async () => {
h = await setup()
expect((window as any).__cmsEditor).toBe(h.editor)
const { boot } = await import('../src/boot')
expect(boot()).toBe(h.editor)
})
})
+213
View File
@@ -0,0 +1,213 @@
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, within } from '@testing-library/react'
import { useState } from 'react'
import { SchemaForm } from '../src/form/Form'
import { validate } from '../src/form/validate'
import { defaultFor, pickVariant, resolveSchema } from '../src/form/resolve'
import { schemas } from '../mock/fixtures'
afterEach(cleanup)
/** Controlled harness; `onValue` sees every change so tests can assert the exact data written. */
function Harness({ schema, initial, onValue }: { schema: any; initial: any; onValue(v: any): void }) {
const [v, setV] = useState(initial)
return <SchemaForm schema={schema} value={v} onChange={(n) => (setV(n), onValue(n))} node={{ id: 'n', dataType: 't', data: initial }} ctrl={null as never} />
}
function mount(schema: any, initial: any = {}) {
let last = initial
const r = render(<Harness schema={schema} initial={initial} onValue={(v) => (last = v)} />)
return { ...r, value: () => last, field: (path: string) => r.container.querySelector<HTMLElement>(`[data-path="${path}"]`)! }
}
const type = (el: Element, v: string) => fireEvent.change(el, { target: { value: v } })
const footer = schemas.footer
const data = () => structuredClone({ companyName: 'Mock AB', links: [{ text: 'About', href: 'https://x.test/a' }, { text: 'Careers', href: 'https://x.test/b' }], usps: ['Fast', 'Friendly'], social: { github: 'https://g.test' }, badges: ['gdpr'], cta: { kind: 'link', href: '/c' } })
describe('array of objects ($ref items)', () => {
it('shows summaries, edits nested fields, and writes them back', () => {
const m = mount(footer, data())
const links = m.field('links')
expect(links.textContent).toContain('About')
expect(links.textContent).toContain('Careers')
type(within(links).getAllByRole('textbox')[0], 'About us') // items <= 2 start expanded
expect(m.value().links[0]).toMatchObject({ text: 'About us', href: 'https://x.test/a' })
})
it('adds an item with schema defaults, respecting maxItems', () => {
const m = mount(footer, data())
for (let i = 0; i < 6; i++) fireEvent.click(within(m.field('links')).getByLabelText('Add item'))
expect(m.value().links).toHaveLength(6)
expect(m.value().links[5]).toEqual({ text: '' }) // required props seeded
expect((within(m.field('links')).getByLabelText('Add item') as HTMLButtonElement).disabled).toBe(true)
})
it('reorders (buttons), duplicates and removes', () => {
const m = mount(footer, data())
const l = () => within(m.field('links'))
fireEvent.click(l().getByLabelText('Move item 1 down'))
expect(m.value().links.map((x: any) => x.text)).toEqual(['Careers', 'About'])
fireEvent.click(l().getByLabelText('Duplicate item 1'))
expect(m.value().links.map((x: any) => x.text)).toEqual(['Careers', 'Careers', 'About'])
fireEvent.click(l().getByLabelText('Remove item 2'))
expect(m.value().links.map((x: any) => x.text)).toEqual(['Careers', 'About'])
})
it('keeps open/closed state with the item when reordering', () => {
const d = data()
d.links.push({ text: 'Third', href: 'https://x.test/c' }) // 3 items -> all start collapsed
const m = mount(footer, d)
const l = () => within(m.field('links'))
fireEvent.click(l().getByText('Third')) // expand it
expect(l().queryAllByRole('textbox')).toHaveLength(2) // text + href of the one open card
fireEvent.click(l().getByLabelText('Move item 3 up'))
expect(m.value().links.map((x: any) => x.text)).toEqual(['About', 'Third', 'Careers'])
expect(l().getAllByRole('textbox')[0].getAttribute('value')).toBe('Third') // still the open one
})
it('an optional array emptied is removed from the data', () => {
const m = mount(footer, { companyName: 'x', links: [{ text: 'Only' }] })
fireEvent.click(within(m.field('links')).getByLabelText('Remove item 1'))
expect('links' in m.value()).toBe(false)
})
})
describe('primitive arrays', () => {
it('edit in place, add and remove rows', () => {
const m = mount(footer, data())
const u = () => within(m.field('usps'))
type(u().getAllByRole('textbox')[1], 'Very friendly')
expect(m.value().usps).toEqual(['Fast', 'Very friendly'])
fireEvent.click(u().getByLabelText('Add item'))
expect(m.value().usps).toEqual(['Fast', 'Very friendly', ''])
fireEvent.click(u().getByLabelText('Remove item 1'))
expect(m.value().usps).toEqual(['Very friendly', ''])
})
it('uniqueItems + enum renders a checkbox set', () => {
const m = mount(footer, data())
const boxes = within(m.field('badges')).getAllByRole('checkbox') as HTMLInputElement[]
expect(boxes.map((b) => b.checked)).toEqual([false, true, false])
fireEvent.click(boxes[0])
expect(m.value().badges).toEqual(['gdpr', 'iso9001'])
fireEvent.click(boxes[1])
expect(m.value().badges).toEqual(['iso9001'])
})
})
describe('object maps (additionalProperties)', () => {
it('adds, renames (keeping order), edits and removes keys; blocks duplicates', () => {
const m = mount(footer, data())
const s = () => within(m.field('social'))
type(s().getByLabelText('New key'), 'mastodon')
fireEvent.click(s().getByLabelText('Add entry'))
expect(Object.keys(m.value().social)).toEqual(['github', 'mastodon'])
const key = s().getByLabelText('Key github')
type(key, 'gh')
fireEvent.blur(key)
expect(Object.keys(m.value().social)).toEqual(['gh', 'mastodon'])
type(s().getByLabelText('New key'), 'gh')
fireEvent.click(s().getByLabelText('Add entry'))
expect(s().getByText('That key already exists')).toBeTruthy()
fireEvent.click(s().getByLabelText('Remove gh'))
expect(Object.keys(m.value().social)).toEqual(['mastodon'])
})
it('x-dict-keys is a fixed vocabulary: one row per key, blank removes', () => {
const schema = { type: 'object', properties: { m: { type: 'object', 'x-editor': 'dictionary', 'x-dict-keys': ['höjd', 'bredd'], additionalProperties: { type: 'integer' } } } }
const m = mount(schema, { m: { höjd: 5 } })
const inputs = within(m.field('m')).getAllByRole('spinbutton')
expect(inputs).toHaveLength(2)
type(inputs[1], '7')
expect(m.value().m).toEqual({ höjd: 5, bredd: 7 })
type(inputs[0], '')
expect(m.value().m).toEqual({ bredd: 7 })
})
})
describe('variants and refs', () => {
it('oneOf: picks the branch from the data and switching sets the discriminator', () => {
const m = mount(footer, data())
const sel = within(m.field('cta')).getByLabelText('Variant') as HTMLSelectElement
expect(sel.options[sel.selectedIndex].text).toBe('Link')
fireEvent.change(sel, { target: { value: '1' } })
expect(m.value().cta).toEqual({ kind: 'phone', number: '' })
type(within(m.field('cta')).getAllByRole('textbox')[0], '08-1234')
expect(m.value().cta).toEqual({ kind: 'phone', number: '08-1234' })
})
it('nullable unions unwrap to the real type', () => {
const root = { $defs: { s: { type: 'string' } } }
expect(resolveSchema(root, { anyOf: [{ $ref: '#/$defs/s' }, { type: 'null' }] })).toMatchObject({ nullable: true, schema: { type: 'string' } })
expect(resolveSchema(root, { type: ['integer', 'null'] })).toMatchObject({ nullable: true, schema: { type: 'integer' } })
})
it('resolves $ref chains and merges allOf', () => {
const root = { $defs: { a: { properties: { x: { type: 'string' } }, required: ['x'] }, b: { allOf: [{ $ref: '#/$defs/a' }, { properties: { y: { type: 'number' } } }] } } }
const { schema } = resolveSchema(root, { $ref: '#/$defs/b', title: 'B' })
expect(Object.keys(schema.properties)).toEqual(['x', 'y'])
expect(schema.required).toEqual(['x'])
expect(schema.title).toBe('B')
})
it('pickVariant scores by discriminator, type and required keys', () => {
const b = footer.properties.cta.oneOf
expect(pickVariant(footer, b, { kind: 'phone', number: '1' })).toBe(1)
expect(pickVariant(footer, b, undefined)).toBe(-1)
expect(pickVariant({}, [{ type: 'string' }, { type: 'number' }], 3)).toBe(1)
})
it('defaultFor seeds required props, consts and enums', () => {
expect(defaultFor(footer, footer.properties.links.items)).toEqual({ text: '' })
expect(defaultFor(footer, footer.properties.cta.oneOf[1])).toEqual({ kind: 'phone', number: '' })
expect(defaultFor({}, { type: 'string', enum: ['a', 'b'] })).toBe('a')
})
})
describe('field behaviour', () => {
it('hidden fields are not rendered; readOnly are shown but not editable', () => {
const m = mount(footer, { companyName: 'x', internal: 'abc', secret: 's' })
expect(m.field('secret')).toBeNull()
expect(m.field('internal').querySelector('input,textarea')).toBeNull()
expect(m.field('internal').textContent).toContain('abc')
})
it('x-group becomes a collapsible section', () => {
const m = mount(footer, data())
const groups = [...m.container.querySelectorAll('details.cms-group')] as HTMLDetailsElement[]
expect(groups.map((g) => g.querySelector('summary')!.textContent!.replace(/\s*\(\d+\)/, ''))).toEqual(['Contact', 'Social'])
expect(groups.map((g) => g.open)).toEqual([true, false]) // collapsedByDefault
})
it('clearing an optional string removes the key; a required one keeps ""', () => {
const m = mount(footer, { companyName: 'Acme', tagline: 'hi' })
type(m.field('tagline').querySelector('input')!, '')
expect('tagline' in m.value()).toBe(false)
type(m.field('companyName').querySelector('input')!, '')
expect(m.value().companyName).toBe('')
})
it('data not in the schema stays editable under "not in the schema"', () => {
const m = mount({ type: 'object', properties: { a: { type: 'string' } } }, { a: '1', legacy: 'old' })
expect(m.container.querySelector('.cms-extra')!.textContent).toContain('1 field')
type(m.container.querySelector('.cms-extra input')!, 'new')
expect(m.value()).toEqual({ a: '1', legacy: 'new' })
})
})
describe('validation', () => {
it('reports required/minLength/format inside array items with form paths', () => {
const e = validate(footer, footer, { companyName: '', links: [{ text: 'x' }, { text: 'ok' }], email: 'nope' })
expect(e).toMatchObject({ companyName: 'Required', 'links.0.text': 'At least 2 characters', email: 'Not a valid email address' })
expect(e['links.1.text']).toBeUndefined()
})
it('checks minItems/maxItems/number bounds and validates the chosen variant only', () => {
const s = { type: 'object', properties: { l: { type: 'array', minItems: 2, items: { type: 'integer', maximum: 5 } }, v: footer.properties.cta } }
expect(validate(s, s, { l: [9], v: { kind: 'phone' } })).toMatchObject({ l: 'Add at least 2 items', 'l.0': 'Must be at most 5', 'v.number': 'Required' })
})
it('shows messages inline and flags the field', () => {
const m = mount(footer, { companyName: 'ok', links: [{ text: 'x' }] })
const row = m.container.querySelector('[data-path="links.0.text"]')!
expect(row.className).toContain('has-error')
expect(row.querySelector('[role=alert]')!.textContent).toBe('At least 2 characters')
})
})
+58
View File
@@ -0,0 +1,58 @@
// Test harness: boots the real editor against the mock CMS inside jsdom.
import { act } from 'react'
import { Api } from '../src/api'
import { AuthManager } from '../src/auth'
import { boot } from '../src/boot'
import type { EditorHandle } from '../src/boot'
import { createMockCms } from '../mock/server'
import { renderPage } from '../mock/fixtures'
export interface Harness {
cms: ReturnType<typeof createMockCms>
auth: AuthManager
editor: EditorHandle
/** Wait for async work (fetches, patch application, React). */
settle(): Promise<void>
shadow: ShadowRoot
/** Click something inside the editor UI by accessible label. */
press(label: string): Promise<void>
cleanup(): void
}
/** `stale`: the DOM is the *published* page, and the store has moved on since (the base-page problem). */
export async function setup(opts: { token?: string; auth?: boolean; stale?: boolean; noPreviewApi?: boolean } = {}): Promise<Harness> {
const cms = createMockCms({ token: opts.token, auth: opts.auth })
document.head.innerHTML = ''
window.open = () => null
document.body.innerHTML = renderPage(cms.graph).match(/<body>([\s\S]*)<\/body>/)![1]
if (opts.stale) cms.graph.edit([{ op: 'set', id: 'h1', data: { text: 'Latest from the store' } }])
if (opts.noPreviewApi) cms.disablePreviewApi()
const auth = new AuthManager({ fetch: cms.fetch, persistent: null, session: null, sleep: (ms) => new Promise((r) => setTimeout(r, Math.min(ms, 5))) })
const api = new Api({ fetch: cms.fetch, auth })
let editor!: EditorHandle
await act(async () => {
editor = boot({ api })
})
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 25))))
const shadow = document.getElementById('cms-onpage-editor')!.shadowRoot!
return {
cms,
auth,
editor,
settle,
shadow,
async press(label) {
const el = shadow.querySelector<HTMLElement>(`[aria-label="${label}"], [title="${label}"]`)
if (!el) throw new Error(`no control "${label}" in editor UI`)
await act(async () => el.click())
await settle()
},
cleanup() {
editor.destroy()
document.body.innerHTML = ''
},
}
}
export const click = (el: Element) => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, composed: true }))
export const byId = (id: string) => document.querySelector<HTMLElement>(`[data-cms="${id}"]`)!
+174
View File
@@ -0,0 +1,174 @@
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()
})
})
+340
View File
@@ -0,0 +1,340 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
import { Icon, TypeIcon, registerTypeIcon } from '../src/ui/Icon'
import { createRoot } from 'react-dom/client'
let h: Harness
afterEach(() => h?.cleanup())
const select = async (id: string) => {
await act(async () => void click(byId(id)))
await h.settle()
}
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
const qa = (sel: string) => [...h.shadow.querySelectorAll<HTMLElement>(sel)]
const kids = (id: string) => h.cms.graph.children(id).map((e) => e.to)
/** jsdom has no layout: give elements rects. */
const place = (id: string, x: number, y: number, w: number, hh: number) => {
byId(id).getBoundingClientRect = () => ({ x, y, left: x, top: y, right: x + w, bottom: y + hh, width: w, height: hh, toJSON() {} }) as DOMRect
}
const ptr = (type: string, x: number, y: number, el: EventTarget = document, altKey = false) => act(async () => void el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, altKey })))
describe('icons', () => {
it('Icon renders an inline svg path; unknown names fall back; types get a guessed or registered icon', async () => {
const host = document.createElement('div')
const root = createRoot(host)
await act(async () => root.render(<><Icon name="edit" /><Icon name="nope" /><TypeIcon type="consulting-heading" /><TypeIcon type="weird" /><TypeIcon type="custom-x" /></>))
expect(host.querySelectorAll('svg path')).toHaveLength(5)
const d = [...host.querySelectorAll('path')].map((p) => p.getAttribute('d'))
expect(d[0]).not.toBe(d[1]) // edit vs fallback
expect(d[1]).toBe(d[3]) // unknown name and unknown type share the generic shape
expect(d[2]).not.toBe(d[3]) // "heading" guessed from the name
registerTypeIcon('custom-x', 'M0 0h24v24H0z')
await act(async () => root.render(<TypeIcon type="custom-x" />))
expect(host.querySelector('path')!.getAttribute('d')).toBe('M0 0h24v24H0z')
})
})
describe('toolbar overflow', () => {
it('JSON lives under the … menu, not in the toolbar', async () => {
h = await setup()
await h.settle()
await select('t1')
expect(h.shadow.querySelector('[role=toolbar] [aria-label="JSON"]')).toBeNull()
expect(h.shadow.querySelector('.cms-menu')).toBeNull()
await h.press('More actions')
expect(qa('.cms-menu [role=menuitem]').map((b) => b.textContent!.trim())).toEqual(['JSON', 'Copy node id'])
await act(async () => void qa('.cms-menu [role=menuitem]')[0].click())
await h.settle()
expect(h.shadow.querySelector('.cms-menu')).toBeNull() // closes on choice
expect(q('.cms-dialog').textContent).toContain('JSON')
})
})
describe('every property editable where it is rendered', () => {
it('binds nested properties to rendered text on selection and edits them in place with one click', async () => {
h = await setup()
await h.settle()
await select('f1')
const marked = [...byId('f1').querySelectorAll('[data-cms-inferred]')].map((e) => [e.getAttribute('data-cms-field'), e.textContent])
expect(marked).toEqual([['companyName', 'Mock AB'], ['links.0.text', 'About'], ['links.1.text', 'Careers']])
const about = byId('f1').querySelectorAll('a')[0] as HTMLElement
await act(async () => void click(about)) // block already selected: edits instead of selecting
await h.settle()
expect(about.getAttribute('contenteditable')).toBeTruthy()
about.textContent = 'About us'
about.dispatchEvent(new Event('input', { bubbles: true }))
h.editor.ctrl.stopInline()
await h.settle()
const links = h.cms.graph.node('f1').data.links
expect(links).toEqual([{ text: 'About us', href: 'https://example.com/about' }, { text: 'Careers', href: 'https://example.com/jobs', external: true }]) // siblings untouched
await h.press('Undo (Ctrl+Z)')
expect(h.cms.graph.node('f1').data.links[0].text).toBe('About')
})
it('markers follow the selection and are removed from the page when it moves on', async () => {
h = await setup()
await h.settle()
await select('f1')
expect(document.querySelectorAll('[data-cms-inferred]').length).toBe(3)
await select('t1')
expect(byId('f1').querySelectorAll('[data-cms-inferred]').length).toBe(0)
})
it('attribute-bound properties get a chip that edits them (href), nested paths included', async () => {
h = await setup()
await h.settle()
await select('f1')
const chips = qa('[aria-label="Edit href"]')
expect(chips).toHaveLength(2)
await act(async () => void chips[1].click())
await h.settle()
const input = q('.cms-dialog input') as HTMLInputElement
expect(input.value).toBe('https://example.com/jobs')
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(input, 'https://example.com/careers')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => void q('.cms-dialog .cms-btn--primary').click())
await h.settle()
expect(h.cms.graph.node('f1').data.links[1]).toEqual({ text: 'Careers', href: 'https://example.com/careers', external: true })
expect(byId('f1').querySelectorAll('a')[1].getAttribute('href')).toBe('https://example.com/careers')
})
it('explicit template markers keep working (and are not double-bound)', async () => {
h = await setup()
await h.settle()
await select('h1')
expect(byId('h1').getAttribute('data-cms-inferred')).toBeNull()
expect(byId('h1').getAttribute('data-cms-field')).toBe('text')
})
})
describe('block palette and drag & drop', () => {
it('lists the types that fit the page, searchable', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.settle()
const names = qa('.cms-block[data-type]').map((b) => b.dataset.type)
expect(names).toEqual(expect.arrayContaining(['heading', 'text', 'button', 'layout', 'footer']))
expect(names).not.toContain('page') // a page root is not a block you insert
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
await act(async () => {
set.call(q('[aria-label="Search blocks"]'), 'head')
q('[aria-label="Search blocks"]').dispatchEvent(new Event('input', { bubbles: true }))
})
expect(qa('.cms-block > span').map((s) => s.textContent)).toEqual(['Heading', 'Hero (heading + text + button)']) // title shown; the blueprint matches too
})
it('click adds to the selection: inside a container, otherwise after the block', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await select('s1') // container
await act(async () => void q('.cms-block[data-type="button"]').click())
await h.settle()
expect(kids('s1')).toHaveLength(4)
await select('t2') // leaf: goes after it, in s2
await act(async () => void q('.cms-block[data-type="heading"]').click())
await h.settle()
expect(h.cms.graph.node(kids('s2')[1]).dataType).toBe('heading')
})
it('drag from the palette shows a ghost at the target and inserts at the drop index', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
place('s1', 0, 0, 600, 300)
place('h1', 10, 10, 580, 50)
place('t1', 10, 70, 580, 100)
place('b1', 10, 180, 580, 40)
h.editor.ctrl.scan()
const item = q('.cms-block[data-type="heading"]')
await ptr('pointerdown', 50, 400, item)
await ptr('pointermove', 50, 400) // under the threshold: nothing yet
expect(h.editor.ctrl.store.get().drag).toBeNull()
await ptr('pointermove', 80, 100) // past it: drag starts (this event only arms it) ...
await ptr('pointermove', 80, 100) // ... and the next one targets s1
const drag = h.editor.ctrl.store.get().drag!
expect(drag).toMatchObject({ source: 'palette', type: 'heading', target: { parentId: 's1', index: 1 } })
expect(q('.cms-ghost').textContent).toContain('New Heading')
await ptr('pointerup', 80, 100)
await h.settle()
const order = kids('s1')
expect(order).toHaveLength(4)
expect(h.cms.graph.node(order[1]).dataType).toBe('heading')
expect(h.editor.ctrl.store.get().drag).toBeNull()
expect(h.shadow.querySelector('.cms-ghost')).toBeNull()
})
it('Escape cancels a drag; a container that does not accept the type refuses it', async () => {
h = await setup()
await h.settle()
h.editor.ctrl.beginPaletteDrag({ type: 'heading', label: 'heading' })
expect(h.editor.ctrl.store.get().drag).not.toBeNull()
await act(async () => void document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
expect(h.editor.ctrl.store.get().drag).toBeNull()
expect(h.editor.ctrl.accepts('s1', 'heading')).toBe(true)
expect(h.editor.ctrl.accepts('s1', 'page')).toBe(false)
expect(h.editor.ctrl.accepts('t1', 'heading')).toBe(false) // a leaf block holds nothing
expect(h.editor.ctrl.accepts('', 'anything')).toBe(true) // page root
})
it('moving an existing block uses the same engine and respects acceptance', async () => {
h = await setup()
await h.settle()
place('s1', 0, 0, 600, 300)
place('h1', 10, 10, 580, 50)
place('t1', 10, 70, 580, 100)
place('b1', 10, 180, 580, 40)
h.editor.ctrl.scan()
h.editor.ctrl.beginDrag('b1')
await ptr('pointermove', 80, 20)
expect(h.editor.ctrl.store.get().drag).toMatchObject({ source: 'block', id: 'b1', target: { parentId: 's1', index: 0 } })
await ptr('pointerup', 80, 20)
await h.settle()
expect(kids('s1')).toEqual(['b1', 'h1', 't1'])
})
})
describe('insertion slots (virtual "+" elements)', () => {
it('a selected container offers a slot between each child and at the end; a slot opens the picker at that index', async () => {
h = await setup()
await h.settle()
await select('s1')
expect(qa('.cms-slot').map((s) => s.getAttribute('aria-label'))).toEqual(['Add block at position 1', 'Add block at position 2', 'Add block at position 3', 'Add block at position 4'])
await act(async () => void qa('.cms-slot')[1].click())
await h.settle()
expect([...h.shadow.querySelectorAll('.cms-dialog h4')].map((x) => x.textContent)).toEqual(['Content', 'Layout', 'Blueprints']) // grouped by x-graph.group
await act(async () => void q('.cms-dialog .cms-tile[data-type="button"]').click())
await h.settle()
expect(h.cms.graph.node(kids('s1')[1]).dataType).toBe('button')
expect(kids('s1')).toHaveLength(4)
})
it('an empty container shows one big "Add block" slot', async () => {
h = await setup()
await h.settle()
await h.editor.ctrl.insert('s2', 'section')
await h.settle()
expect(qa('.cms-slot--empty')).toHaveLength(1)
expect(q('.cms-slot--empty').textContent).toContain('Add block')
})
it('leaf blocks show no slots', async () => {
h = await setup()
await h.settle()
await select('t1')
expect(qa('.cms-slot')).toHaveLength(0)
})
})
describe('sorting root nodes (snap between siblings)', () => {
const roots = () => {
place('s1', 0, 0, 600, 300)
place('h1', 10, 10, 580, 50)
place('t1', 10, 70, 580, 100)
place('b1', 10, 180, 580, 40)
place('s2', 0, 320, 600, 100)
place('t2', 10, 330, 580, 80)
place('l1', 0, 440, 600, 100) // a container: used to swallow the drop
place('x1', 10, 450, 280, 80)
place('x2', 310, 450, 280, 80)
place('f1', 0, 560, 600, 50)
h.editor.ctrl.scan()
}
const order = () => kids('p1')
it('dragging a root block over another container snaps between roots instead of nesting', async () => {
h = await setup()
await h.settle()
roots()
h.editor.ctrl.beginDrag('s2')
await ptr('pointermove', 80, 500) // deep inside the l1 container
const drag = h.editor.ctrl.store.get().drag!
expect(drag.target).toMatchObject({ parentId: '', index: 2 }) // siblings s1, l1, f1 -> between l1 and f1
expect(drag.target!.snaps).toHaveLength(4)
expect(h.shadow.querySelectorAll('.cms-snap')).toHaveLength(4)
expect(q('.cms-dragchip').textContent).toContain('sorting')
expect(q('.cms-dropline').style.top).toBe('548.5px') // centred in the 540..560 gap
await ptr('pointerup', 80, 500)
await h.settle()
expect(order()).toEqual(['s1', 'l1', 's2', 'f1'])
expect(kids('l1')).toEqual(['x1', 'x2']) // nothing was nested
})
it('the snap marker follows the nearest boundary as the pointer moves', async () => {
h = await setup()
await h.settle()
roots()
h.editor.ctrl.beginDrag('f1')
const idx = () => h.editor.ctrl.store.get().drag!.target!.index
await ptr('pointermove', 80, 20)
expect(idx()).toBe(0)
await ptr('pointermove', 80, 330)
expect(idx()).toBe(1)
await ptr('pointermove', 80, 450)
expect(idx()).toBe(2)
await ptr('pointerup', 80, 20)
await h.settle()
expect(order()).toEqual(['f1', 's1', 's2', 'l1'])
})
it('holding Alt nests into the container under the pointer; pressing/releasing Alt re-targets without a move', async () => {
h = await setup()
await h.settle()
roots()
h.editor.ctrl.beginDrag('s2')
await ptr('pointermove', 80, 500)
expect(h.editor.ctrl.store.get().drag!.target!.parentId).toBe('')
await act(async () => void document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', bubbles: true })))
expect(h.editor.ctrl.store.get().drag!.target).toMatchObject({ parentId: 'l1' })
expect(q('.cms-dragchip').textContent).toContain('nesting')
expect(h.shadow.querySelectorAll('.cms-snap')).toHaveLength(0)
await act(async () => void document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', bubbles: true })))
expect(h.editor.ctrl.store.get().drag!.target!.parentId).toBe('')
await ptr('pointermove', 80, 500, document, true) // moving with Alt held nests
await ptr('pointerup', 80, 500, document, true)
await h.settle()
expect(kids('l1')).toContain('s2')
expect(order()).toEqual(['s1', 'l1', 'f1'])
})
it('dropping a root block where it already is changes nothing; over editor chrome there is no target', async () => {
h = await setup()
await h.settle()
roots()
const edits = () => h.editor.ctrl.store.get().log.filter((l) => l.url === '/api/edit').length
const before = edits()
h.editor.ctrl.beginDrag('s2')
await ptr('pointermove', 80, 300) // nearest boundary is its own slot
await ptr('pointerup', 80, 300)
await h.settle()
expect(edits()).toBe(before)
h.editor.ctrl.beginDrag('s2')
await ptr('pointermove', 900, 300) // beside the page: over the rail / panels
expect(h.editor.ctrl.store.get().drag!.target).toBeNull()
expect(q('.cms-dragchip').textContent).toContain('not here')
await ptr('pointerup', 900, 300)
await h.settle()
expect(edits()).toBe(before)
})
it('nested blocks sort among their siblings the same way, in a row too', async () => {
h = await setup()
await h.settle()
roots()
h.editor.ctrl.beginDrag('x1')
await ptr('pointermove', 520, 480) // right half of the row, over x2
const t = h.editor.ctrl.store.get().drag!.target!
expect(t).toMatchObject({ parentId: 'l1', index: 1 }) // after x2 (siblings without x1: x2 only)
expect(t.line.w).toBe(3) // a vertical line: the siblings are side by side
await ptr('pointerup', 520, 480)
await h.settle()
expect(kids('l1')).toEqual(['x2', 'x1'])
})
})
+85
View File
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
import { attachDialog, registerEditor, registerFieldEditor } from '../src/registry'
import { diffData } from '../src/diff'
import { allowedChildren, fieldKind } from '../src/schema'
let h: Harness
afterEach(() => h?.cleanup())
describe('extension points', () => {
it('registerEditor adds a toolbar button for matching types only, and disposes cleanly', async () => {
h = await setup()
const off = registerEditor({ id: 't-heading', label: 'Heading tools', icon: 'H', match: 'heading', component: () => <p>heading dialog</p> })
await act(async () => void click(byId('h1')))
await h.settle()
expect(h.shadow.querySelector('[aria-label="Heading tools"]')).not.toBeNull()
await act(async () => void click(byId('t1')))
await h.settle()
expect(h.shadow.querySelector('[aria-label="Heading tools"]')).toBeNull()
off()
await act(async () => void click(byId('h1')))
await h.settle()
expect(h.shadow.querySelector('[aria-label="Heading tools"]')).toBeNull()
})
it('attachDialog attaches by selector and opens with the live node + schema', async () => {
h = await setup()
let seen: any
const off = attachDialog('.prose', ({ node, schema }) => ((seen = { id: node.id, type: node.dataType, props: Object.keys(schema.properties) }), <p>custom body editor</p>), { id: 'body-ed', label: 'Body' })
await act(async () => void click(byId('t1')))
await h.settle()
await h.press('Body')
expect(h.shadow.querySelector('.cms-dialog')!.textContent).toContain('custom body editor')
expect(seen).toEqual({ id: 't1', type: 'text', props: ['body'] })
off()
})
it('registerEditor with the same id replaces a built-in', async () => {
h = await setup()
registerEditor({ id: 'properties', label: 'Properties', match: '*', priority: 100, onDoubleClick: true, component: () => <p>my properties</p> })
await act(async () => void click(byId('t1')))
await h.press('Properties')
expect(h.shadow.querySelector('.cms-dialog')!.textContent).toContain('my properties')
})
it('field editors override form widgets by schema', async () => {
h = await setup()
const off = registerFieldEditor({
id: 'tone-swatches',
match: (s, path) => path === 'tone',
component: ({ value, onChange }) => (
<div className="swatches">
{['light', 'dark'].map((t) => (
<button type="button" key={t} aria-pressed={value === t} onClick={() => onChange(t)}>{t}</button>
))}
</div>
),
})
await act(async () => void click(byId('s1')))
await h.press('Properties')
expect(h.shadow.querySelector('.swatches')).not.toBeNull()
off()
})
})
describe('helpers', () => {
it('diffData: changed → value, removed → null, equal → omitted', () => {
expect(diffData({ a: 1, b: 2, c: { x: 1 } }, { a: 1, b: 3, c: { x: 1 } })).toEqual({ b: 3 })
expect(diffData({ a: 1, b: 2 }, { a: 1 })).toEqual({ b: null })
expect(diffData({}, { n: { x: 1 } })).toEqual({ n: { x: 1 } })
})
it('allowedChildren honours x-graph and "*"', () => {
expect(allowedChildren({ 'x-graph': { children: ['a'] } }, ['a', 'b'])).toEqual(['a'])
expect(allowedChildren({ 'x-graph': { children: ['*'] } }, ['a', 'b'])).toEqual(['a', 'b'])
expect(allowedChildren({}, ['a'])).toEqual([])
})
it('fieldKind picks widgets', () => {
expect(fieldKind({ type: 'string', format: 'markdown' })).toBe('markdown')
expect(fieldKind({ type: 'string', enum: ['a'] })).toBe('enum')
expect(fieldKind({ type: 'array' })).toBe('json')
expect(fieldKind({ type: ['string', 'null'] })).toBe('string')
})
})
+10
View File
@@ -0,0 +1,10 @@
// jsdom has no layout: give the few APIs the editor touches harmless defaults.
if (!('scrollIntoView' in Element.prototype)) (Element.prototype as any).scrollIntoView = () => {}
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
// Store updates arrive from fetch/timers outside act(); the harness settles them explicitly.
const error = console.error
console.error = (...a: unknown[]) => {
if (typeof a[0] === 'string' && a[0].includes('not wrapped in act')) return
error(...a)
}
+187
View File
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { fireEvent } from '@testing-library/react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
let h: Harness
afterEach(() => h?.cleanup())
const select = async (id: string) => {
await act(async () => void click(byId(id)))
await h.settle()
}
const data = (id: string) => h.cms.graph.node(id).data
const editOps = () => h.editor.ctrl.store.get().log.filter((l) => l.url === '/api/edit').map((l) => (l.request as any).ops)
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
const chip = (field: string, label: string) => [...h.shadow.querySelectorAll<HTMLElement>(`[data-style-field="${field}"] .cms-chip`)].find((c) => c.textContent!.includes(label))!
describe('Style panel (schema-driven)', () => {
it('loads schemas in the background and matches editors on the schema, not the node type', async () => {
h = await setup()
await h.settle()
expect(h.editor.ctrl.schemaOf('layout')).toBeDefined()
expect(h.editor.ctrl.wrapperType()).toBe('layout')
await select('l1')
expect(h.shadow.querySelector('[aria-label="Style"]')).not.toBeNull()
await select('t1') // a text block has no style fields
expect(h.shadow.querySelector('[aria-label="Style"]')).toBeNull()
})
it('chips write one token through set; reset clears it; the page re-renders with the new class', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
expect(q('[data-style-field="gap"] .cms-chip.is-on').textContent).toBe('md')
await act(async () => void chip('gap', 'lg').click())
await h.settle()
expect(data('l1').gap).toBe('lg')
expect(byId('l1').className).toContain('k6n-gap-lg')
expect(editOps().at(-1)[0]).toMatchObject({ op: 'set', id: 'l1', data: { gap: 'lg' } })
await act(async () => void q('[aria-label="Reset Gap"]').click())
await h.settle()
expect('gap' in data('l1')).toBe(false)
})
it('flow chips hide legacy aliases; columns shows only for grid-like flows', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
const labels = [...h.shadow.querySelectorAll('[data-style-field="flow"] .cms-chip')].map((c) => c.textContent)
expect(labels).toEqual(['Row', 'Stack', 'Actions'])
expect(h.shadow.querySelector('[data-style-field="columns"]')).not.toBeNull()
await act(async () => void chip('flow', 'Stack').click())
await h.settle()
expect(h.shadow.querySelector('[data-style-field="columns"]')).toBeNull()
})
it('box model: per-side selects, overrides show the inherited value, shared fields appear once', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
const sel = (label: string) => h.shadow.querySelector<HTMLSelectElement>(`select[aria-label="${label}"]`)
// l1: padding sm, paddingTop lg (own), margin sm
expect(sel('padding top')!.value).toBe('lg')
expect(sel('padding bottom')!.value).toBe('') // inherits
expect(sel('padding bottom')!.options[0].text).toBe('sm (padding)')
expect(sel('padding all sides')!.value).toBe('sm') // one select for left+right, other edge shows a link
expect(h.shadow.querySelectorAll('.cms-box__link').length).toBeGreaterThan(0)
await act(async () => void fireEvent.change(sel('padding bottom')!, { target: { value: 'xl' } }))
await h.settle()
expect(data('l1').paddingBottom).toBe('xl')
await act(async () => void fireEvent.change(sel('padding top')!, { target: { value: '' } }))
await h.settle()
expect('paddingTop' in data('l1')).toBe(false)
expect(sel('padding top')!.options[0].text).toBe('sm (padding)')
})
it('breakpoint tabs edit responsive.<bp>.* and leave the base alone', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Tablet')!.click())
expect(h.shadow.querySelector('[data-style-field="gap"]')).toBeNull() // spacing has no per-breakpoint form
await act(async () => void chip('flow', 'Stack').click())
await h.settle()
expect(data('l1').responsive).toEqual({ tablet: { flow: 'stack' } })
expect(data('l1').flow).toBe('row')
expect(byId('l1').className).toContain('k6n-layout--t-stack')
await act(async () => void q('[aria-label="Reset Flow"]').click())
await h.settle()
expect('responsive' in data('l1')).toBe(false)
})
it('rapid clicks on one field coalesce into a single undo step', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
for (const t of ['sm', 'lg', 'xl']) await act(async () => void chip('gap', t).click())
await h.settle()
expect(data('l1').gap).toBe('xl')
await h.press('Undo (Ctrl+Z)')
expect(data('l1').gap).toBe('md') // back to before the first click
})
})
describe('wrap in layout (give any block spacing)', () => {
it('wraps in one atomic batch, opens the Style panel, and undoes in one step', async () => {
h = await setup()
await h.settle()
await select('t2')
const edits = editOps().length
await h.press('Wrap in layout')
await h.settle()
const wrapper = byId('t2').closest('[data-cms-type="layout"]')!
expect(wrapper).not.toBeNull()
expect(wrapper.parentElement!.closest('[data-cms]')!.getAttribute('data-cms')).toBe('s2') // took t2's place
expect(editOps().length).toBe(edits + 1)
expect(editOps().at(-1).map((o: any) => o.op)).toEqual(['insert', 'move'])
expect(h.editor.ctrl.store.get().selected).toBe(wrapper.getAttribute('data-cms'))
expect(h.shadow.querySelector('.cms-dialog')!.textContent).toContain('Style')
await h.press('Undo (Ctrl+Z)')
expect(byId('t2').closest('[data-cms-type="layout"]')).toBeNull()
expect(byId('t2').parentElement!.getAttribute('data-cms')).toBe('s2')
})
it('is not offered for blocks that already have style fields', async () => {
h = await setup()
await h.settle()
await select('l1')
expect(h.shadow.querySelector('[aria-label="Wrap in layout"]')).toBeNull()
})
})
describe('on-page spacing handles', () => {
const grips = () => [...h.shadow.querySelectorAll<HTMLElement>('.cms-grip-h')]
const ptr = (el: Element, type: string, x: number, y: number) => act(async () => void el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y })))
it('draws grips only for what the selected block can express', async () => {
h = await setup()
await h.settle()
await select('l1')
const labels = grips().map((g) => g.getAttribute('aria-label')!.split(':')[0])
expect(labels).toEqual(expect.arrayContaining(['padding top', 'padding bottom', 'padding (all sides)', 'margin top', 'margin bottom', 'margin (all sides)']))
await select('t1')
expect(grips()).toHaveLength(0)
})
it('dragging snaps to a token, previews live, and commits a single coalesced set', async () => {
h = await setup()
await h.settle()
// jsdom has no layout/CSS vars: give the page a measured scale and a computed padding-top of 32px.
const px: Record<string, number> = { none: 0, xs: 8, sm: 16, md: 32, lg: 64, xl: 96 }
h.editor.ctrl.measure = (v) => px[v.replace('--k6n-sp-', '')] ?? null
byId('l1').style.paddingTop = '32px'
await select('l1')
const top = () => grips().find((g) => g.getAttribute('aria-label')!.startsWith('padding top'))!
expect(top().getAttribute('aria-label')).toBe('padding top: 32px')
await ptr(top(), 'pointerdown', 50, 100)
await ptr(top(), 'pointermove', 50, 140) // +40px -> 72 -> nearest lg (64)
expect(byId('l1').style.getPropertyValue('padding-top')).toBe('64px') // live preview
expect(h.shadow.querySelector('.cms-tip')!.textContent).toContain('lg')
await ptr(top(), 'pointerup', 50, 140)
await h.settle()
expect(data('l1').paddingTop).toBe('lg') // paddingTop, not the shared padding
expect(data('l1').padding).toBe('sm')
expect(byId('l1').className).toContain('k6n-pt-lg')
expect(byId('l1').style.getPropertyValue('padding-top')).toBe('') // preview removed
})
it('a click without movement changes nothing; Escape/cancel removes the preview', async () => {
h = await setup()
await h.settle()
h.editor.ctrl.measure = () => 10
await select('l1')
const before = editOps().length
const g = grips()[0]
await ptr(g, 'pointerdown', 10, 10)
await ptr(g, 'pointerup', 10, 10)
await h.settle()
expect(editOps().length).toBe(before)
})
})
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { schemas } from '../mock/fixtures'
import { describeStyle, effective, fieldForSide, isWrapperSchema, patchFor } from '../src/style/model'
import { discoverSpacingVar, measureScale, snap } from '../src/style/tokens'
import type { Scale } from '../src/style/tokens'
import { computeHandles, dragPx, gapsBetween, previewProperty } from '../src/style/handles'
const layout = schemas.layout
const fields = describeStyle(layout)
const byName = (n: string) => fields.find((f) => f.name === n)!
/** The same schema with every x-style removed: what an un-annotated deployment serves. */
const stripped = () => {
const s = structuredClone(layout)
for (const p of Object.values<any>(s.properties)) delete p['x-style']
for (const p of Object.values<any>(s.$defs.breakpoint.properties)) delete p['x-style']
delete s['x-style']
return s
}
describe('describeStyle', () => {
it('reads x-style annotations: kinds, spacing box/side/overrides, token, breakpoints', () => {
expect(fields.map((f) => f.name)).toEqual(['flow', 'columns', 'gap', 'padding', 'paddingTop', 'paddingBottom', 'margin', 'marginTop', 'marginBottom', 'align', 'justify', 'tone', 'border'])
expect(byName('paddingTop')).toMatchObject({ kind: 'spacing', box: 'padding', side: 'top', overrides: 'padding', token: '--k6n-sp-{value}', inferred: false })
expect(byName('gap')).toMatchObject({ kind: 'gap', inferred: false })
expect(byName('flow').responsive).toEqual(['mobile', 'tablet', 'desktop'])
expect(byName('gap').responsive).toEqual([]) // spacing is not a breakpoint override (the template has no classes for it)
expect(isWrapperSchema(layout)).toBe(true)
})
it('infers the same fields from names + enums when the schema is not annotated (and says so)', () => {
const inferred = describeStyle(stripped())
expect(inferred.map((f) => [f.name, f.kind, f.box, f.side])).toEqual(fields.map((f) => [f.name, f.kind, f.box, f.side]))
expect(inferred.every((f) => f.inferred)).toBe(true)
expect(inferred.find((f) => f.name === 'marginBottom')).toMatchObject({ overrides: 'margin' })
expect(isWrapperSchema(stripped())).toBe(false) // the wrapper tag is never guessed
})
it('does not mistake ordinary fields for style (enum without a scale, unrelated names)', () => {
const s = { type: 'object', properties: { padding: { type: 'string' }, gap: { type: 'string', enum: ['a', 'b'] }, title: { type: 'string' }, variant: { enum: ['x', 'y'] } } }
expect(describeStyle(s)).toEqual([])
})
it('an annotation wins over naming (any property can be tagged)', () => {
const s = { type: 'object', properties: { inset: { type: 'string', enum: ['none', 'xs', 'sm', 'md'], 'x-style': { kind: 'spacing', box: 'padding', side: 'x' } } } }
expect(describeStyle(s)).toMatchObject([{ name: 'inset', kind: 'spacing', box: 'padding', side: 'x', inferred: false }])
})
const real = '/home/mats/git.k6n.net/ecommerce/data/k6n/schemas/layout.json'
it.skipIf(!existsSync(real))('the real k6n layout schema is annotated and consistent with the mock', () => {
const s = JSON.parse(readFileSync(real, 'utf8'))
const f = describeStyle(s)
expect(f.map((x) => x.name)).toEqual(expect.arrayContaining(fields.map((x) => x.name)))
expect(f.map((x) => x.name)).toContain('reveal')
expect(f.filter((x) => x.inferred)).toEqual([]) // fully annotated
expect(isWrapperSchema(s)).toBe(true)
expect(f.find((x) => x.name === 'paddingTop')).toMatchObject({ overrides: 'padding', token: '--k6n-sp-{value}' })
})
})
describe('values', () => {
it('effective() falls back from an override to its base field', () => {
const d = { padding: 'sm' }
expect(effective(d, fields, byName('paddingTop'))).toMatchObject({ value: 'sm', from: { name: 'padding' } })
expect(effective({ padding: 'sm', paddingTop: 'xl' }, fields, byName('paddingTop'))).toMatchObject({ value: 'xl', from: { name: 'paddingTop' } })
expect(effective({}, fields, byName('paddingTop')).value).toBeUndefined()
})
it('patchFor: base is flat (undefined clears), breakpoints rebuild responsive and prune empties', () => {
expect(patchFor({}, byName('gap'), 'lg')).toEqual({ gap: 'lg' })
expect(patchFor({}, byName('gap'), undefined)).toEqual({ gap: null })
const d = { responsive: { tablet: { columns: 2 }, mobile: { flow: 'stack' } } }
expect(patchFor(d, byName('columns'), 3, 'tablet')).toEqual({ responsive: { tablet: { columns: 3 }, mobile: { flow: 'stack' } } })
expect(patchFor(d, byName('columns'), undefined, 'tablet')).toEqual({ responsive: { mobile: { flow: 'stack' } } })
expect(patchFor({ responsive: { tablet: { columns: 2 } } }, byName('columns'), undefined, 'tablet')).toEqual({ responsive: null })
expect(d.responsive.tablet.columns).toBe(2) // input untouched
})
it('fieldForSide: exact side, then axis, then all', () => {
expect(fieldForSide(fields, 'padding', 'top')!.name).toBe('paddingTop')
expect(fieldForSide(fields, 'padding', 'left')!.name).toBe('padding')
expect(fieldForSide(fields, 'margin', 'bottom')!.name).toBe('marginBottom')
expect(fieldForSide(describeStyle({ type: 'object', properties: { gap: layout.properties.gap } }), 'padding', 'top')).toBeUndefined()
})
})
describe('token scales', () => {
const scale: Scale = { values: ['none', 'xs', 'sm', 'md', 'lg', 'xl'], px: [0, 8, 16, 32, 64, 96] }
it('snaps to the nearest measured size', () => {
expect(snap(scale, 'md', 32, 0)).toBe('md')
expect(snap(scale, 'md', 32, 12)).toBe('md') // 44 -> md(32) vs lg(64): md
expect(snap(scale, 'md', 32, 20)).toBe('lg') // 52 -> lg
expect(snap(scale, 'md', 32, -100)).toBe('none') // clamps at 0
expect(snap(scale, undefined, 0, 500)).toBe('xl')
})
it('steps ordinally when sizes cannot be measured', () => {
const blind: Scale = { values: scale.values, px: scale.values.map(() => null) }
expect(snap(blind, 'sm', 0, 0)).toBe('sm')
expect(snap(blind, 'sm', 0, 60)).toBe('lg') // 2 steps of 28px
expect(snap(blind, 'sm', 0, -500)).toBe('none')
})
it('measureScale resolves each value through the field token template', () => {
const asked: string[] = []
const sc = measureScale(byName('gap'), (v) => (asked.push(v), asked.length * 10))
expect(asked[0]).toBe('--k6n-sp-none')
expect(asked).toContain('--k6n-sp-md')
expect(sc.px).toEqual([10, 20, 30, 40, 50, 60])
})
it('discovers the spacing variable prefix from the page stylesheets', () => {
const style = { length: 2, 0: '--acme-sp-sm', 1: '--acme-sp-md' }
const doc = { styleSheets: [{ cssRules: [{ style }] }] } as unknown as Document
expect(discoverSpacingVar(doc)).toBe('--acme-sp-{value}')
expect(discoverSpacingVar({ styleSheets: [] } as unknown as Document)).toBeUndefined()
})
})
describe('handles', () => {
const rect = { x: 100, y: 200, w: 400, h: 300 }
const box = (t: number, r: number, b: number, l: number) => ({ top: t, right: r, bottom: b, left: l })
const base = { rect, padding: box(40, 20, 10, 20), margin: box(16, 0, 16, 0), children: [], fields }
it('creates a handle per side the schema can express, with the right field, axis and sign', () => {
const hs = computeHandles(base)
const get = (id: string) => hs.find((h) => h.id === id)!
expect(hs.filter((h) => h.kind === 'padding')).toHaveLength(4)
expect(get('padding-top')).toMatchObject({ axis: 'y', sign: 1, px: 40, shared: false, field: { name: 'paddingTop' } })
expect(get('padding-bottom')).toMatchObject({ axis: 'y', sign: -1, field: { name: 'paddingBottom' } })
expect(get('padding-left')).toMatchObject({ axis: 'x', sign: 1, shared: true, field: { name: 'padding' } }) // only "all" covers left/right
expect(get('padding-right')).toMatchObject({ axis: 'x', sign: -1, shared: true })
expect(get('margin-top')).toMatchObject({ axis: 'y', sign: -1 }) // margin grows outwards
expect(get('margin-bottom')).toMatchObject({ axis: 'y', sign: 1 })
})
it('places bands inside the box for padding and outside for margin, grips on the free edge', () => {
const hs = computeHandles(base)
const pt = hs.find((h) => h.id === 'padding-top')!
expect(pt.band).toEqual({ x: 100, y: 200, w: 400, h: 40 })
expect(pt.grip.y + pt.grip.h / 2).toBe(240) // centred on where the 40px ends
const mt = hs.find((h) => h.id === 'margin-top')!
expect(mt.band).toEqual({ x: 100, y: 184, w: 400, h: 16 })
expect(mt.grip.y + mt.grip.h / 2).toBe(184)
})
it('a zero-size value still gets a grabbable grip', () => {
const h = computeHandles({ ...base, padding: box(0, 0, 0, 0) }).find((x) => x.id === 'padding-top')!
expect(h.band.h).toBe(0)
expect(h.grip.h).toBeGreaterThanOrEqual(14)
})
it('no spacing fields -> no handles', () => {
expect(computeHandles({ ...base, fields: fields.filter((f) => f.kind === 'flow' || f.kind === 'columns') })).toEqual([])
})
it('gap bands come from the space between neighbouring children (rows and columns)', () => {
const c = (x: number, y: number) => ({ x, y, w: 100, h: 50 })
// 2x2 grid with a 20px column gap and a 10px row gap
const g = gapsBetween([c(0, 0), c(120, 0), c(0, 60), c(120, 60)])
expect(g.filter((x) => x.axis === 'x').map((x) => x.rect.w)).toEqual([20, 20])
expect(g.filter((x) => x.axis === 'y').map((x) => x.rect.h)).toEqual([10, 10])
expect(gapsBetween([c(0, 0)])).toEqual([])
const hs = computeHandles({ ...base, children: [c(0, 0), c(120, 0)] })
expect(hs.filter((h) => h.kind === 'gap')).toMatchObject([{ axis: 'x', px: 20, field: { name: 'gap' } }])
})
it('dragPx follows the sign and never goes negative', () => {
expect(dragPx({ axis: 'y', sign: 1, px: 40 }, 0, 25)).toBe(65)
expect(dragPx({ axis: 'y', sign: -1, px: 40 }, 0, 25)).toBe(15)
expect(dragPx({ axis: 'x', sign: 1, px: 10 }, -50, 99)).toBe(0)
})
it('previewProperty maps a field to the CSS property to preview', () => {
expect(previewProperty(byName('paddingTop'), 'top')).toBe('padding-top')
expect(previewProperty(byName('margin'), 'left')).toBe('margin')
expect(previewProperty(byName('gap'), 'gap')).toBe('gap')
expect(previewProperty({ ...byName('padding'), side: 'x' }, 'left')).toBe('padding-inline')
})
})
+190
View File
@@ -0,0 +1,190 @@
import { afterEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { byId, click, setup } from './harness'
import type { Harness } from './harness'
import { OTHER_GROUP, groupOf, labelKey, sortGroups } from '../src/schema'
import { schemas } from '../mock/fixtures'
let h: Harness
afterEach(() => h?.cleanup())
const select = async (id: string) => {
await act(async () => void click(byId(id)))
await h.settle()
}
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
const qa = (sel: string) => [...h.shadow.querySelectorAll<HTMLElement>(sel)]
const label = (id: string) => h.editor.ctrl.labelFor(id)
describe('x-graph label / group helpers', () => {
it('read the label property and the group from the schema; sort groups with the catch-all last', () => {
expect(labelKey({ 'x-graph': { group: 'Commerce', label: 'title' } })).toBe('title')
expect(groupOf({ 'x-graph': { group: 'Commerce', label: 'title' } })).toBe('Commerce')
expect(labelKey({ 'x-graph': {} })).toBeUndefined()
expect(labelKey(undefined)).toBeUndefined()
expect(groupOf({ 'x-graph': { group: 3 } })).toBeUndefined()
expect(sortGroups(['Layout', OTHER_GROUP, 'Commerce', 'Layout', 'Content'])).toEqual(['Commerce', 'Content', 'Layout', OTHER_GROUP])
expect(labelKey(schemas.footer)).toBe('companyName')
})
})
describe('node titles (x-graph.label)', () => {
it('loads every title with ONE tree request, not one request per block', async () => {
h = await setup()
await h.settle()
await h.settle()
expect(h.cms.requests.filter((r) => r.startsWith('GET /api/tree/')).length).toBe(1)
expect(h.cms.requests.filter((r) => /^GET \/api\/nodes\/[^/]+$/.test(r))).toEqual([])
expect(['s1', 'h1', 'b1', 'f1', 'l1'].map(label)).toEqual(['Welcome', 'Edit this headline', 'Get in touch', 'Mock AB', 'Two columns'])
expect(label('t1')).toBeUndefined() // the text type declares no label
})
it('shows the title in the outline (type as the secondary line), falling back to type + text', async () => {
h = await setup()
await h.settle()
await h.settle()
await h.press('Outline')
const items = qa('.cms-outline li button')
const s1 = items.find((b) => b.textContent!.includes('Welcome'))!
expect(s1.querySelector('.cms-outline__type')!.textContent).toBe('Welcome')
expect(s1.querySelector('.cms-outline__text')!.textContent).toBe('section')
const t1 = items.find((b) => b.querySelector('.cms-outline__type')!.textContent === 'text')!
expect(t1.querySelector('.cms-outline__text')!.textContent).toContain('A paragraph')
})
it('titles the toolbar chip (type and group in its tooltip), the hover tag and the dialog', async () => {
h = await setup()
await h.settle()
await h.settle()
await select('s1')
expect(q('.cms-bar__name').textContent).toBe('Welcome')
expect(q('.cms-bar__name').getAttribute('title')).toBe('section · Layout')
h.editor.ctrl.store.set({ hovered: 'f1' })
await h.settle()
const tag = q('.cms-frame__tag')
expect(tag.textContent).toContain('Mock AB')
expect(tag.textContent).toContain('footer')
expect(tag.querySelector('em')!.textContent).toBe('Layout')
await h.press('Properties')
expect(q('.cms-dialog__head').textContent).toContain('Properties · Welcome')
await select('t1')
expect(q('.cms-bar__name').textContent).toBe('text') // no label key: the type
})
it('titles follow edits live, and new nodes get theirs lazily', async () => {
h = await setup()
await h.settle()
await h.settle()
await select('s1')
await h.editor.ctrl.setData('s1', { title: 'Renamed' })
await h.settle()
await h.settle()
expect(label('s1')).toBe('Renamed')
expect(q('.cms-bar__name').textContent).toBe('Renamed')
const res = await h.editor.ctrl.insert('s2', 'button', { data: { label: 'Click me' } })
await h.settle()
const id = res!.results[0].node!
label(id) // first ask kicks off the fetch ...
await h.settle()
await h.settle()
expect(label(id)).toBe('Click me') // ... and it is there once loaded
})
it('long titles are shortened', async () => {
h = await setup()
await h.settle()
await h.editor.ctrl.setData('h1', { text: 'x'.repeat(80) })
await h.settle()
await h.settle()
expect(label('h1')!.length).toBe(40)
expect(label('h1')!.endsWith('…')).toBe(true)
})
})
describe('groups in the UI', () => {
it('the palette is grouped by x-graph.group (collapsible), blueprints last', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.settle()
await h.settle()
const secs = qa('.cms-group-sec')
expect(secs.map((s) => s.querySelector('summary')!.textContent!.replace(/\s*\d+$/, '').trim())).toEqual(['Content', 'Layout', 'Blueprints'])
const inGroup = (g: string) => [...secs.find((s) => s.querySelector('summary')!.textContent!.startsWith(g))!.querySelectorAll<HTMLElement>('.cms-block')].map((x) => x.dataset.type)
expect(inGroup('Content')).toEqual(['heading', 'text', 'button'])
expect(inGroup('Layout')).toEqual(expect.arrayContaining(['section', 'layout', 'footer']))
expect(secs.every((s) => (s as HTMLDetailsElement).open)).toBe(true)
await act(async () => void ((secs[0].querySelector('summary') as HTMLElement).click()))
expect((secs[0] as HTMLDetailsElement).open).toBe(false)
})
it('the insert picker groups the allowed types too', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Add inside')
await h.settle()
await h.settle()
expect(qa('.cms-dialog h4').map((x) => x.textContent)).toEqual(['Content', 'Layout', 'Blueprints'])
expect(qa('.cms-dialog .cms-tile').map((x) => x.dataset.type)).toEqual(['heading', 'text', 'button', 'section', 'layout', undefined]) // + the blueprint tile
})
})
describe('the block list shows titles and groups straight from /api/types', () => {
it('seeds every schema from the one types request - no per-type schema fetches for the palette', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.settle()
await h.settle()
// /api/types carries each type's schema: types that are not on the page are never fetched individually
expect(h.cms.requests.filter((r) => r.startsWith('GET /api/schemas/page'))).toEqual([])
expect(h.cms.requests.filter((r) => r === 'GET /api/types').length).toBe(1)
expect(h.editor.ctrl.schemaOf('page')).toBeDefined()
expect(h.editor.ctrl.groupOfType('page')).toBe('Pages')
})
it('palette items show the schema title with the type id underneath (disambiguates look-alikes)', async () => {
h = await setup()
await h.settle()
await h.press('Blocks')
await h.settle()
const heading = q('.cms-block[data-type="heading"]')
expect(heading.querySelector('span')!.textContent).toBe('Heading')
expect(heading.querySelector('small')!.textContent).toBe('heading')
expect(heading.getAttribute('title')).toBe('Add Heading')
expect(h.editor.ctrl.typeTitle('footer')).toBe('Footer')
expect(h.editor.ctrl.typeTitle('unknown-type')).toBe('unknown-type')
})
it('page-root (staticRoot) types are not offered, even where a container would accept anything', async () => {
h = await setup()
await h.settle()
expect(h.editor.ctrl.isStaticRoot('page')).toBe(true)
expect(h.editor.ctrl.isStaticRoot('heading')).toBe(false)
await h.press('Blocks')
await h.settle()
expect(qa('.cms-block[data-type="page"]')).toHaveLength(0)
expect(qa('.cms-group-sec summary').map((s) => s.textContent)).not.toContain('Pages')
})
it('the insert picker shows titles too', async () => {
h = await setup()
await h.settle()
await select('s1')
await h.press('Add inside')
await h.settle()
const tile = q('.cms-dialog .cms-tile[data-type="layout"]')
expect(tile.querySelector('span')!.textContent).toBe('Layout')
expect(tile.querySelector('small')!.textContent).toBe('layout')
})
it('ingestTypes tolerates entries without a schema (they stay "Other" until loaded)', async () => {
h = await setup()
await h.settle()
h.editor.ctrl.ingestTypes([{ type: 'mystery' }, 'bare-string', { type: 'with', schema: { title: 'With', 'x-graph': { group: 'Odd' } } }])
expect(h.editor.ctrl.groupOfType('mystery')).toBe('Other')
expect(h.editor.ctrl.groupOfType('with')).toBe('Odd')
expect(h.editor.ctrl.typeTitle('with')).toBe('With')
})
})
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": false,
"skipLibCheck": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": { "@k6n/cms-editor": ["../ecommerce/backoffice/web/packages/cms-editor/src/index.ts"] }
},
"include": ["src", "mock", "vite", "vite.config.ts", "test"]
}
+46
View File
@@ -0,0 +1,46 @@
import { defineConfig, loadEnv, type UserConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'node:path'
import { cmsLivePlugin } from './vite/live'
import { cmsMockPlugin } from './vite/mock'
export default defineConfig(({ command, mode }): UserConfig => {
const env = { ...loadEnv(mode, process.cwd(), ''), ...process.env } as Record<string, string>
const editorSrc = path.resolve(env.CMS_EDITOR_SRC || '../ecommerce/backoffice/web/packages/cms-editor/src')
const common: UserConfig = {
plugins: [react()],
resolve: {
alias: { '@k6n/cms-editor': path.join(editorSrc, 'index.ts') },
// The shared package imports tiptap from its own location; keep a single copy.
dedupe: ['react', 'react-dom', '@tiptap/core', '@tiptap/pm'],
},
server: { port: 5173, strictPort: true /* the issuer's CORS allowlist names localhost:5173 */, fs: { allow: [process.cwd(), path.resolve(editorSrc, '..')] } },
test: {
environment: 'jsdom',
environmentOptions: { jsdom: { pretendToBeVisual: true } },
include: ['test/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}', 'mock/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
},
} as UserConfig
if (command === 'build') {
// The artifact served at /editor/onpage-editor.js. One self-contained file;
// only loaded when the preview cookie is present (see src/main.ts).
return {
...common,
define: { 'process.env.NODE_ENV': '"production"' },
build: {
outDir: 'dist',
lib: { entry: 'src/main.ts', formats: ['es'], fileName: () => 'onpage-editor.js' },
sourcemap: true,
cssCodeSplit: false,
},
}
}
const mock = (env.CMS_MODE || 'live') === 'mock'
return {
...common,
plugins: [...(common.plugins ?? []), mock ? cmsMockPlugin() : cmsLivePlugin(env)],
}
})
+91
View File
@@ -0,0 +1,91 @@
// Dev server for the *live* CMS: everything is proxied to CMS_TARGET, and the
// page HTML (which the proxy can't rewrite reliably - compression, streaming)
// is fetched server-side so the editor's dev entry can be injected into it.
//
// Auth never reaches the browser: CMS_TOKEN / CMS_API_KEY / CMS_COOKIE are added
// to every upstream request here.
import type { Plugin, ViteDevServer } from 'vite'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { injectDevEntry, isPageRequest, isViteOwned, upstreamHeaders } from './shared'
export function cmsLivePlugin(env: Record<string, string>): Plugin {
const target = (env.CMS_TARGET || 'https://k6n.net').replace(/\/$/, '')
const fixed = { token: env.CMS_TOKEN, apiKey: env.CMS_API_KEY, cookie: env.CMS_COOKIE }
// Token the browser got through the in-editor device login (POST /__dev/token). Env credentials win.
let runtimeToken: string | undefined
const creds = () => ({ ...fixed, token: fixed.token || runtimeToken })
return {
name: 'cms-live',
config: () => ({
server: {
proxy: {
// Anything Vite doesn't own goes to the real site (assets, /api, /editor, fonts...).
'^/(?!@|src/|node_modules/|__).*': {
target,
changeOrigin: true,
secure: true,
cookieDomainRewrite: '',
cookiePathRewrite: '/',
bypass: (req) => (isViteOwned(req.url ?? '') ? req.url : undefined),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
const h = upstreamHeaders(req.headers, creds())
for (const [k, v] of Object.entries(h)) proxyReq.setHeader(k, v)
})
},
},
},
},
}),
configureServer(server: ViteDevServer) {
// Registered directly (not in a returned fn) so it runs BEFORE the proxy.
server.middlewares.use(async (req: IncomingMessage, res: ServerResponse, next) => {
if (req.url === '/__dev/token') {
if (req.method === 'DELETE') runtimeToken = undefined
else if (req.method === 'POST') {
const chunks: Buffer[] = []
for await (const c of req) chunks.push(c as Buffer)
try {
runtimeToken = (JSON.parse(Buffer.concat(chunks).toString()) as { accessToken?: string }).accessToken
} catch {
/* ignore malformed */
}
}
res.statusCode = 204
return void res.end()
}
if (!isPageRequest(req)) return next()
try {
const upstream = await fetch(target + req.url, {
headers: upstreamHeaders(req.headers, creds(), { identity: true }),
redirect: 'manual',
})
const type = upstream.headers.get('content-type') ?? ''
res.statusCode = upstream.status
upstream.headers.forEach((v, k) => {
// fetch() already decoded the body; CSP would block our injected module.
if (['content-encoding', 'content-length', 'transfer-encoding', 'content-security-policy', 'strict-transport-security'].includes(k)) return
if (k === 'location') v = v.replace(target, '')
if (k === 'set-cookie') return
res.setHeader(k, v)
})
for (const c of upstream.headers.getSetCookie()) {
res.appendHeader('set-cookie', c.replace(/;\s*domain=[^;]*/i, '').replace(/;\s*secure/i, ''))
}
let body = await upstream.text()
if (type.includes('text/html')) {
body = await server.transformIndexHtml(req.url!, injectDevEntry(body))
res.setHeader('x-cms-dev', 'injected')
res.setHeader('cache-control', 'no-store') // never let the browser reuse a stale published page
}
res.end(body)
} catch (e) {
res.statusCode = 502
res.setHeader('content-type', 'text/plain')
res.end(`dev proxy: cannot reach ${target}: ${(e as Error).message}`)
}
})
},
}
}
+39
View File
@@ -0,0 +1,39 @@
// `npm run dev:mock`: no network, no login. Serves a fixture page and the mock API.
import type { Plugin } from 'vite'
import { createMockCms } from '../mock/server'
import { injectDevEntry } from './shared'
export function cmsMockPlugin(): Plugin {
// CMS_MOCK_AUTH=1 makes the mock require the device-login flow (approve on the mock /device page) so the login UI can be developed.
const cms = createMockCms(process.env.CMS_MOCK_AUTH ? { auth: true } : {})
return {
name: 'cms-mock',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const url = req.url ?? '/'
if (url.startsWith('/__dev/')) return void ((res.statusCode = 204), res.end())
const isApi = url.startsWith('/api/')
const isReset = url === '/__mock/reset' && req.method === 'POST'
const isMock = url === '/device' || url.startsWith('/device?') || url === '/__mock/approve'
const isPage = req.method === 'GET' && !/^\/(@|src\/|node_modules\/|mock\/|__)/.test(url) && String(req.headers.accept ?? '').includes('text/html')
if (!isApi && !isReset && !isPage && !isMock) return next()
if (isReset) {
cms.reset()
res.statusCode = 204
return void res.end()
}
const chunks: Buffer[] = []
for await (const c of req) chunks.push(c as Buffer)
const body = chunks.length ? Buffer.concat(chunks) : undefined
const resp = await cms.handle(
new Request(new URL(url, `http://${req.headers.host ?? 'localhost'}`), { method: req.method, headers: req.headers as Record<string, string>, body: body && req.method !== 'GET' ? body : undefined }),
)
res.statusCode = resp.status
resp.headers.forEach((v, k) => res.setHeader(k, v))
let text = await resp.text()
if (resp.headers.get('x-cms-page')) text = await server.transformIndexHtml(url, injectDevEntry(text))
res.end(text)
})
},
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { IncomingHttpHeaders, IncomingMessage } from 'node:http'
/** Paths Vite itself serves (HMR client, source modules, deps). */
export const isViteOwned = (url: string) =>
/^\/(@|src\/|node_modules\/|__|mock\/|test\/)/.test(url)
/** A navigation to a page we should fetch + inject, as opposed to an asset/API call. */
export function isPageRequest(req: IncomingMessage): boolean {
const url = req.url ?? '/'
if (req.method !== 'GET' || isViteOwned(url)) return false
if (/^\/(api|editor|assets|js|css|fonts|static)(\/|$)/.test(url)) return false
if (/\.[a-z0-9]{2,5}($|\?)/i.test(url.split('?')[0])) return false // has a file extension
return String(req.headers.accept ?? '').includes('text/html')
}
export interface Auth {
token?: string
apiKey?: string
cookie?: string
}
const hasPreviewCookie = (cookie?: string) => /(?:^|;\s*)cms_preview=1/.test(cookie ?? '')
/** Headers for an upstream request: browser cookies + configured credentials + preview flag. */
export function upstreamHeaders(
incoming: IncomingHttpHeaders,
auth: Auth,
opts: { identity?: boolean } = {},
): Record<string, string> {
const h: Record<string, string> = {}
if (incoming.accept) h.accept = String(incoming.accept)
if (incoming['accept-language']) h['accept-language'] = String(incoming['accept-language'])
if (opts.identity) h['accept-encoding'] = 'identity'
const cookie = [incoming.cookie, auth.cookie].filter(Boolean).join('; ')
if (cookie) h.cookie = cookie
// The edge routes on the cookie OR this header; send both so a proxy hop can't lose it.
if (hasPreviewCookie(cookie)) h['x-cms-preview'] = '1'
if (auth.token) h.authorization = `Bearer ${auth.token}`
else if (incoming.authorization) h.authorization = String(incoming.authorization)
if (auth.apiKey) h['x-api-key'] = auth.apiKey
return h
}
/** Put the dev entry in front of </body> (or at the end). */
export function injectDevEntry(html: string): string {
const tag = '<script type="module" src="/src/dev.ts"></script>'
return /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${tag}</body>`) : html + tag
}