@pagina/editor
v0.3.0
Published
WYSIWYG editor for pagina article folders, built on TipTap, shipped as both a module and a self-contained IIFE bundle.
Maintainers
Readme
@pagina/editor
The in-browser WYSIWYG editor for a pagina article folder.
A three-pane editor — pages and files on the left, the document in the middle, the rendered page on the right — that reads and writes an article folder over an HTTP contract, applies every edit locally first, and persists in the background. It builds Kineglyph figures from a form, embeds GLB models, uploads images, and publishes the whole article (pages and figures, pre-rendered to SVG) through one endpoint.
It is backend-agnostic on purpose. The UI talks only to ArticleStore; the store talks only to an
ArticleBackend. pagina dev --edit is one server behind that contract and a Laravel package is
meant to be another; nothing under src/ui or src/store imports node:* or Vite.
npx pagina dev docs --edit # then open http://127.0.0.1:4321/__edit/What it does
- Editing — a TipTap document over pagina's markdown dialect: tabs, admonitions, snippet
includes, figures, model viewers, raw HTML blocks, tables, links, images, text colour and
highlight. Markdown in, markdown out;
parseMarkdown/serializeMarkdownare a round trip. - Optimistic persistence — an edit applies immediately, is queued per file (500 ms debounce),
retried with backoff, and sent with
If-Match. A409raises a conflict banner offering "reload theirs" or "overwrite with mine"; nothing is ever silently overwritten. - A figure builder — a form over Kineglyph's
SimpleSceneSpec, with the real runtime as its preview. It writesscenes/<id>.mjscarrying a// pagina:specmarker, which is how it knows a module is one it can re-open. Hand-authored scenes are offered a source editor instead. - Live figures in the document — a
figureKgnode evaluates its module and mounts it, so the figure in the editor pane is the figure, updating the moment the builder saves. <model-viewer>nodes, a colour picker reading the site's own--pg-*palette, drag/paste uploads, a/slash menu, and a sidebar that creates and deletes pages and theirnaventries (comments and key order inarticle.yamlsurvive).- Publish — renders every page through
@pagina/coreand every module/inline figure to light + dark SVG in the browser, thenPOSTs manifest + pages + figures in one payload.
The three distribution forms
1. React component
import { PaginaEditor, ArticleStore, HttpBackend } from "@pagina/editor";
import "@pagina/editor/editor.css";
const store = new ArticleStore(new HttpBackend({ baseUrl: "/__pagina/edit" }));
<PaginaEditor store={store} page="guide/tabs.md" theme="dark" />;Props: store (required), page (defaults to index.md), theme, modelViewerUrl, and
onReady(open) — which hands back a function for opening another page.
2. mountEditor(el, options)
import { mountEditor } from "@pagina/editor";
const editor = mountEditor(document.getElementById("editor"), {
backendUrl: "/__pagina/edit",
page: "index.md",
});
await editor.publish();
editor.open("guide/tabs.md");
editor.destroy();EditorOptions: backend (an already-built ArticleBackend, which wins over backendUrl),
backendUrl, headers (sent on every request — CSRF, Authorization, …), page, base,
theme, modelViewerUrl. The returned handle exposes store, open, publish, destroy.
3. <pagina-editor> custom element
<script type="importmap">{"imports":{"kineglyph":"/assets/kineglyph.js"}}</script>
<link rel="stylesheet" href="/assets/editor.css">
<script type="module">
import { defineElement } from "/assets/editor.js";
defineElement(); // registers <pagina-editor>
</script>
<pagina-editor backend-url="/__pagina/edit" page="index.md" base="/"></pagina-editor>Attributes: backend-url, page, base, theme (light/dark), model-viewer-url, and
headers (a JSON object). The element exposes .store, .open(path) and .publish(), so a
Blade or Livewire page can drive it without importing anything. This is the form
pagina dev --edit uses, and the one the Laravel package is designed around.
For a <script> tag rather than a module, use dist/editor.iife.js, which defines the global
Pagina.
Builds and kineglyph
dist/editor.js (ESM) and dist/editor.iife.js bundle React, and share one dist/editor.css.
dist/editor.css is the only stylesheet the editor needs, under any host. It inlines
@pagina/shell-static's token and reading layers, so the preview pane and the ProseMirror
document — both .pg-content markup — look like the published page even on a host whose CSS
reset has flattened every heading. It also declares pagina's complete cascade-layer order, so a
host that links pagina.css as well may link the two in either order. See
docs/theming.md.
kineglyph is deliberately left external: figures in the preview must hydrate on the same
runtime instance the site's own pages use, so the host page's import map (or bundler alias)
decides what it resolves to. A host with neither will see figure nodes report
"Failed to resolve module specifier" — this is a configuration requirement, not a bug.
The backend contract
Full specification: docs/design/2026-08-17-editor-connectivity-laravel.md.
GET {base}/files → { files: [{ path, size, version, mtime }] }
GET {base}/files/{path} → text or binary; ETag = version
PUT {base}/files/{path} (If-Match: v) → { version } 409 → { theirs, version }
(If-Match: *) → must already exist; 412 → { message } when absent
DELETE {base}/files/{path} → 204
POST {base}/upload (multipart file,path?) → { path, url, version }
POST {base}/rename { from, to } → { version }
POST {base}/publish { manifest, pages, figures } → { publishedAt }
GET {base}/events (SSE) → { type, path, version } framesOver HTTP, version is the sha1 of the file's bytes, so two servers handing out the same version
for the same content is a feature (a no-op write is not a conflict) and mtime jitter is not. The
contract only requires that a version changes when the content does — LocalStorageBackend counts
instead, deliberately. A version
mismatch is a 409 carrying { theirs, version } — the pair the conflict banner is built from;
If-Match: * means "must already exist" and is a 412 when it does not, because there is no
theirs to hand back. A PUT with no If-Match creates or replaces unconditionally. Implement
ArticleBackend (src/store/types.ts) to talk to something else entirely.
Three implementations ship, and one parametrised suite (test/backend-contract.ts, run from
test/backends.test.ts) checks all three against the same behaviour — HttpBackend over an
in-process implementation of the endpoints above rather than response stubs:
| | Use it when | Persistence | Cross-tab |
| --- | --- | --- | --- |
| MemoryBackend | tests, a demo that starts clean each time | none | emit() |
| LocalStorageBackend | offline, a static site, a browser-only demo | localStorage, survives reload | the DOM storage event |
| HttpBackend | anything with a server | the server's | SSE at {base}/events |
const backend = new LocalStorageBackend({
namespace: "my-article", // keys scoped; two articles cannot collide
seed: { "article.yaml": "…", "index.md": "…" },// written only where missing
});
await backend.reset(); // back to the seed
backend.usage(); // { files, bytes }Its versions are a monotonic counter, not a content hash: an identical write from a second tab
still conflicts, and a version is never reused, so a stale one cannot match a deleted-and-recreated
file. Uploads are base64 in the same store, capped at 512 KB (maxBinaryBytes) — IndexedDB
would hold more but has no cross-tab event, which would split text and media across two notions of
"current". A full store raises StorageQuotaError (507) with an actionable message and nothing
half-written; an oversized upload is a 413. publish() is accepted but only its timestamp is
persisted, because a rendered article is exactly what would fill the 5 MB.
Trust model
The article folder is trusted content, and so is everyone who can reach the editing endpoint.
- Markdown passes through with
html: true, and scene modules execute — at publish time in the browser and at build time in Node. The editor is not a sandbox for user-submitted documents. pagina dev --editmakes the folder writable over HTTP with no authentication. It is off by default and inherits the dev server's loopback-only bind for exactly that reason. A hosted backend must do its own authn/authz before it reaches the contract.- The dev middleware refuses to escape the folder (paths are checked lexically and by realpath),
refuses any dot-prefixed segment for writes (
.pagina/,.git/,.env;publishis the only writer allowed into.pagina/), and caps request bodies. - Accepted, not fixed (task B3 re-review): containment is a check-then-use. A symlink inside
the folder is resolved and rejected at check time, but a symlink swapped between the check and
the write is not defended against. Closing that needs
O_NOFOLLOW-style handle-based I/O; since the folder is trusted and the attacker would already need write access to it, the race buys them nothing they do not already have.
What is not done yet
- The figure builder covers
SimpleSceneSpeconly — no images, icons, badges, grids or machines. Scenes using them stay hand-authored, and the builder refuses to open them rather than flattening what it cannot express. - No Laravel backend. The contract is specified and the dev server implements it; the PHP side is a separate deliverable.
- No authentication, no multi-user awareness beyond conflict detection. SSE tells you a file changed; it does not tell you who is editing it, and there is no presence or locking.
- No undo across files, no page rename from the sidebar (only create and delete), no search.
- Publish renders figures on the host's runtime, so a host page without an import map for
kineglyphpublishes pages whose figures hydrate client-side instead of carrying SVG. - A dev host page must not reload the editor over the editor's own writes.
@pagina/editorannounces every successful backend mutation throughwindow.__paginaSelfWrite(path, ts)if the host installed it (noteSelfWrite, exported from./store);pagina dev --editinstalls a matching guard that drops an HMRfull-reloadlanding within 2 s of one. A host with its own live-reload has to do the same, or an upload will be discarded before the 400 ms serialize debounce has written it. The window is time-based, so a genuine external change to the same file inside those 2 s is missed by the editor's tab (and by no one else's). - The Playwright lane is a smoke test, not coverage. Two specs — type into a page and see the
file on disk change, and see the figures hydrate — over a real
pagina dev --edit. Everything else is unit and integration tests plus verification in Chrome by hand.
Development
npm test # from the repo root: vitest, all packages
npm run typecheck
npm run build # tsc → dist/, then vite → dist/editor.{js,iife.js,css}
npm run test:e2e # Playwright against a real `pagina dev --edit` (needs the build)Entry points: . (everything), ./model (schema + markdown parse/serialize, no UI),
./store (the optimistic store + backends, no UI), ./bundle (dist/editor.js),
./editor.css (built, self-contained — link this one), and ./theme.css (the source sheet,
which @imports @pagina/shell-static; only useful through a bundler that resolves them).
License
MIT — see LICENSE.
