Add on-page editor for the go-graph-cms
Injectable React editor that is side-loaded onto the statically rendered page and edits through the CMS HTTP API (POST /api/edit). - Shadow-root UI over server HTML; patches morphed in, undo/redo from server inverses (reuses @k6n/cms-editor EditClient/morph/markdown editor) - Sign-in via go-auth device grant as the CMS client (aud matches), token refresh + 401 retry; live preview swapped in on load and on tab focus - Schema-driven property form (arrays, maps, variants, $ref, validation) - Every string property editable where it is rendered (value-bound inline fields and attribute chips), markdown WYSIWYG for marked fields - Block palette + drag & drop with ghost drop targets, insertion slots, sort snapping between siblings (Alt to nest) - x-style annotations: on-page padding/margin/gap handles, Style panel, wrap-in-layout; x-graph label/group for titles and grouping - Dev workflow: Vite proxy to the live site (token injected server-side) or an offline in-memory mock CMS; jsdom test suite Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1af9ec3172
67 files changed
+12322
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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>`
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
Generated
+3617
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+29
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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 })
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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())} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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'])
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}"]`)!
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)],
|
||||
}
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user