commit 1af9ec317232eaef3dfc57a5b69d48b27874082e Author: Mats Törnberg Date: Sat Oct 31 22:34:48 2026 +0100 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..60cacb3 --- /dev/null +++ b/.env.example @@ -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 +# CMS_API_KEY - a minted key with write scope -> X-API-Key: +# CMS_COOKIE - the admin session cookie string -> Cookie: +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1640b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +.env.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff10859 --- /dev/null +++ b/README.md @@ -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=""` 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=`, then the same with `&locale=`, 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. diff --git a/docs/styling.md b/docs/styling.md new file mode 100644 index 0000000..6cbbd87 --- /dev/null +++ b/docs/styling.md @@ -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 `
\n${body}\n
` +} diff --git a/mock/graph.test.ts b/mock/graph.test.ts new file mode 100644 index 0000000..02e3ddb --- /dev/null +++ b/mock/graph.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { seedGraph } from './fixtures' +import { HttpError } from './graph' + +const kids = (g: ReturnType, 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) + }) +}) diff --git a/mock/graph.ts b/mock/graph.ts new file mode 100644 index 0000000..7d956fb --- /dev/null +++ b/mock/graph.ts @@ -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; 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 + +export class Graph { + nodes = new Map() + edges = new Map() + private seq = 100 + + constructor(seed?: { nodes: Omit[]; edges: Omit[] }) { + 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() + 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 = {} + for (const [k, v] of Object.entries(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() + 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) + } +} diff --git a/mock/server.ts b/mock/server.ts new file mode 100644 index 0000000..f1aa589 --- /dev/null +++ b/mock/server.ts @@ -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() + 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 { + 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( + `Mock approval

Approve sign-in?

Code MOCK-1234

`, + { 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, + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..642ef7f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3617 @@ +{ + "name": "cms-onpageeditor", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cms-onpageeditor", + "version": "0.1.0", + "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" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz", + "integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.12", + "@babel/types": "^7.29.11", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz", + "integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.12", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.12", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.11", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mdi/js": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", + "integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==", + "license": "Apache-2.0" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.3.tgz", + "integrity": "sha512-o4kGHKu2YRn/jegh1XMs6+1/3SuX6AfWaACGEWavFvN5D6YZd9OrMEBqcIMj0uTeKZr6nTXPhlpkc0IXC6DMXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.3.tgz", + "integrity": "sha512-vfEEFajQ0K5crrtAAyownI2ZFthoY+BTwGnjoMgd4+l3YPuAcweykaMqcMYXT1wJKCnMiSIpNnAroAWix2fJ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.3.tgz", + "integrity": "sha512-/oNPEUU5nVDZmp1CYGDMIGdUBzON5z5RQufJbtjnOD4+CZYt//2ECHRvvmJL3EXLHaqQ7Zjr1dSTlhLCxJJbGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.3.tgz", + "integrity": "sha512-Otbw33hEDwrM/jUn3nib5JLJL0vyTE0ZlmU8L44sCS+AJm0JiZs3ZlNUYj141L4k7XxYmOWtf+9cN2Kqog7OzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.3.tgz", + "integrity": "sha512-3CNtrc+6X9HL+fzSkKF0o3WkBdO6YBm5PhbLnR9PeQNKLLL7ir4MDtlx6gvP9ZM3Uxjue3xVysAX7+VjWX66Ng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.3.tgz", + "integrity": "sha512-KfHwLGjSfgOKHoyzhqqKNhohxNErfYRR2dNWXN6kn5iWMrDNldZopob+Ft/q6aurweYHRz+7M9HuFZ7nvYj5Xg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.3.tgz", + "integrity": "sha512-fXWMWNshsQrNi9pq8FJIpTZfLjhLUqDCabW1hq2Qv1kxzz7I63fRjFRNAC0C49xK9CyUngL/tWbSYjpKo2+GRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.3.tgz", + "integrity": "sha512-22gepfGL1b0Aay36BYddC4mqvQoUgQwGU/5zrUK2BdJucWgWytbhxY4o3iSIbtZsSsFiiRlNT2xfAjrCsDQGQw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.3.tgz", + "integrity": "sha512-l04417gOo3y7xaQu52PeXGgy17dSPAa8qhs4iqxMDHKyAUpldNFsasy0kR+Z7CFifnTgwFGmyukDWLIZYEnyQQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.3.tgz", + "integrity": "sha512-0SEhbnWkUv29OhSANlnnOKQSWs5KFoJ0FTQ3ZhxZunUjgBagQzzUmLgxpulYSpldXAGOCK5XLvvspoPngjEWpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.3.tgz", + "integrity": "sha512-2wLIbgSqKUcEc4DHrQDHOg9LSfgfSr+rAoBj9wromzLMLSclupvEKHTEugGSuGuklrF7wF0dzXo9R5VymKXgcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.3.tgz", + "integrity": "sha512-cV3cQixj3ILfdOmvJeXu6GedaH/Xlm4/qtNAUxvVK0tQnzICits97mwuvWY/DZs0lEZTkbCqZmpO/+ne6hs5iQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.3.tgz", + "integrity": "sha512-DTnriHdEmvrGgPWHVoV8LfwkLsmG2oK9GYvlfV6+beMcdVWVYnNC3R6+jL9DhTVOIpv+Ys9A+1i9rM8aeayOZw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.3.tgz", + "integrity": "sha512-2+v1frx+8t084SIinkBBURODj1lpTqLZO53INfozQa0+bnCNEGtRFag8Gi6OMV9kEK67zIRIqncfyKe1eIYGHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.3.tgz", + "integrity": "sha512-vEi902pAjMHXPXOpU4Jr58jMMiZxTf8kkhis1xiLBtLtlm6oRaX+mgFjOsqMdbGt80lS+bfnTCEsAofbK9isLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.3.tgz", + "integrity": "sha512-1Nm/kYmkKDcc9gJ9cRVsRPBrNUqLEaY5b2zDk2OsQMcWd2nJiFUpQf3q4T1IEVKgyUQSMuiVNDemLXcuKh/f9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.3.tgz", + "integrity": "sha512-bx93yN/wit1aY9lja1lRh2jfRPFDBfeI9kHoytXDi1Jw6dWz2P2zd6B3oKnQOHa5fTtpHmX9/swWOA2+jWLjKw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.3.tgz", + "integrity": "sha512-Nt1/uFKauyn2bwGcYaIcF9DA3TORW45XrtdhThm5QPu0TNDpDC5onMNSh8fzvB+klrnLKTCISCw8lR9xpxrEFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.3.tgz", + "integrity": "sha512-K8HqIGH9CD57XmZFo/0T3pafDAzAoB9Iof02FEFvOXg7lBn7vlkLEjHgtZkYp+srWANrlxTSf8BolQM/27kiCw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.3.tgz", + "integrity": "sha512-6vTihCOamPjOpgcjkXOFB+Lh+LM4Eb95EPYNHjcovwxsnQrFRIJ7M664fLnGklKFOZutrhR9/o+NfgQLE4Zmdg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.3.tgz", + "integrity": "sha512-k5XxCKgPGASSGyXOhXVQQJM6VIRHteqwHBsbwhW55EoimmI2jfkPebwgg/lwcjezpvgyLu8YTTCRsdSrvHKA7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.3.tgz", + "integrity": "sha512-WYqJUhonki85lB0iQ/Ro9BNJiGd7rG7GBZBiB/h4EUBWiUKyI3vq6EZ1aTyy5Y8qVuSIx4ZdqDyAXYiR59Vtvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.3.tgz", + "integrity": "sha512-WJgIOxBAnyZy2nXZdSk1xtDP0nmU41qfB47jCZ5VCxCipt2/aoImPtXMyPYB1fzXcpmNsAtYxBCY2d2WFYo00g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.3.tgz", + "integrity": "sha512-C0vJ/s69Fll/puogTouGiFkn7kFI7nIAQc8/PTVawth9VNpNYIpv9xrZr6syRaFzSxVhTUt5zoXUFLELGs6vVA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.3.tgz", + "integrity": "sha512-UFCIgrIBg2jU6Ajkrx+AwGz0EWpYCRTjr6wh+jBCY+A6r8pCDeeejTtMbbBszRdnmtUCzlXwwA4h/f4f3zPRSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.3.tgz", + "integrity": "sha512-tlynfm3LIQtOCjFdT7kjyMnGrl0PbhyldgCfj7b1VxafD6uYiJFjd2ATzmYb+YnzNIzNezpK7XzFzDtPu37IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tiptap/core": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.2.tgz", + "integrity": "sha512-EitM8KY3agYfnp/bqhfxcaReVq4CUqVzMqoSIl7xSfYABTGYoTDtVXUdYSGutcCBFjc86BbXjrOlDlqkgC8BsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.32.2" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.32.2.tgz", + "integrity": "sha512-VLcdnbOm7G52NpUhgbyo3XIOz6Eky7/DyGYuYnCpEUe0TE5xk7Qs6ukrh9vaf2XhyeLDHsmVZuzybGrdtqoC0w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2", + "@tiptap/pm": "3.32.2" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.32.2.tgz", + "integrity": "sha512-5Q8bScZEZbIBB2tBgnc7Cr6XuH2Nt/6l1HP3FO0wWO7nqXPOcodEbjslePI3v1iag+LNWX16BZ8fDrJr/PbxXA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.32.2.tgz", + "integrity": "sha512-YP7IlC3swrNPlnFHIgVDsDl70geqqWtcAkbuncQEs5FJ9UQg432VwVfwCUW4NsG532p5AqlTTwOKcSLS7p/pqA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.2" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.32.2.tgz", + "integrity": "sha512-jm7qXuDmBzQYPemm/9chLepQjCNVtZhaoU9N2f4GxdUi8u/fi3AQLP7xlXjRCQUyam50FVqIIWgD567mLQ3sMg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.32.2.tgz", + "integrity": "sha512-KFkPMMgXEgXImGpbUsrHNaERtvOGbJ/JiWn0zHNGSJCf6frn44OLeRFPpEMptmwMqtJxftBVFbEbcPOlATYqDw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2", + "@tiptap/pm": "3.32.2" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.32.2.tgz", + "integrity": "sha512-zKWj8jh1kpckfSwle+kqFn0ZnYvkhb7nz89nrNvpnnsQMbbGU2/+bUAfYU3XSEKZr3VBvW176FtlMPD8ggC3Cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.32.2.tgz", + "integrity": "sha512-mE9muf4KSY3s+LCaHG/G957pVHVF2q46i+YkZF3ZG70errQcyZ5YFKnce9PSL1fiL0VLL+2UhMAL2QDM5oDiNA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.32.2" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.32.2.tgz", + "integrity": "sha512-7W/iX8h6KQ2HK2SfvzCIgySOEJwTM4owqz1tXVOPiLPvKSvDDa9GQsp60+xNyqDNHuplE8bRsZ5dH1nilgAaAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.32.2" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.32.2.tgz", + "integrity": "sha512-oIdXmya2XBzp3Cw9Nr36agPvr9ZXP3p2fQXaFflxYYBKOG3UUzw38CId7J6K1jYxBf8c1zodbiKFEL7rttJ6fQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.32.2.tgz", + "integrity": "sha512-AE2WNA3jVnPV/cVyTwTmtmGZqX8LiiGicDHXX4bdvfzv+Aez4sgzdkuFjOGWtnEy43NnXdoI1UX7y/984NM0iA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.32.2.tgz", + "integrity": "sha512-Pl0YRS60R339yHrAWGhPT9WHIlD72BO6SGVdj8lBs2OAaYw5RQL/1BGZi+7UvAyc2yJxaRC4XN9X4jdQHDUq9A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2", + "@tiptap/pm": "3.32.2" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.32.2.tgz", + "integrity": "sha512-TRjp//2r1MTE1emLXxEki6+D0bOPYaX41CnM1hrUnp1iw/cS0a4XBXAWXxK/EFEl94zrqWrafkarU9rXpnBHeg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.32.2.tgz", + "integrity": "sha512-aqk8OK4Xo59qZni7YSy42/ltQFb2IOmli3YERIq1XPQ4vl5sxoYhL+sFoOPmvbjHvaHZpK6n8vPh1SmAnB570A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.32.2.tgz", + "integrity": "sha512-EwQtMXsU1Vc03n+yud7lfaR+RjHTPwFyI/hcEylvJba7iIXDSLA/ey4mnpwt2od8CEx/ZcOutIryORKVR9Py/Q==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2", + "@tiptap/pm": "3.32.2" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.32.2.tgz", + "integrity": "sha512-rLYk3GXSswrCakyZwo8VW0+yjnYhLJSBRvzClneIrlPhqQ2r/oDUjeqkpm8lGvwREw1lDSh7wefGhkhTRcQkAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2", + "@tiptap/pm": "3.32.2" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.32.2.tgz", + "integrity": "sha512-vTdPfzZODLVXog/NMs6zcaTOhtlh6ptJidlMg8l1TLhO+YQyEyO/K45aV429D9xTo/BOVazreav/C+hgemSnYg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.2" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.32.2.tgz", + "integrity": "sha512-SioQO8TqRb8aq9L+GpI/a/F7HCSBarm3Uvqtt2YttyazjSjEedu1zSnaa93V+WEFmlqyXnxVa24vv2zj6qlavQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.2" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.32.2.tgz", + "integrity": "sha512-WQw2fLtJAxCYftKAk+YNlPDc+OxtXK4nNfXRBwr6oC67SoJtoLe0bo5jS6vF+cEqzHjDaboJi+O3MSNp2L2VQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.2" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.32.2.tgz", + "integrity": "sha512-3bqC//4GEhUiux3dhW/TJuveBp9ADv4nHlqBdVQa3A+USGbaBvE9YvXxA32wZL0leSRykxEnHbNHZ1J4KjIynQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.32.2.tgz", + "integrity": "sha512-i3d4vCrZySS9g5nGa5W0LS6LPMKxcAnJO6uADTnGVlKK0ewklcWWZbZ9GB/Fpx/IjKAvXWeayjfsNpTae/yVZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.32.2.tgz", + "integrity": "sha512-W6G7UbQ71TyvZvORDdj6u8By8B/Dymy8KoaKGaNHqB82SpM42ZK2TmeFBHI0Lo4wTkZJL3LuI+QthaDvh0USsA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.32.2.tgz", + "integrity": "sha512-kY7B5ocKx3JzqIl2kLXGvnLGZRxuYEFzMkpgJ6Vh6/VTTK+JCeOVItYEQGFrAxrodIMmhIrXpi1jF57s6iZswg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.32.2.tgz", + "integrity": "sha512-TUE1JedPjYQ/T527f2a4xz+4Xi1a5nIgW8KwC0DjmqbaHirlz0eCNI2nMEN70pu5WPpqgXmki3lCF3LScQxIGw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2", + "@tiptap/pm": "3.32.2" + } + }, + "node_modules/@tiptap/markdown": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.32.2.tgz", + "integrity": "sha512-sV/TZS8CC37FV+XinyfI5RyVVKvF2FuPu1efSKTdk3itPY1+a6Sxa+4G63ylLn1TCiKr19d7CuJ4yXmqJmJJ/w==", + "license": "MIT", + "dependencies": { + "marked": "^17.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.2", + "@tiptap/pm": "3.32.2" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.2.tgz", + "integrity": "sha512-4cuUbYf55NnYBIvDBK/K1VFVQ4pocKwAZC5TAy4s9dptvazxVHkQVMbiQsyhwfIbM4rxmHWkM/6omaFfTB++/g==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.4", + "prosemirror-commands": "^1.7.2", + "prosemirror-dropcursor": "^1.8.4", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.1", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.12", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.2", + "prosemirror-view": "^1.42.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.32.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.32.2.tgz", + "integrity": "sha512-6K+oOMF4orhnOxivgUTKiSpNAEftHyWOcfFIvIEMUP044b34obBUogzMTfM3RjtewZtQXVafO9zmGU30IllC1g==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.32.2", + "@tiptap/extension-blockquote": "3.32.2", + "@tiptap/extension-bold": "3.32.2", + "@tiptap/extension-bullet-list": "3.32.2", + "@tiptap/extension-code": "3.32.2", + "@tiptap/extension-code-block": "3.32.2", + "@tiptap/extension-document": "3.32.2", + "@tiptap/extension-dropcursor": "3.32.2", + "@tiptap/extension-gapcursor": "3.32.2", + "@tiptap/extension-hard-break": "3.32.2", + "@tiptap/extension-heading": "3.32.2", + "@tiptap/extension-horizontal-rule": "3.32.2", + "@tiptap/extension-italic": "3.32.2", + "@tiptap/extension-link": "3.32.2", + "@tiptap/extension-list": "3.32.2", + "@tiptap/extension-list-item": "3.32.2", + "@tiptap/extension-list-keymap": "3.32.2", + "@tiptap/extension-ordered-list": "3.32.2", + "@tiptap/extension-paragraph": "3.32.2", + "@tiptap/extension-strike": "3.32.2", + "@tiptap/extension-text": "3.32.2", + "@tiptap/extension-underline": "3.32.2", + "@tiptap/extensions": "3.32.2", + "@tiptap/pm": "3.32.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001820", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz", + "integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.472", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz", + "integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/linkifyjs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.4.0.tgz", + "integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.63", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz", + "integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.5.tgz", + "integrity": "sha512-hv3Qhsn5GyoferiBqL8euGvj3Y8Z+2Ef75NnPLChr5DTWArpPAjK6F96Yc0iM7SBYNDPQbQaA+xEtAR8NuWo9A==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz", + "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz", + "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.12", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", + "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.6.0.tgz", + "integrity": "sha512-G1srDtan+VqWn+qpmWFj/6j0YsZ5zMU+ZIlGVIW41a5JF2jAI3jzfMRWmSABmEH2Qoq1PmAG/o2mucMMdLOsLg==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz", + "integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.9", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.9.tgz", + "integrity": "sha512-bKbfzeKZOu2H2JQU2TaJmYu6EBcor3VnGIR4jR31cbEt1ztUnfUB6bKGV1+fJzOJdrVWR+tRU0208zM3oQx4wA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.12", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.3.tgz", + "integrity": "sha512-HJXOntSbeXzBfjUQ+YfgcOR+NV+8queObyCqWWOy2YL6bXDFE7vNRgVXlOUyrQqfJnxdXcL27PXlkC0O3ze6jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.67.3", + "@rollup/rollup-android-arm64": "4.67.3", + "@rollup/rollup-android-x64": "4.67.3", + "@rollup/rollup-darwin-arm64": "4.67.3", + "@rollup/rollup-darwin-x64": "4.67.3", + "@rollup/rollup-freebsd-arm64": "4.67.3", + "@rollup/rollup-freebsd-x64": "4.67.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.3", + "@rollup/rollup-linux-arm-musleabihf": "4.67.3", + "@rollup/rollup-linux-arm64-gnu": "4.67.3", + "@rollup/rollup-linux-arm64-musl": "4.67.3", + "@rollup/rollup-linux-loong64-gnu": "4.67.3", + "@rollup/rollup-linux-loong64-musl": "4.67.3", + "@rollup/rollup-linux-ppc64-gnu": "4.67.3", + "@rollup/rollup-linux-ppc64-musl": "4.67.3", + "@rollup/rollup-linux-riscv64-gnu": "4.67.3", + "@rollup/rollup-linux-riscv64-musl": "4.67.3", + "@rollup/rollup-linux-s390x-gnu": "4.67.3", + "@rollup/rollup-linux-x64-gnu": "4.67.3", + "@rollup/rollup-linux-x64-musl": "4.67.3", + "@rollup/rollup-openbsd-x64": "4.67.3", + "@rollup/rollup-openharmony-arm64": "4.67.3", + "@rollup/rollup-win32-arm64-msvc": "4.67.3", + "@rollup/rollup-win32-ia32-msvc": "4.67.3", + "@rollup/rollup-win32-x64-gnu": "4.67.3", + "@rollup/rollup-win32-x64-msvc": "4.67.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d4bdbd2 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..7fec699 --- /dev/null +++ b/src/api.ts @@ -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 + 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 + +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>() + private typesP?: Promise + private blueprintsP?: Promise + 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) => 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), ...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(method: string, path: string, body?: unknown): Promise { + 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 = (path: string) => this.request('GET', path) + post = (path: string, body?: unknown) => this.request('POST', path, body) + + me = () => this.get<{ [k: string]: unknown }>('/api/auth/me') + node = (id: string) => this.get(`/api/nodes/${encodeURIComponent(id)}`) + edges = (id: string) => this.get(`/api/nodes/${encodeURIComponent(id)}/edges`) + edit = (ops: Op[]) => this.post('/api/edit', { ops }) + publish = (roots: string[]) => this.post('/api/publish-roots', { roots }) + + /** Schemas are code: cache for the session. */ + schema(type: string): Promise { + let p = this.schemas.get(type) + if (!p) { + p = this.get(`/api/schemas/${encodeURIComponent(type)}`) + p.catch(() => this.schemas.delete(type)) + this.schemas.set(type, p) + } + return p + } + types(): Promise { + return (this.typesP ??= this.get('/api/types').then((r) => (Array.isArray(r) ? r : r.types ?? []))) + } + blueprints(): Promise { + return (this.blueprintsP ??= this.get('/api/blueprints').catch(() => [])) + } +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..43f2d86 --- /dev/null +++ b/src/auth.ts @@ -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 + /** + * 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 + /** 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 + 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 = createStore({ status: 'unknown' }) + private tokens: Tokens | null = null + private refreshing: Promise | null = null + private abort: AbortController | null = null + private issuerP?: Promise + private configP?: Promise<{ issuer?: string; clientId?: string }> + private readonly doFetch: typeof fetch + private readonly sleep: (ms: number) => Promise + 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 { + 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 { + 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 { + if (!this.tokens?.refreshToken) return Promise.resolve(false) + return (this.refreshing ??= this.doRefresh().finally(() => (this.refreshing = null))) + } + private async doRefresh(): Promise { + 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 { + 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 { + 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>): Promise { + 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 { + 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 }) + } +} diff --git a/src/boot.ts b/src/boot.ts new file mode 100644 index 0000000..5dc7c9a --- /dev/null +++ b/src/boot.ts @@ -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> { + /** 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 , 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 +} diff --git a/src/controller.ts b/src/controller.ts new file mode 100644 index 0000000..9265bd9 --- /dev/null +++ b/src/controller.ts @@ -0,0 +1,1119 @@ +// The controller owns everything stateful: the page scan, selection, the +// EditClient (undo/redo from server inverses), patch application and the dialog +// stack. UI components are thin views over `ctrl.store`; extensions get the +// controller in their props. +import { createElement } from 'react' +import type { ReactNode } from 'react' +import { EditClient, EditError, applyPatches, morphChildren, notifyIslandsAdded, notifyIslandsRemoving } from '@k6n/cms-editor' +import type { EditResult, Op, Patch } from '@k6n/cms-editor' +import { Api, ApiError } from './api' +import type { AuthManager, AuthState } from './auth' +import type { CmsEdge, CmsNode, JSONSchema, RequestLog } from './api' +import { blockEditors, editorsFor, inlineEditors } from './registry' +import { dropTarget, moveTarget } from './page/dnd' +import { INFERRED_ATTR, collectStrings, getIn, inferBindings, markInferred, parsePath, patchForPath } from './page/infer' +import type { AttrBinding } from './page/infer' +import { OTHER_GROUP, acceptsChild, groupOf, labelKey, typeName } from './schema' +import { describeStyle, patchFor } from './style/model' +import type { Breakpoint, StyleField } from './style/model' +import { measureScale, probeMeasure } from './style/tokens' +import type { Measure, Scale } from './style/tokens' +import type { DropTarget } from './page/dnd' +import type { BlockEditorDef, BlockInfo, InlineSession } from './registry' +import { createStore } from './store' +import type { Store } from './store' + +export interface Block extends BlockInfo { + depth: number +} + +export interface DialogInstance { + key: number + title: string + size: 'sm' | 'md' | 'lg' + content: ReactNode + /** Page-space rect of the block it belongs to, for initial placement. */ + anchor?: { x: number; y: number } + /** Identity for de-duplication: opening a dialog whose id is already open focuses the existing one. */ + id?: string + /** Stacking order (higher = on top); raised when the dialog is opened again or clicked. */ + z: number + /** Bumped to ask the dialog to pulse and take focus. */ + pulse: number +} + +export interface EditorState { + mode: 'edit' | 'browse' + selected: string | null + hovered: string | null + blocks: Block[] + /** Bumped when geometry may have changed (scroll/resize/DOM mutation). */ + tick: number + dialogs: DialogInstance[] + canUndo: boolean + canRedo: boolean + busy: number + toast: { kind: 'info' | 'error'; text: string; n: number; action?: { label: string; run(): void } } | null + drag: { id: string | null; source: 'block' | 'palette'; type?: string; label: string; target: DropTarget | null; pointer?: { x: number; y: number }; /** Alt held: nesting into containers instead of sorting among siblings */ nesting?: boolean } | null + /** Attribute-bound properties of the selected block (href, src, alt…), shown as chips on the page. */ + bindings: AttrBinding[] + /** Bumped when a schema finishes loading (editors that match on schema re-evaluate). */ + schemasVersion: number + /** Bumped when node titles (x-graph.label values) become known or change. */ + labelsVersion: number + /** Bumped after every applied edit/undo/redo (open panels re-read their node). */ + editRev: number + /** Left rail: page outline or the block palette. */ + rail: 'outline' | 'blocks' | null + dev: boolean + inline: { id: string; field: string } | null + log: RequestLog[] + /** Page root node id, learned from the first top-level block's parent edge. */ + rootId: string | null + dirty: boolean + auth: AuthState + undoHint: boolean +} + +export interface ControllerOptions { + api?: Api + doc?: Document + /** Elements belonging to the editor UI (never selectable / morphed). */ + host: HTMLElement + /** Reload strategy when a patch can't be applied. Default: soft re-fetch of the page. */ + reload?: () => Promise +} + +const BLOCK_SEL = '[data-cms]' + +export class Controller { + readonly store: Store + readonly api: Api + readonly auth: AuthManager + readonly client: EditClient + readonly doc: Document + readonly host: HTMLElement + private nodes = new Map>() + private edgeCache = new Map>() + private inlineSession: (InlineSession & { id: string }) | null = null + private cleanup: Array<() => void> = [] + private dialogKey = 0 + private toastN = 0 + private scanFrame = 0 + private reloadImpl: () => Promise + + constructor(opts: ControllerOptions) { + this.doc = opts.doc ?? document + this.host = opts.host + this.store = createStore({ + mode: 'edit', + selected: null, + hovered: null, + blocks: [], + tick: 0, + dialogs: [], + canUndo: false, + canRedo: false, + busy: 0, + toast: null, + rail: null, + dev: false, + inline: null, + log: [], + rootId: null, + dirty: false, + auth: { status: 'unknown' }, + undoHint: false, + drag: null, + bindings: [], + schemasVersion: 0, + labelsVersion: 0, + editRev: 0, + }) + this.api = opts.api ?? new Api() + this.auth = this.api.auth + this.auth.state.subscribe(() => this.onAuthChange()) + // Feed the dev panel even when the caller supplied its own Api. + const prevLog = this.api.opts.onLog + this.api.opts.onLog = (e) => { + prevLog?.(e) + this.store.set((s) => ({ log: [...s.log.slice(-99), e] })) + } + this.client = new EditClient({ + fetch: this.api.loggedFetch, + onPatches: (patches, res) => this.onPatches(patches, res), + onChange: () => this.store.set({ canUndo: this.client.canUndo, canRedo: this.client.canRedo }), + }) + this.reloadImpl = opts.reload ?? (() => this.softReload()) + } + + // --- lifecycle ---------------------------------------------------------- + + start(): void { + const doc = this.doc + const win = doc.defaultView! + this.scan() + // Already signed in? The page we hold may be the published file: swap in the live preview now. + void this.checkAuth().then(async (st) => { + if (st === 'signed-in') { + this.lastPreview = Date.now() + await this.softReload() + void this.loadTree() + } + this.scan() + }) + this.doc.addEventListener('visibilitychange', this.onVisible) + this.cleanup.push(() => this.doc.removeEventListener('visibilitychange', this.onVisible)) + const on = (t: K, f: (e: DocumentEventMap[K]) => void, capture = true) => { + doc.addEventListener(t, f, capture) + this.cleanup.push(() => doc.removeEventListener(t, f, capture)) + } + on('click', (e) => this.onClick(e)) + on('dblclick', (e) => this.onDblClick(e)) + on('mouseover', (e) => this.onHover(e)) + on('keydown', (e) => this.onKey(e)) + const tick = () => this.store.set((s) => ({ tick: s.tick + 1 })) + win.addEventListener('scroll', tick, { passive: true, capture: true }) + win.addEventListener('resize', tick) + this.cleanup.push(() => win.removeEventListener('scroll', tick, true), () => win.removeEventListener('resize', tick)) + const mo = new win.MutationObserver((records) => { + if (records.every((r) => this.host.contains(r.target))) return + this.scheduleScan() + }) + mo.observe(doc.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-cms', 'data-cms-type', 'data-cms-container'] }) + this.cleanup.push(() => mo.disconnect()) + // Edit mode: stop links/forms from navigating away mid-edit. + doc.documentElement.setAttribute('data-cms-editing', this.store.get().mode) + this.cleanup.push(() => doc.documentElement.removeAttribute('data-cms-editing')) + } + + destroy(): void { + this.clearPreview() + this.cancelDrag() + this.stopInline() + this.cleanup.forEach((f) => f()) + this.cleanup = [] + if (this.scanFrame) this.doc.defaultView!.cancelAnimationFrame(this.scanFrame) + } + + // --- page scan ---------------------------------------------------------- + + private scheduleScan() { + if (this.scanFrame) return + const win = this.doc.defaultView! + this.scanFrame = win.requestAnimationFrame(() => { + this.scanFrame = 0 + this.scan() + }) + } + + scan(): Block[] { + const els = Array.from(this.doc.querySelectorAll(BLOCK_SEL)).filter((el) => !this.host.contains(el)) + const blocks: Block[] = els.map((el) => { + const parent = el.parentElement?.closest(BLOCK_SEL) ?? null + let depth = 0 + for (let p = parent; p; p = p.parentElement?.closest(BLOCK_SEL) ?? null) depth++ + const type = el.getAttribute('data-cms-type') ?? '' + const ctrl = this + return { + id: el.getAttribute('data-cms') ?? '', + type, + el, + container: el.getAttribute('data-cms-container') === 'true', + parentId: parent?.getAttribute('data-cms') ?? undefined, + depth, + get schema() { + return ctrl.schemaCache.get(type) + }, + } + }) + this.prefetchSchemas(blocks.map((b) => b.type)) + const prev = this.store.get().blocks + const same = prev.length === blocks.length && prev.every((b, i) => b.el === blocks[i].el && b.type === blocks[i].type && b.container === blocks[i].container) + this.store.set((s) => ({ blocks: same ? s.blocks : blocks, tick: s.tick + 1 })) + return blocks + } + + block(id: string | null | undefined): Block | undefined { + return id ? this.store.get().blocks.find((b) => b.id === id) : undefined + } + + private blockFromEvent(e: Event): Block | undefined { + if (e.composedPath().includes(this.host)) return undefined + const el = (e.target instanceof Element ? e.target : null)?.closest(BLOCK_SEL) + return el ? this.store.get().blocks.find((b) => b.el === el) : undefined + } + + /** Editing is live: edit mode AND a signed-in session. */ + get active(): boolean { + const s = this.store.get() + return s.mode === 'edit' && s.auth.status === 'signed-in' + } + + // --- sign-in --------------------------------------------------------------- + + private lastStatus = 'unknown' + private onAuthChange() { + const auth = this.auth.state.get() + this.store.set({ auth }) + if (auth.status === 'signed-out' && this.lastStatus === 'signed-in') { + this.stopInline() + this.store.set({ selected: null, hovered: null }) + } + if (auth.status === 'signed-in' && this.lastStatus !== 'signed-in') queueMicrotask(() => this.scan()) + this.lastStatus = auth.status + } + + /** Probe the session; the login panel appears when it fails. */ + async checkAuth() { + return this.auth.check(() => this.api.me()) + } + + async signIn() { + // Open the tab inside the click's user gesture; point it at the approval page once known. + const w = typeof window !== 'undefined' ? window.open('', '_blank') : null + if (w) w.opener = null + const ok = await this.auth.login((url) => { + if (w) w.location.href = url + }) + if (w && !w.closed) w.close() + if (ok) await this.afterSignIn() + } + + /** Paste-a-token escape hatch. */ + async signInWithToken(token: string) { + this.auth.useToken(token) + await this.afterSignIn() + } + + private async afterSignIn() { + if ((await this.checkAuth()) !== 'signed-in') return + this.client.clear() + this.nodes.clear() + this.edgeCache.clear() + await this.softReload() // the page may have been the published one: swap to live preview + this.scan() + void this.loadTree() + this.toast('info', `Signed in as ${this.auth.state.get().user}`) + } + + signOut() { + this.auth.logout() + this.toast('info', 'Signed out') + } + + // --- selection & events ------------------------------------------------- + + select(id: string | null, opts: { scroll?: boolean } = {}) { + if (id !== this.store.get().selected) { + this.stopInline() + this.clearInferred() + } + this.store.set({ selected: id }) + void this.refreshBindings() + if (id && opts.scroll) this.block(id)?.el.scrollIntoView({ block: 'center', behavior: 'smooth' }) + } + + setMode(mode: 'edit' | 'browse') { + this.stopInline() + this.store.set({ mode, selected: mode === 'browse' ? null : this.store.get().selected, hovered: null }) + this.doc.documentElement.setAttribute('data-cms-editing', mode) + } + + private onHover(e: MouseEvent) { + if (!this.active) return + const b = this.blockFromEvent(e) + const id = b?.id ?? null + if (id !== this.store.get().hovered) this.store.set({ hovered: id }) + } + + private onClick(e: MouseEvent) { + if (!this.active || e.composedPath().includes(this.host)) return + if (this.inlineSession && (e.target as Element)?.closest?.('[contenteditable]')) return // caret placement + const b = this.blockFromEvent(e) + if (!b) return + e.preventDefault() + e.stopPropagation() + // Second click on a text field of the already-selected block edits it in place. + const fieldEl = (e.target as Element).closest('[data-cms-field][data-cms-editor]') + if (b.id === this.store.get().selected && fieldEl && fieldEl.closest(BLOCK_SEL) === b.el) return void this.startInline(b.id, fieldEl) + this.select(b.id) + } + + private onDblClick(e: MouseEvent) { + if (!this.active) return + const b = this.blockFromEvent(e) + if (!b) return + e.preventDefault() + e.stopPropagation() + const fieldEl = (e.target as Element).closest('[data-cms-field][data-cms-editor]') + if (fieldEl && b.el.contains(fieldEl) && fieldEl.closest(BLOCK_SEL) === b.el) { + void this.startInline(b.id, fieldEl) + return + } + const def = editorsFor(b).find((d) => d.onDoubleClick) ?? editorsFor(b)[0] + if (def) void this.openEditor(def, b.id) + } + + private onKey(e: KeyboardEvent) { + const t = e.target as HTMLElement | null + const typing = !!t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName)) + const inHost = e.composedPath().includes(this.host) + if (e.key === 'Escape') { + const { dialogs } = this.store.get() + if (dialogs.length) this.closeDialog(dialogs.reduce((top, x) => (x.z > top.z ? x : top)).key) + else if (this.inlineSession) this.stopInline() + else if (this.store.get().selected) this.select(null) + else return + e.preventDefault() + return + } + if (typing || inHost) return + const mod = e.metaKey || e.ctrlKey + if (mod && e.key.toLowerCase() === 'z') { + e.preventDefault() + void (e.shiftKey ? this.redo() : this.undo()) + } else if (mod && e.key.toLowerCase() === 'y') { + e.preventDefault() + void this.redo() + } else if ((e.key === 'Delete' || e.key === 'Backspace') && this.store.get().selected && this.active) { + e.preventDefault() + void this.remove(this.store.get().selected!) + } + } + + // --- data ---------------------------------------------------------------- + + node(id: string, fresh = false): Promise { + if (fresh) this.nodes.delete(id) + let p = this.nodes.get(id) + if (!p) { + p = this.api.node(id) + p.then((n) => this.remember(n)).catch(() => this.nodes.delete(id)) + this.nodes.set(id, p) + } + return p + } + + // --- node titles (x-graph.label) and groups (x-graph.group) ------------------------------- + + private snaps = new Map() + private snapAsked = new Set() + private labelsTimer: ReturnType | null = null + private remember(n: CmsNode) { + this.snaps.set(n.id, n) + // coalesce: a page load remembers dozens of nodes at once + this.labelsTimer ??= setTimeout(() => { + this.labelsTimer = null + this.store.set((st) => ({ labelsVersion: st.labelsVersion + 1 })) + }, 16) + } + + /** + * The node's title: the value of the property its type names in `x-graph.label` ("title"), or undefined + * (no label key, empty, or not loaded yet - in which case it is fetched in the background and + * `labelsVersion` bumps when it arrives). + */ + labelFor(id: string): string | undefined { + const n = this.snaps.get(id) + if (!n) { + if (this.signedIn() && !this.snapAsked.has(id)) { + this.snapAsked.add(id) + void this.node(id).catch(() => this.snapAsked.delete(id)) + } + return undefined + } + const key = labelKey(this.schemaCache.get(n.dataType)) + const v = key ? n.data?.[key] : undefined + const text = typeof v === 'string' || typeof v === 'number' ? String(v).replace(/\s+/g, ' ').trim() : '' + return text ? (text.length > 40 ? `${text.slice(0, 39)}…` : text) : undefined + } + + private staticRoots = new Set() + /** + * `/api/types` carries every type's full schema (and whether it is a page root), so one request seeds + * titles, groups, labels and acceptance rules for all types - no per-type schema fetch needed. + */ + ingestTypes(entries: unknown[]) { + let changed = false + for (const e of entries as Array<{ type?: string; schema?: JSONSchema; staticRoot?: boolean }>) { + const t = typeName(e) + if (!t) continue + if (e && typeof e === 'object' && e.schema && typeof e.schema === 'object' && !this.schemaCache.has(t)) { + this.schemaCache.set(t, e.schema) + changed = true + } + if (e && typeof e === 'object' && e.staticRoot) this.staticRoots.add(t) + } + if (changed) this.store.set((st) => ({ schemasVersion: st.schemasVersion + 1 })) + } + /** Page-root types (static pages): they are not blocks you insert into a page. */ + isStaticRoot(type: string) { + return this.staticRoots.has(type) + } + /** Human name of a type: the schema's `title` ("Horizontal row"), else the type id. */ + typeTitle(type: string): string { + const t = this.schemaCache.get(type)?.title + return typeof t === 'string' && t ? t : type + } + + /** Category of a type (x-graph.group); OTHER_GROUP when unknown or unlabelled. */ + groupOfType(type: string): string { + return groupOf(this.schemaCache.get(type)) ?? OTHER_GROUP + } + + /** Load schemas for these types in the background (bounded concurrency); bumps schemasVersion as they land. */ + async loadSchemas(types: string[], concurrency = 6): Promise { + const todo = types.filter((t) => t && !this.schemaCache.has(t)) + let next = 0 + const worker = async () => { + while (next < todo.length) { + const t = todo[next++] + try { + this.schemaCache.set(t, await this.api.schema(t)) + } catch { + /* a type without a schema just stays in "Other" */ + } + } + this.store.set((st) => ({ schemasVersion: st.schemasVersion + 1 })) + } + await Promise.all(Array.from({ length: Math.min(concurrency, todo.length) }, worker)) + } + + /** One request for the whole page's node data (and child edges) instead of one per block. */ + async loadTree(): Promise { + try { + const root = await this.pageRoot() + const tree = await this.api.get(`/api/tree/${encodeURIComponent(root)}?depth=-1`) + const walk = (t: any) => { + const n = t?.node as CmsNode | undefined + if (n?.id) { + this.nodes.set(n.id, Promise.resolve(n)) + this.snapAsked.add(n.id) + this.remember(n) + } + for (const c of t?.children ?? []) walk(c) + } + walk(tree) + } catch { + /* labels then arrive lazily, per node */ + } + } + schema = (type: string): Promise => this.api.schema(type) + + private schemaCache = new Map() + private schemaFailed = new Map() + private typesRequested = false + /** Synchronous schema lookup for editors that match on schema; undefined until loaded. */ + schemaOf(type: string): JSONSchema | undefined { + return this.schemaCache.get(type) + } + /** Load schemas for the types on the page (and the style wrapper) in the background. */ + private prefetchSchemas(types: Iterable) { + if (!this.signedIn()) return + if (!this.typeList.length && !this.typesRequested) { + this.typesRequested = true + this.api + .types() + .then((ts) => { + this.typeList = ts.map(typeName).filter(Boolean) + this.ingestTypes(ts) + this.store.set((st) => ({ schemasVersion: st.schemasVersion + 1 })) + }) + .catch(() => (this.typesRequested = false)) + } + for (const t of new Set([...types, 'layout'])) { + if (!t || this.schemaCache.has(t) || Date.now() - (this.schemaFailed.get(t) ?? 0) < 30_000) continue + this.schemaFailed.set(t, Date.now()) // in flight / just failed: do not hammer + this.api + .schema(t) + .then((s) => { + this.schemaCache.set(t, s) + this.schemaFailed.delete(t) + this.store.set((st) => ({ schemasVersion: st.schemasVersion + 1 })) + }) + .catch(() => {}) + } + } + private typeList: string[] = [] + /** Node type names the CMS knows (for `x-graph.children: true`). */ + get allTypes(): string[] { + return this.typeList + } + /** Can `type` be inserted into the block `parentId` ('' = page root, always allowed)? */ + accepts(parentId: string, type: string): boolean { + if (!parentId) return true + const b = this.block(parentId) + return !!b && acceptsChild(b.schema, type, this.typeList) + } + + /** The node type used to wrap blocks for styling: the schema tagged `x-style.wrapper`. */ + wrapperType(): string | undefined { + for (const [t, s] of this.schemaCache) if (s['x-style']?.wrapper) return t + return undefined + } + + /** Parent edges of a node (cached until the next structural change). */ + private parentEdges(id: string): Promise { + let p = this.edgeCache.get(`in:${id}`) + if (!p) { + p = this.api.edges(id).then((r) => r.into ?? []) + p.catch(() => this.edgeCache.delete(`in:${id}`)) + this.edgeCache.set(`in:${id}`, p) + } + return p + } + private childEdges(id: string): Promise { + let p = this.edgeCache.get(`out:${id}`) + if (!p) { + p = this.api.edges(id).then((r) => (r.from ?? []).filter((e) => !e.type || e.type === 'child').sort((a, b) => a.sort - b.sort)) + p.catch(() => this.edgeCache.delete(`out:${id}`)) + this.edgeCache.set(`out:${id}`, p) + } + return p + } + + /** The composition edge that places this block in its parent. */ + async edgeOf(id: string): Promise { + const b = this.block(id) + const attr = b?.el.getAttribute('data-cms-edge') + const into = (await this.parentEdges(id)).filter((e) => !e.type || e.type === 'child') + const hit = attr ? into.find((e) => e.id === attr) : undefined + const edge = hit ?? into.find((e) => e.from === b?.parentId) ?? (b?.parentId ? undefined : into[0]) + if (!edge) throw new Error(`cannot resolve the edge of ${id} (parent ${b?.parentId ?? 'page root'})`) + if (!b?.parentId && this.store.get().rootId !== edge.from) this.store.set({ rootId: edge.from }) + return edge + } + + /** Parent id for inserting at page level (the page root node). */ + async parentIdOf(id: string): Promise { + return (await this.edgeOf(id)).from + } + + // --- edits ----------------------------------------------------------------- + + /** Run an edit batch; shows errors, tracks busy state, resolves null on failure. */ + async apply(ops: Op[], opts: { coalesce?: string } = {}): Promise { + this.store.set((s) => ({ busy: s.busy + 1 })) + try { + const res = await this.client.apply(ops, opts) + this.store.set({ dirty: true }) + return res + } catch (e) { + this.fail(e) + return null + } finally { + this.store.set((s) => ({ busy: s.busy - 1 })) + } + } + + setData(id: string, data: Record, opts: { coalesce?: string; version?: number } = {}) { + const op: Op = { op: 'set', id, data, ...(opts.version !== undefined ? { version: opts.version } : {}) } + return this.apply([op], { coalesce: opts.coalesce }) + } + + async insert(parent: string, type: string, opts: { index?: number; handle?: string; data?: Record } = {}) { + const res = await this.apply([{ op: 'insert', parent, type, ...opts }]) + const created = res?.results[0]?.node + if (created) await this.selectCreated(created) + return res + } + + async insertBlueprint(parent: string, blueprint: string, index?: number) { + const res = await this.apply([{ op: 'blueprint', parent, blueprint, ...(index !== undefined ? { index } : {}) }]) + const created = res?.results[0]?.node + if (created) await this.selectCreated(created) + return res + } + + async remove(id: string) { + try { + const edge = await this.edgeOf(id) + this.select(null) + const res = await this.apply([{ op: 'delete', edge: edge.id }]) + if (res) this.toast('info', 'Block deleted', { label: 'Undo', run: () => void this.undo() }) + return res + } catch (e) { + this.fail(e) + return null + } + } + + async duplicate(id: string) { + try { + const edge = await this.edgeOf(id) + const res = await this.apply([{ op: 'duplicate', edge: edge.id }]) + const created = res?.results[0]?.node + if (created) await this.selectCreated(created) + return res + } catch (e) { + this.fail(e) + return null + } + } + + /** Move a block one position within its parent. */ + async nudge(id: string, dir: -1 | 1) { + try { + const edge = await this.edgeOf(id) + const sibs = await this.childEdges(edge.from) + const i = sibs.findIndex((e) => e.id === edge.id) + const j = i + dir + if (i < 0 || j < 0 || j >= sibs.length) return null + return await this.apply([{ op: 'move', edge: edge.id, index: j }]) + } catch (e) { + this.fail(e) + return null + } + } + + private async selectCreated(id: string) { + await this.whenRendered() + this.select(id, { scroll: true }) + } + + /** Index of the block among its parent's children, and the sibling count. */ + async position(id: string): Promise<{ index: number; count: number }> { + const edge = await this.edgeOf(id) + const sibs = await this.childEdges(edge.from) + return { index: sibs.findIndex((e) => e.id === edge.id), count: sibs.length } + } + + async childCount(parent: string): Promise { + return (await this.childEdges(parent)).length + } + + undo = () => this.guard(() => this.client.undo()) + redo = () => this.guard(() => this.client.redo()) + private async guard(fn: () => Promise) { + this.store.set((s) => ({ busy: s.busy + 1 })) + try { + return await fn() + } catch (e) { + this.fail(e) + return null + } finally { + this.store.set((s) => ({ busy: s.busy - 1 })) + } + } + + async publish() { + try { + const rootId = await this.pageRoot() + await this.api.publish([rootId]) + this.store.set({ dirty: false }) + this.toast('info', 'Published') + } catch (e) { + this.fail(e) + } + } + + /** The page root node id (learned from the first top-level block's parent edge). */ + async pageRoot(): Promise { + const id = this.store.get().rootId ?? (await this.discoverRoot()) + if (!id) throw new Error('This page has no editable blocks to learn the page node from') + return id + } + + private async discoverRoot(): Promise { + const top = this.store.get().blocks.find((b) => !b.parentId) + if (!top) return null + return (await this.edgeOf(top.id)).from + } + + // --- patches --------------------------------------------------------------- + + /** Resolves once the DOM reflects the last edit (immediately, unless a soft reload is running). */ + private rendered: Promise = Promise.resolve() + whenRendered(): Promise { + return this.rendered + } + + private onPatches(patches: Patch[], res: EditResult) { + for (const id of res.touched ?? []) { + this.nodes.delete(id) + if (this.snaps.has(id)) void this.node(id).catch(() => {}) // keep titles live (old title stays until the new one lands) + } + this.store.set((st) => ({ editRev: st.editRev + 1 })) + this.edgeCache.clear() // structure may have changed + const active = this.doc.activeElement + const report = applyPatches(this.doc.body, patches, { + skip: (el) => !!this.inlineSession && el instanceof HTMLElement && el.isContentEditable && (el.contains(active) || el === active), + }) + // Touched nodes the server could not re-render standalone (e.g. the page itself). + const needsReload = report.failed.length > 0 + this.scan() + void this.refreshBindings() + if (needsReload) this.rendered = this.reloadImpl().then(() => void this.scan()) + // Drop a selection whose block vanished. + const sel = this.store.get().selected + if (sel && !this.block(sel)) this.store.set({ selected: null }) + } + + /** + * The live render of this page. A browser navigation can't carry our bearer token, so the edge may + * have served the *published* file even with the preview cookie set; asking the API ourselves is + * what guarantees the latest content. Order: `/api/preview?path=` (documented), the same with the + * page's locale, then the page URL with the preview header (edge routing) as a last resort. + */ + private async fetchPreviewHtml(): Promise { + const path = location.pathname + const lang = this.doc.documentElement.lang + const attempts: Array<[string, Record]> = [[`/api/preview?path=${encodeURIComponent(path)}`, {}]] + if (lang) attempts.push([`/api/preview?path=${encodeURIComponent(path)}&locale=${encodeURIComponent(lang)}`, {}]) + attempts.push([location.pathname + location.search, { 'X-CMS-Preview': '1' }]) + let last = 'no response' + for (const [url, headers] of attempts) { + const resp = await this.api.doFetch(url, { cache: 'no-store', headers: { Accept: 'text/html', ...headers } }) + const type = resp.headers.get('content-type') ?? '' + if (resp.ok && (type.includes('html') || !type)) return resp.text() + last = `${url.split('?')[0]} ${resp.status}` + if (resp.status === 401 || resp.status === 403) break // trying other URLs will not help + } + throw new Error(last) + } + + /** Morph the page to the latest preview render (no reload, scroll and focus stay). */ + async softReload(): Promise { + try { + const fresh = new DOMParser().parseFromString(await this.fetchPreviewHtml(), 'text/html') + const out = morphChildren(this.doc.body, fresh.body, { + skip: (el) => this.host.contains(el) || (el instanceof HTMLElement && el.isContentEditable), + willRemove: notifyIslandsRemoving, + }) + out.added.forEach(notifyIslandsAdded) + this.scan() + } catch (e) { + this.toast('error', `Could not load the latest preview (${(e as Error).message})`) + } + } + + private lastPreview = 0 + /** Pull the latest content when the tab comes back (other editors may have changed things). */ + private onVisible = () => { + if (this.doc.visibilityState !== 'visible' || !this.signedIn() || this.inlineSession || this.store.get().busy) return + if (Date.now() - this.lastPreview < 5000) return + this.lastPreview = Date.now() + this.nodes.clear() + this.edgeCache.clear() + void this.softReload() + } + private signedIn = () => this.store.get().auth.status === 'signed-in' + + // --- visual style (x-style fields) ------------------------------------------------- + + /** Pixel measurement of a CSS custom property; replaceable (tests have no layout). */ + measure: Measure | null = null + private scales = new Map() + + styleFields(id: string): StyleField[] { + return describeStyle(this.block(id)?.schema) + } + + /** Token -> px for a spacing/gap field, measured from the page's CSS (cached per viewport width). */ + scaleFor(field: StyleField): Scale { + const key = `${field.name}:${field.token ?? ''}:${this.doc.defaultView?.innerWidth}` + let sc = this.scales.get(key) + if (!sc) { + sc = measureScale(field, this.measure ?? probeMeasure(this.doc), this.doc) + this.scales.set(key, sc) + } + return sc + } + + /** Set one style field (base, or a breakpoint override) as a single, coalesced undo step. */ + async setStyle(id: string, field: StyleField, value: unknown, opts: { bp?: Breakpoint } = {}) { + const node = await this.node(id) + return this.setData(id, patchFor(node.data ?? {}, field, value, opts.bp), { coalesce: `style:${id}:${field.name}:${opts.bp ?? ''}` }) + } + + private previewed = new Map() + /** Live preview while dragging: inline style on the block's elements, removed by clearPreview(). */ + previewStyle(id: string, property: string, px: number) { + for (const b of this.store.get().blocks.filter((x) => x.id === id)) { + b.el.style.setProperty(property, `${px}px`, 'important') + const list = this.previewed.get(b.el) ?? [] + if (!list.includes(property)) list.push(property) + this.previewed.set(b.el, list) + } + } + clearPreview() { + for (const [el, props] of this.previewed) for (const p of props) el.style.removeProperty(p) + this.previewed.clear() + } + + /** + * Give a block spacing/layout controls it does not have by wrapping it in the wrapper type + * (the schema tagged `x-style.wrapper`, i.e. `layout`). One atomic batch: insert the wrapper where + * the block is, then move the block into it - so undo restores the page in one step. + */ + async wrapInLayout(id: string) { + const type = this.wrapperType() + if (!type) return this.toast('error', 'No wrapper type (x-style.wrapper) is available for this site') + try { + const edge = await this.edgeOf(id) + const { index } = await this.position(id) + const wid = `w${Math.random().toString(36).slice(2, 10)}` + const res = await this.apply([ + // `id` is accepted by POST /api/edit (docs/onpage-editor.md) but missing from the shared InsertOp type + { op: 'insert', parent: edge.from, type, index, id: wid, data: { flow: 'stack' } } as Op, + { op: 'move', edge: edge.id, parent: wid, index: 0 }, + ]) + if (!res) return + await this.whenRendered() + this.select(wid, { scroll: false }) + void this.openEditor('style', wid) + } catch (e) { + this.fail(e) + } + } + + // --- drag & drop (existing blocks and palette items share one engine) --------------------- + + private dragCleanup: (() => void) | null = null + + /** Start dragging an existing block (from its grip). */ + beginDrag(id: string) { + const b = this.block(id) + this.select(id) + this.startDrag({ id, source: 'block', type: b?.type, label: this.labelFor(id) ?? b?.type ?? 'block' }, (target) => void this.dropBlock(id, target)) + } + + /** Start dragging a new block type (or blueprint) out of the palette. */ + beginPaletteDrag(item: { type?: string; blueprint?: string; label: string }) { + const type = item.type + this.startDrag({ id: null, source: 'palette', type, label: item.label }, (target) => void this.dropNew(target, item)) + } + + private startDrag(d: { id: string | null; source: 'block' | 'palette'; type?: string; label: string }, onDrop: (t: DropTarget) => void) { + this.cancelDrag() + const doc = this.doc + this.store.set({ drag: { ...d, target: null } }) + doc.documentElement.setAttribute('data-cms-dragging', '') + // Rects are measured per move from the live DOM; the ghost is an overlay (not a DOM node), so the + // page never reflows under the pointer and the drop target cannot jitter. + const compute = (x: number, y: number, nest: boolean) => { + const blocks = this.store.get().blocks.map((b) => { + const r = b.el.getBoundingClientRect() + return { id: b.id, parentId: b.parentId, container: b.container, depth: b.depth, rect: { x: r.x, y: r.y, w: r.width, h: r.height } } + }) + const accepts = d.type ? (parent: string) => this.accepts(parent, d.type!) : undefined + // Existing blocks sort among their siblings by default (snapping); Alt nests into the container under the pointer. + if (d.source === 'block' && d.id) return moveTarget(blocks, { x, y }, { id: d.id, parentId: this.block(d.id)?.parentId }, { nest, accepts }) + return dropTarget(blocks, { x, y }, d.id, accepts) + } + let last: { x: number; y: number; alt: boolean } | null = null + const update = () => last && this.store.set({ drag: { ...d, target: compute(last.x, last.y, last.alt), pointer: { x: last.x, y: last.y }, nesting: last.alt } }) + const move = (e: PointerEvent) => { + last = { x: e.clientX, y: e.clientY, alt: e.altKey } + update() + } + const up = (e: PointerEvent) => { + const target = compute(e.clientX, e.clientY, e.altKey) + this.cancelDrag() + if (target) onDrop(target) + } + const key = (e: KeyboardEvent) => { + if (e.key === 'Escape') return void (e.stopPropagation(), this.cancelDrag()) + if (e.key === 'Alt' && last) { + last.alt = e.type === 'keydown' // re-target the moment Alt is pressed/released, without waiting for a move + update() + } + } + doc.addEventListener('pointermove', move, true) + doc.addEventListener('pointerup', up, true) + doc.addEventListener('keydown', key, true) + doc.addEventListener('keyup', key, true) + this.dragCleanup = () => { + doc.removeEventListener('pointermove', move, true) + doc.removeEventListener('pointerup', up, true) + doc.removeEventListener('keydown', key, true) + doc.removeEventListener('keyup', key, true) + } + } + + cancelDrag() { + this.dragCleanup?.() + this.dragCleanup = null + this.doc.documentElement.removeAttribute('data-cms-dragging') + if (this.store.get().drag) this.store.set({ drag: null }) + } + + /** Commit a block move: one `move` op (re-parent and/or reorder). */ + async dropBlock(id: string, target: DropTarget) { + try { + const edge = await this.edgeOf(id) + const parent = target.parentId || (await this.pageRoot()) + const sibs = await this.childEdges(edge.from) + const cur = sibs.findIndex((e) => e.id === edge.id) + if (parent === edge.from && cur === target.index) return null // dropped where it was + return await this.apply([{ op: 'move', edge: edge.id, parent, index: target.index }]) + } catch (e) { + this.fail(e) + return null + } + } + + /** Commit a palette drop: insert a new node (or blueprint) at the drop position. */ + async dropNew(target: DropTarget, item: { type?: string; blueprint?: string }) { + try { + const parent = target.parentId || (await this.pageRoot()) + if (item.blueprint) return await this.insertBlueprint(parent, item.blueprint, target.index) + if (item.type) return await this.insert(parent, item.type, { index: target.index }) + } catch (e) { + this.fail(e) + } + return null + } + + // --- every property editable where it is rendered --------------------------------------- + + /** Remove the markers inference put on the page (the next selection re-infers for its own block). */ + private clearInferred() { + for (const el of this.doc.querySelectorAll(`[${INFERRED_ATTR}]`)) { + el.removeAttribute('data-cms-field') + el.removeAttribute('data-cms-editor') + el.removeAttribute(INFERRED_ATTR) + } + if (this.store.get().bindings.length) this.store.set({ bindings: [] }) + } + + /** + * Bind the selected block's string properties (nested arrays/objects included) to what the page renders: + * text leaves become inline fields, attribute matches become chips. Re-run after every patch, since a + * morph rewrites the attributes we added. + */ + async refreshBindings(): Promise { + const id = this.store.get().selected + const block = this.block(id) + if (!id || !block || !this.active) return this.clearInferred() + try { + const node = await this.node(id) + if (this.store.get().selected !== id || !block.el.isConnected) return + const schema = block.schema ?? (await this.schema(node.dataType).catch(() => undefined)) + const owns = (el: Element) => el.closest(BLOCK_SEL) === block.el + const found = inferBindings(block.el, owns, collectStrings(node.data ?? {}, schema)) + markInferred(block.el, found.text) + this.store.set({ bindings: found.attrs }) + } catch { + /* binding is a convenience; the Properties dialog always works */ + } + } + + /** Write one string at a (possibly nested) path of a node's data. */ + async setPath(id: string, key: string, value: string, opts: { coalesce?: string } = {}) { + const path = parsePath(key) + const data = path.length === 1 ? {} : (await this.node(id)).data ?? {} + return this.setData(id, patchForPath(data, path, value), { coalesce: opts.coalesce ?? `path:${id}:${key}` }) + } + + // --- inline editing -------------------------------------------------------- + + async startInline(id: string, fieldEl: HTMLElement) { + this.stopInline() + const field = fieldEl.getAttribute('data-cms-field')! + const kind = fieldEl.getAttribute('data-cms-editor')! + const def = inlineEditors.get(kind) + if (!def) return this.toast('error', `No inline editor "${kind}" registered`) + try { + const node = await this.node(id, true) + if (this.store.get().selected !== id) this.select(id) + this.inlineSession = { ...def.start({ el: fieldEl, node, field, ctrl: this }), id } + this.store.set({ inline: { id, field } }) + } catch (e) { + this.fail(e) + } + } + + stopInline() { + const s = this.inlineSession + if (!s) return + this.inlineSession = null + s.stop() + this.store.set({ inline: null }) + } + + // --- dialogs ----------------------------------------------------------------- + + private zCounter = 0 + /** + * Open a dialog. With `id`, a second request for the same thing focuses the dialog that is already open + * (raised, pulsed, first field focused) instead of opening a duplicate; the existing key is returned. + */ + openDialog(d: Omit & { size?: DialogInstance['size'] }): number { + const existing = d.id ? this.store.get().dialogs.find((x) => x.id === d.id) : undefined + if (existing) { + this.raiseDialog(existing.key, true) + return existing.key + } + const key = ++this.dialogKey + this.store.set((s) => ({ dialogs: [...s.dialogs, { size: 'md', ...d, key, z: ++this.zCounter, pulse: 0 }] })) + return key + } + /** Bring a dialog to the front; `pulse` also flashes it and moves focus into it. */ + raiseDialog(key: number, pulse = false) { + this.store.set((s) => ({ dialogs: s.dialogs.map((x) => (x.key === key ? { ...x, z: ++this.zCounter, pulse: pulse ? x.pulse + 1 : x.pulse } : x)) })) + } + /** Focus the open dialog with this id (raise, pulse, focus its first field). False when none is open. */ + focusDialog(id: string): boolean { + const d = this.store.get().dialogs.find((x) => x.id === id) + if (d) this.raiseDialog(d.key, true) + return !!d + } + closeDialog(key: number) { + this.store.set((s) => ({ dialogs: s.dialogs.filter((d) => d.key !== key) })) + } + + /** Open a registered block editor for a node (loads node + schema first). */ + private opening = new Set() + /** + * Open a registered block editor for a node (loads node + schema first). One dialog per editor+node: + * asking again - even double-clicking before the first has loaded - focuses the existing one. + */ + async openEditor(def: BlockEditorDef | string, id: string) { + const d = typeof def === 'string' ? blockEditors.get(def) : def + const info = this.block(id) + if (!d || !info) return + const dialogId = `editor:${d.id}:${id}` + if (this.focusDialog(dialogId)) return + if (this.opening.has(dialogId)) return + this.opening.add(dialogId) + try { + const [node, schema] = await Promise.all([this.node(id, true), this.node(id).then((n) => this.schema(n.dataType))]) + const r = info.el.getBoundingClientRect() + let key = 0 + key = this.openDialog({ + id: dialogId, + title: `${d.label} · ${this.labelFor(id) ?? node.dataType}`, + size: d.size, + anchor: { x: r.right, y: r.top }, + content: createElement(d.component, { info, node, schema, ctrl: this, close: () => this.closeDialog(key) }), + }) + } catch (e) { + this.fail(e) + } finally { + this.opening.delete(dialogId) + } + } + + // --- feedback ------------------------------------------------------------------ + + toast(kind: 'info' | 'error', text: string, action?: { label: string; run(): void }) { + this.store.set({ toast: { kind, text, n: ++this.toastN, action } }) + } + + private fail(e: unknown) { + const msg = + e instanceof EditError || e instanceof ApiError + ? e.status === 409 + ? 'Someone else changed this node - reopen it and retry' + : e.status === 401 || e.status === 403 + ? `Not allowed (${e.status}): ${e.message}` + : e.message + : e instanceof Error + ? e.message + : String(e) + this.toast('error', msg) + console.error('[cms-editor]', e) + } +} diff --git a/src/dev.ts b/src/dev.ts new file mode 100644 index 0000000..f691452 --- /dev/null +++ b/src/dev.ts @@ -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()) diff --git a/src/diff.ts b/src/diff.ts new file mode 100644 index 0000000..d81e1e8 --- /dev/null +++ b/src/diff.ts @@ -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, draft: Record): Record { + const out: Record = {} + 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 +} diff --git a/src/editors/AttrEditor.tsx b/src/editors/AttrEditor.tsx new file mode 100644 index 0000000..ebe969e --- /dev/null +++ b/src/editors/AttrEditor.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react' +import type { Controller } from '../controller' +import { Icon } from '../ui/Icon' + +const LABEL: Record = { 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(null) + useEffect(() => input.current?.select(), []) + return ( +
{ + e.preventDefault() + if (v !== initial) await ctrl.setPath(id, path, v) + close() + }} + > + +
+ + +
+
+ ) +} diff --git a/src/editors/builtin.tsx b/src/editors/builtin.tsx new file mode 100644 index 0000000..c459091 --- /dev/null +++ b/src/editors/builtin.tsx @@ -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>(node.data ?? {}) + const [saving, setSaving] = useState(false) + const patch = useMemo(() => diffData(node.data ?? {}, draft), [node, draft]) + const form = useRef(null) + useEffect(() => void form.current?.querySelector('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 ( +
{ + e.preventDefault() + if (changed && !problems.length) void save() + }} + > + +
+ + {problems.length > 0 && ( + `${p || 'form'}: ${m}`).join('\n')}> + {problems.length} problem{problems.length === 1 ? '' : 's'} to fix + + )} + +
+ + ) +} + +// --- Raw JSON: escape hatch for anything the form can't express ----------------- + +function RawJson({ node, ctrl, close }: EditorProps) { + const [draft, setDraft] = useState(node.data ?? {}) + const patch = diffData(node.data ?? {}, draft && typeof draft === 'object' ? draft : {}) + return ( +
+ +
+ +
+
+ ) +} + +// --- 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

Loading…

+ if ('error' in state) return

{state.error}

+ if (!state.types.length && !state.blueprints.length) return

This block does not accept children.

+ const byGroup = new Map() + for (const t of state.types) byGroup.set(ctrl.groupOfType(t), [...(byGroup.get(ctrl.groupOfType(t)) ?? []), t]) + const tile = (t: string) => ( + + ) + return ( +
+ {sortGroups(byGroup.keys()).map((g) => ( +
+

{g}

+
{byGroup.get(g)!.map(tile)}
+
+ ))} + {state.blueprints.length > 0 &&

Blueprints

} +
+ {state.blueprints.map((b) => ( + + ))} +
+
+ ) +} + +function AddChild({ info, ctrl, close }: EditorProps) { + return +} + +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 ? :

Loading…

+} + +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 | 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() } +} diff --git a/src/examples.tsx b/src/examples.tsx new file mode 100644 index 0000000..8858e44 --- /dev/null +++ b/src/examples.tsx @@ -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
{JSON.stringify(node, null, 2)}
+} + +// 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 }) diff --git a/src/form/ArrayField.tsx b/src/form/ArrayField.tsx new file mode 100644 index 0000000..7c47406 --- /dev/null +++ b/src/form/ArrayField.tsx @@ -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 + 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 + + // uniqueItems + enum items: a checkbox set (the natural UI for "pick several"). + if (Array.isArray(itemSchema.enum) && s.uniqueItems) { + return ( +
+ {itemSchema.enum.map((o: unknown) => ( + + ))} +
+ ) + } + + 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 +} + +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([]) + 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>({}) + const [dragFrom, setDragFrom] = useState(null) + const [dragOver, setDragOver] = useState(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 ( +
+
    + {items.map((item, i) => { + const k = keys.current[i] + const expanded = !compound || isOpen(k) + return ( +
  1. { + if (dragFrom === null) return + e.preventDefault() + setDragOver(i) + }} + onDrop={(e) => { + e.preventDefault() + if (dragFrom !== null) move(dragFrom, i) + setDragFrom(null) + setDragOver(null) + }} + > +
    + + {compound ? ( + + ) : ( +
    + commit(items.map((x, j) => (j === i ? (v === undefined ? defaultFor(root, itemSchema) : v) : x)))} path={`${path}.${i}`} required /> +
    + )} + + + + {compound && ( + + )} + + +
    + {compound && expanded && ( +
    + commit(items.map((x, j) => (j === i ? (v ?? {}) : x)))} path={`${path}.${i}`} required bare /> +
    + )} +
  2. + ) + })} +
+
+ + + {plural(items.length, 'item')} + {s.minItems ? ` · min ${s.minItems}` : ''} + {s.maxItems ? ` · max ${s.maxItems}` : ''} + +
+
+ ) +} diff --git a/src/form/Form.tsx b/src/form/Form.tsx new file mode 100644 index 0000000..32431bd --- /dev/null +++ b/src/form/Form.tsx @@ -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 + onChange(v: Record): 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 ( + + {hasAdvanced && ( + + )} + onChange(v ?? {})} path="" /> + + ) +} + +// --- containers ---------------------------------------------------------------- + +const isPlainObject = (v: unknown): v is Record => !!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; onChange(v: Record | undefined): void; path: string }) { + const { root, advanced } = useForm() + const required = new Set(s.required ?? []) + const entries = Object.entries(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 + 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]) => ( + + set(k, v)} path={at(k)} required={required.has(k)} /> + + ) + return ( + <> + {sections.map((sec) => + sec.group === null ? ( + row(sec.items[0]) + ) : meta[sec.group]?.advancedOnly && !advanced ? null : ( +
+ + {meta[sec.group]?.title ?? labelOf(sec.group, {})} ({sec.items.length}) + + {meta[sec.group]?.description &&

{meta[sec.group]!.description}

} + {sec.items.map(row)} +
+ ), + )} + {extra.length > 0 && ( +
+ {extra.length} field(s) not in the schema + {extra.map((k) => ( + + set(k, v)} path={at(k)} /> + + ))} +
+ )} + + ) +} + +// --- 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 ( + + + {s.title ?? labelOf(name, s)} + {required && *} + + {s.description && {s.description}} + {children} + {err && ( + + {err} + + )} + + ) +} + +// --- 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 + + if (s.readOnly || x === 'readonly') return + if (variantsOf(s)) return + if (x === 'json') return + + const t = Array.isArray(s.type) ? s.type.find((y: string) => y !== 'null') : s.type + if (s.enum) return + if (t === 'array') return + if (t === 'object' || (!t && (s.properties || s.additionalProperties))) { + if (x === 'dictionary' || s['x-dict-keys'] || !s.properties) return + const obj = isPlainObject(value) ? value : {} + const fields = onChange(v)} path={path} /> + return bare ?
{fields}
:
{fields}
+ } + if (t === 'boolean') return onChange(e.target.checked)} /> + if (t === 'integer' || t === 'number') return + if (t === 'string') return + // Untyped ("any"): decide by what is actually stored. + switch (typeof value) { + case 'string': + return + case 'number': + return + case 'boolean': + return onChange(e.target.checked)} /> + case 'undefined': + return + default: + return + } +} + +// --- 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