Repository navigation
feat(plugins): author, build and activate site plugins in a full-screen IDE - #495
Draft
DavidBabinec wants to merge 36 commits into
Draft
DavidBabinec wants to merge 36 commits into
DavidBabinec wants to merge 36 commits into
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
DavidBabinec
force-pushed
the
feat/site-branches
branch
from
September 11, 2026 18:26
dffe67b to
d123109
Compare
…source column, plugin file type - Reserve the site.* plugin-id namespace on the zip-install boundary (readPluginPackage) so uploaded packages can't hijack a site plugin's identity, grants, settings, or secrets. The manifest parser keeps accepting site.* ids — generated site-plugin packages parse through it. - Migration 022 (both dialects): installed_plugins.source text not null default 'installed' — provenance for display + lifecycle routing only. - Plumb source through the plugins repository and InstalledPlugin type. - Add SiteFileType 'plugin' with an isolation gate pinning that plugin source never enters runtime scripts, user stylesheets, explorer sections, or any published-output pipeline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed import containment - New @core/plugin-build: buildPluginPackage (manifest write + per-surface bundling), bundleEntrypoint (one Bun.build choke point: externals layering, sandbox facades, post-bundle scans), facade generation, and a containment resolver plugin. - The CLI build (instatic-plugin build|dev) becomes a thin frontend: config evaluation, dist reset, pack/icon copy, and zipping stay CLI-side; bundling behavior is byte-identical (verified by init+build smoke runs for module and server kinds). - containmentPlugin fails resolution closed to the workspace root: upward-relative escapes, absolute paths, and import-attribute payloads throw; bare specifiers must be explicitly mapped (SDK) or external (import-map-resolved). Symlinked roots (macOS /var → /private/var) are realpath-normalized so containment can't be silently disabled. - Barrel gate covers @core/plugin-build; sandbox-invariant gate re-pinned to the shared core. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fecycle endpoints, publish coupling - @core/site-plugins: folder-convention discovery over draft SiteFile[], author-manifest validation (derived fields rejected via additionalProperties:false against the runtime manifest's own field schemas), runtime-manifest derivation through the REAL parser, order-independent content hash, generated 1.0.<n>+<hash> versions, scaffold templates, and the runtime-state machine shared by server + UI. - server/plugins/sitePlugins: temp-workspace materializer (path-safe, realpath'd), build orchestrator (single-flight per plugin, validate-only mode returning the modules bundle for canvas preview, 30s timeout, fail-closed containment with '@instatic/plugin-sdk' mapped to the host SDK), and revision retention (active + previous kept for rollback). - Extracted activatePluginPackageFromDisk from the zip install/upgrade path — site plugins ride the exact same lifecycle (install→activate, deactivate→migrate→activate with rollback), zero runtime branches. - /admin/api/cms/site-plugins routes: list (union of draft folders and site-local rows with computed states), scaffold (site.structure.edit), validate + preview-pack (no elevated capability), activate/rollback (plugins.install; step-up only on first activation and grant changes), delete (row + assets + draft source; step-up). - Publish coupling: visitor-facing activations republish before revision GC so baked pages never reference a deleted revision. - sitePluginRoute SDK helper for stable frontend→backend calls. - manifest.ts split into schemas + parser modules (size ceiling); the siteDocument file-type validator now derives from SITE_FILE_TYPES (killed a parallel hardcoded list that silently dropped 'plugin' files). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collab core — granular shell files: - The shell's files key becomes a per-file Y.Map (entry per file id, content as Y.Text): different files never collide, one file's content merges character-level, and deleting a file can't clobber a neighbour's concurrent edit. Patch translation handles membership via pre/post id-set diffs, skips pure index shifts by object identity, and splices content minimally; projection returns the ordered array (legacy LWW array layouts still project and upgrade on first write). The IDE (/admin/plugins/develop/:localId): - Renders in the shared workspace canvas shell: resizable file tree (Tree primitives + context menu; inline new/rename), co-edited CodeMirror buffer via a new CollabCodeMirrorEditor lazy sibling (y-codemirror.next: live splices, per-peer colored carets/selections, local-only per-file undo), diagnostics strip fed by debounced automatic validation, and the structured manifest panel (permissions checklist with the editor.code danger note, network hosts, derived fields read-only) writing plugin.json as minimal Y.Text splices. - Its own collab session binds ONLY site:default over the site socket — server-seeded, no editor store, presence published in the shared awareness (site editors see IDE peers in their roster; IDE rows show who's editing which file). - Toolbar: state chip + ONE smart primary action + Preview in canvas for module drafts + overflow (rollback/deactivate/restart/diagnostics/ settings/delete); disabled-with-reason, never hidden. Plugins page: Site plugins group (state chips, Open IDE) + New site plugin dialog (live kebab-case id, template picker). Scripts section relabeled 'Frontend scripts' with placement guidance; 'plugin' files stay out of the site editor entirely. Gates: workspace/circular/codemirror/bundle-size/token gates updated in the same change per repo rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tees, invariant gates, docs
- Preview in canvas: the site editor's ?previewSitePlugin=<localId> loads
the draft's validate-only module bundle from the no-store preview-pack
route, activates it through the SAME browser-side pack loader installed
plugins use, and badges the modules 'Draft' in the inserter. Nothing
registers server-side; leaving the page restores the active revision's
registrations. Publishing a page that uses a site.* module with no
active registration logs a publish warning naming the plugin.
- Export/import pinned: bundles carry plugin SOURCE ('plugin' shell files),
never generated artifacts or runtime rows; imported drafts validate
cleanly and require rebuild+activate on the target.
- site-plugin-invariants gate: zero 'site-local' branches outside the
repository/engine/UI allowlist; worker/VM/host machinery never mentions
site plugins; the zip boundary rejection, the containment policy call
shape, and the plugins.install + conditional step-up authority are
source-pinned.
- docs/features/site-plugins.md + cross-links from plugin-system.md,
site-shell.md, and the CLAUDE.md stack bullet.
- Explorer tests updated for the Frontend scripts relabel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fact rebake on activation, consent dialog
Findings from the end-to-end browser smoke (scaffold → co-edit → validate
→ review → step-up → activate → preview → publish), all fixed:
- Mapping '@instatic/plugin-sdk' to the FULL SDK barrel inlined TypeBox
(~390 kB) and tripped a bundler tree-shake edge ('Object3 is not
defined') that broke module packs in BOTH the browser preview and the
QuickJS publisher. New src/core/plugin-sdk/inlineRuntime.ts exposes
exactly the pure-data surface (defineModule, control, html/raw/safeUrl,
sitePluginRoute) + type-only re-exports; bundles drop to ~4 kB and run
everywhere.
- republishAllPages re-rendered pages but never re-baked Layer-A static
artefacts, so activation left published HTML referencing the OLD
revision (the exact staleness the publish coupling exists to prevent).
It now takes an optional uploadsDir and updates each page's artefact
in place; the site plugin activation passes it.
- IDE lifecycle actions now fire notifyCmsPluginsChanged so open editor
sessions reload the new revision's module pack without a page reload.
- Grant-changing activations show a permission-review dialog (new grants
with labels, revocations, editor.code danger note) BEFORE the build —
the consent moment the design requires, on top of server-side step-up.
- ideCollab upgrades a legacy LWW files array from its EXISTING entries
(an empty replacement would have projected a shell that lost every
other site file) and reads metas across both layouts.
- Build diagnostics rewrite throwaway-workspace paths to the draft's
plugins/<id>/ paths — no server temp-dir leakage, the author's file
is named.
Verified live: multi-author co-editing with peer carets across two
sessions, character merges, containment rejection of a used host-file
escape import, activation → auto-republish serving the new render, and
the scaffolded routes plugin answering through the QuickJS runtime.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…me, canvas surface, explorer-identical tree Layout/design review feedback: - Side panels no longer paint their own gray backgrounds — FileTreePane and ManifestPane now render inside the shared Panel shell (transparent over the sidebar's --bg-body black, standard PanelHeader with title + actions + close), exactly like the DOM panel and Content explorer. - The editor column gets the standard elevated canvas treatment (the Data grid pattern): --bg-surface-2 with 16px rounded top corners over the black shell; the diagnostics strip is transparent chrome on that surface. All invented backgrounds removed. - Tree rows recomposed to the Site Explorer's exact pattern: TreeRow as the shell with a ghost-Button hit surface (treeRowButton reset), TreeChevron + TreeIconSlot(icon=) + TreeLabelGroup, F2/double-click inline rename using the explorer's select-all/commit-on-blur behavior. - The Files panel's ✕ collapses to the rail (state lifted to the page); the rail button and the manifest edge-notch reopen their panels — same affordances as Content/Data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e's ws proxy is broken under Bun
Testing on the standard `bun run dev` stack surfaced that the collab
WebSocket never connects through the Vite proxy: Vite's bundled
http-proxy calls socket.destroySoon on the upgrade path, which Bun's
net.Socket does not implement — upgrades die before establishing (and
the failure can crash Vite outright). Every collab surface (site editor
co-editing AND the Plugin IDE) was stuck at 'Connecting to the live
draft…' in dev.
- vite.config.ts injects __INSTATIC_CMS_DEV_ORIGIN__ (the CMS origin the
proxy already targets); in dev the collab client connects the socket
DIRECTLY to it, skipping the proxy hop. Cookies are host-scoped
(port-agnostic on localhost) and the CMS Origin allowlist admits the
Vite dev origins, so the direct hop stays fully authenticated.
Production serves admin + API from one origin and keeps the
same-origin path (import.meta.env.DEV gates the branch).
- ManifestPane no longer claims 'plugin.json is missing' while the doc
is still syncing — it shows the connecting state until first sync.
- bundle-size gate: the per-icon-chunk check now verifies chunk CONTENT
(svg body without a grouped-chunk import) — rolldown legitimately
names shared feature chunks after member modules ('upload' helpers)
that collide with icon names.
Verified on a fresh `bun run dev` (vite-on-Bun, :5173/:3001): account
setup → New site plugin → IDE fully loads (tree, buffer, diagnostics,
manifest), site editor reaches 'Draft synced', zero WebSocket errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The right-side ManifestPane duplicated what the raw plugin.json buffer already does for the IDE's developer audience: the diagnostics strip names every manifest mistake, and the activation review dialog is where permissions get human-readable treatment. Removing it simplifies the IDE to file tree + co-edited buffer + diagnostics. - delete ManifestPane (+ CSS module) and the contentRightPanel wiring - remove the now-dead replaceFileContent from IdeCollabSession - drop the pluginIde 'manifest' notch label from the workspace layout - docs: describe raw plugin.json as the only manifest surface Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plugin source authoring previously borrowed site.structure.edit. Writing plugin code is a different trust level than editing site structure, and a plugin-developer persona needs no site rights — so it gets its own capability, granted to Owner/Admin via the boot-time role sync (no migration). - new 'plugins' write-policy category: every change to a type:'plugin' file requires plugins.edit on both transports (collab relay + HTTP save); full site-writers without it no longer skip the guard - socket write gate + full-writer fast path include plugins.edit - scaffold endpoint and IDE/Plugins-page gates move to plugins.edit - test harness now runs syncSystemRoles like server boot, so new capabilities reach the seeded Owner/Admin in tests - capability picker metadata + docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fills the reserved 'plugin' ToolScope seat following the content-scope blueprint: - shared TypeBox input schemas in @core/ai (browser + server validate the same source of truth) - browser-bridged file tools (list/read/write/patch/rename/delete/open) mirroring the site code-asset shapes: paginated reads + SHA-256 hash, patch with expectedHash optimistic concurrency - server-resolved lifecycle tools: plugin_list_plugins, plugin_validate (the diagnostics-strip build), plugin_activate (same-grant rebuilds only — grant changes remain a human consent moment in the IDE header) - activation engine extracted from the HTTP route (runSitePluginActivation) so both transports share one orchestration; audit path accepts req=null for non-HTTP callers - ToolContext gains uploadsDir (threaded router → chat handler → tools; MCP server passes its runtime value) - plugin-scope system prompt teaching folder conventions, manifest rules, containment, and the validate-until-clean → activate workflow Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ools
The IDE's left rail gains an AI assistant button (ai.chat-gated, the
Content sidebar pattern): the shared AgentPanel docked on the 'plugin'
chat scope, backed by a per-mount agent store, a module-level bridge
handle, and a dispatcher that re-validates every tool input against the
shared @core/ai schemas before touching the CRDT session.
- agent file edits are Y transactions (minimal splices) — they merge
character-level with concurrent human typing and show up live for
co-editors; replaceFileContent returns to IdeCollabSession with the
agent as its consumer
- MCP parity: EditorBridgeScope/McpWorkspaceScope grow 'plugin'; the IDE
registers its tool bridge for the whole page mount, so external MCP
connectors reach the open IDE even with the panel closed; plugin tools
join the MCP registry (lifecycle tools headless)
- sitePlugins handler split into sitePlugins/{index,service}.ts — HTTP
routing vs transport-independent service layer (the module crossed the
700-line god-file ceiling); gate tests updated for the new layout
- cmsMigrations role-seed gate reworked: the migration seed is an initial
snapshot (boot sync owns propagation), so the gate now checks seeded
capabilities are real instead of demanding completeness
- docs: site-plugins.md AI panel section, mcp-connectors.md plugin scope
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- selection-time capability filtering (reads vs plugins.edit writes vs plugins.install activate; open_file as pure editor-state switch) - lifecycle handlers against the capability harness: validate via explicit localId AND via snapshot; first activation refused as a consent moment; same-grant rebuild + skip; list states - browser dispatcher against a Y.Text-backed fake session: relative paths + hashes, hash-guarded patches, replaceAll ambiguity, folder escape rejection, plugin.json protection, canEdit refusal, closed-IDE error - plugin_list_plugins wraps its payload in aiToolOk for envelope consistency Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ws, schema teaching, code/diff blocks Four findings from a real agent session in the IDE: - BRIDGE STALENESS (the 'Create failed for server/index.ts' class): the tool bridge read file metas from React state, which lags the Y doc when tool calls arrive back-to-back without a render turn — a create could not see its own file. Metas now come straight from the live session (session.pluginFiles()), for tools and the snapshot both. - TOOL ROWS: plugin_* names fell through to the raw 'Running plugin read file' default. toolCallDisplay now maps every plugin tool to a human title + detail (file path, edit count, rename arrow, localId). - SCHEMA GUESSING: the model invented adminPages content shapes. The system prompt now spells out the content union — including the kind:'resource' CRUD page, which is the right answer for 'manage customers' requests — and says node_modules isn't on disk. The 'Expected union value' diagnostic gains the same shape hint for humans. - CODE VISIBILITY: write/patch tool rows (plugin AND site code-asset tools) get a 'View code / changes' toggle rendering a lazy read-only CodeMirror block — unified diff via @codemirror/merge for patches. New AgentCodeView joins the codemirror-lazy-only allowlist; theme + language stacks shared with CodeMirrorEditor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plugins page simplification (user feedback): - site plugins and installed plugins are ONE list — an activated site plugin is just an installed row with an 'Open IDE' action next to Disable; draft-only site plugins (no runtime row yet) join the same list as DraftSitePluginCard reusing the PluginCard chrome - 'New site plugin' moves into the page header next to Upload Plugin; SitePluginsSection is gone (fetching extracted to useSitePlugins) Agent code blocks: - always visible — no View/Hide toggle; plain-text <pre> fallback while the CodeMirror chunk loads so rows don't jump - font drops to the fluid --text-xs token (CSS module policy compliant) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…acks The theme/highlight/language exports added to CodeMirrorEditor.tsx broke react-refresh (component files must export only components). They move to a non-component module consumed by all three viewers; the lazy-only gate lists it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… agent Live testing showed the model guessing contracts (an .html app-page entry that validated clean and only failed at runtime) and learning by error. Two fixes: - plugin_docs tool: curated author-facing topics (manifest, admin-pages, server, modules, editor, frontend, workflow, examples) hand-written from the real SDK types and host loaders — the system prompt now directs the agent to read the matching topic before writing code in an unfamiliar area. Exposed over MCP too (headless). - derive-time app-entry validation: adminPages kind:'app' entries get the same source→bundle rewrite as frontend assets, non-JS entries and dangling paths now fail the BUILD with an instructive message instead of exploding in the admin shell at runtime. Also tightens the prompt's SDK summary to the real ServerPluginModule / defineModule / activate(api) contracts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
String.prototype.replace with a string replacement interprets $$, $&, $` and $'
in the replacement text. An agent patching a template literal to $${props.price}
silently got ${props.price} written back, and the returned hash was of the
corrupted content. Both patch tools now go through one slice-and-join engine
(applyExactReplacements in @core/ai) that never interprets the replacement.
…ource
Bun evaluates `import x from './m.ts' with { type: 'macro' }` inside the host
process at bundle time with full Node/Bun access. Path containment cannot catch
it (the macro module resolves inside the workspace) and the sandbox scan runs
on the output after the macro already executed, so it was reachable through
the site.read-gated validate and preview-pack routes. Bun's own Transpiler.scan
runs macros too, so the guard is a textual onLoad check that fails closed:
only json/text/file/toml attribute clauses pass; a macro, a comment inside the
clause, an escaped key or a trailing field fails the build. The gate test
covers the real build path (the marker file must not appear) and the clause
spellings.
…ws before sweeping Only the build was single-flight; two overlapping activations both read the same row version, derived the same next counter and raced the upgrade path. Activation, rollback and delete now serialize per local id through one keyed promise-chain helper shared with the build. The post-activation republish only re-baked pages, so entry-template data rows (/posts/hello) kept HTML pointing at the revision the sweep then deleted. The data-row bake takes a write target, the full publish and the in-place republish share it, and the republish runs under the publish lock at N+1 before bumping, the same ordering the full publish uses, so hole shells are never stamped stale.
basicSetup put history() and its Mod-z binding ahead of the Yjs undo keymap, so remote peers' deltas entered local history and Cmd+Z reverted a peer's typing (and broadcast the revert). The base stack is now spelled out from the @codemirror packages with a localHistory switch; the collab buffer mounts without history() and gives the Y.UndoManager keymap high precedence. The codemirror meta-package is no longer imported and is dropped.
… lifecycle actions A file created before the server's initial sync built an empty files map client-side; the server seeds with client id 1, so that map won the merge and the persisted shell lost every other site file. Every mutating session method now throws IdeNotSyncedError until synced, the file tree keeps New file, rename and delete disabled with the reason, and the agent bridge answers "still connecting". A relay reset (scaffold, delete, settings save, import) destroyed the bound doc while the IDE kept typing into it. The session now rebinds, bumps a generation the buffer remounts on, drops the undo managers that referenced the dead text, and the editor reads the live Y.Text through a sync subscription so the compiler cannot hand back the destroyed instance. The auto-select effect ignores the empty list during the reset window instead of jumping to the manifest. Deactivate, Restart and Activate are gated on plugins.lifecycle with an inline reason, matching the server. The bridge's patch tool rides the shared verbatim replacement engine and re-reads the live text after the digest.
A syntax error in a module file came back as "Failed to bundle plugins/x/__modules-facade.ts" with two bare bundler messages and no line number. Bun reports a position for every log; it was dropped. Diagnostics now read plugins/x/modules/foo.ts:22:16: <message> (1-based column), and the modules pack's generated facade is labelled as the modules pack instead of leaking its synthetic file name.
… the overflow The diagnostics strip shows the active revision's runtime error while the plugin is parked, the one problem validation cannot see. "View logs" and "Open plugin settings" both navigated to the Plugins list; both now say "Open on Plugins page". The duplicate "Preview in canvas" overflow item is gone. The overflow trigger takes focus after opening so Escape closes the menu (the menu itself is not focusable). The permission review says a password prompt follows. Presence updates the active-file field instead of replacing the whole awareness state, which showed peers a leave and a rejoin on every file switch. The IDE route now maps to its workspace so the persisted sidebar width applies on a direct load.
…epublish failures, one validate path A same-grant rebuild (the everyday agent loop over MCP included) silently re-enabled a plugin an operator had deactivated; the engine now refuses with a pointer to the Activate action. A failed post-activation republish was logged and forgotten while the response said success; it now comes back as a warning on the activate and rollback responses and the AI tool result. The validate route, the preview-pack route and plugin_validate ran three copies of the same discover-and-build; they share buildDraftSitePlugin in the service layer. The agent reference's style asset example used src where the manifest requires href.
… snapshot schema, hook cleanup The two code-asset bridges carried line-for-line copies of hashing and pagination; both now use hashText and paginateText from @core/ai. The content and plugin agent stores were the same factory with a different config; createScopedAgentStore replaces both files. The IDE snapshot schema lives once in @core/ai and both the browser handle and the server tools derive their type from it. The view-model hook drops six useCallbacks no dependency array reads, folds five lifecycle actions into one helper that also toasts republish warnings, loads the summary through useAsyncResource, and lets the session cache the file list so keystrokes never re-render the tree. Dead exports removed: the seven Plugin*Input types, the bridge's parse alias, the orphaned dialog error class. The scaffold dialog toasts request failures and stops emitting aria-pressed on radio buttons. The base editor installs no change listener when nothing owns persistence. The draft preview badge subscribes to its store. Docs: the site-plugins design doc no longer links two plan files that do not exist, site-shell's document model mentions the granular files map, agent.md lists the plugin scope, and the store references point at createScopedAgentStore.
…e like the site toolbar The IDE never said which plugin was open; the left panel is now titled with the plugin's name. The runtime state chip was its own pill; it now renders through ToolbarStatus, the dot-and-label indicator extracted from the site toolbar's publish group, so "Draft changed" in the IDE looks like "Draft synced" in the site editor. Tones: active is success, permission review is warning, build and runtime failures are danger, everything else neutral.
…he name, no panel footer The toolbar carried three separate controls (Preview in canvas, the smart primary action, an overflow menu). They are now one SplitButton, the same control the site toolbar's Publish uses: the state-appropriate action on the left, everything else grouped in the menu with inline reasons when unavailable. For an active plugin the left half stays "Build & activate" and says the draft already matches the active revision. SplitButton menu rows gain tooltip, danger and separatorBefore, mapped onto the ContextMenu features they already had; and the chevron now takes focus after opening and closes on Escape, which also fixes the site Publish menu after a mouse click. The files panel shows the active version muted beside the plugin name (the semver part; the content hash stays in the title attribute) and drops the "Files live in plugins/…" footer.
Retention kept the active build plus the one below it, so "Rollback to previous revision" had exactly one possible target and rolling back swept the build you had just left. The sweep now keeps the five highest builds plus whatever is active (a deep rollback keeps its target), and every retained build is a rollback target: the summary lists them newest first with the build time, the rollback route takes a version and validates it against the retained directories before touching a path, a target with a different grant set still steps up, and rolling back no longer discards the newer build, so rolling forward is the same action. The IDE menu's single rollback row becomes a "Roll back to…" submenu (v1.0.3 · 48m · active, v1.0.2 · 16h, …); SplitButton rows gain nested children for that, rendered through the shared ContextMenuSubmenu.
…n click A changed grant set was its own runtime state, "Permission review needed", with its own primary action. Grants live in plugin.json, so every grant change is a draft change, and the review dialog already opens from the Build & activate click when the declared and granted sets differ. The state is gone: the chip reads Draft changed, the click reviews then builds, and the summary keeps newPermissions / removedPermissions for the dialog. The state machine test, the lifecycle rollback test, and the docs follow.
- TreeGroup joins the shared tree primitives: a row plus its children, painted as one surface while the parent is selected and expanded. The Layers panel uses it instead of its own group styling. - The IDE file tree renders folders recursively, folders first, with the chevron at the end of the row so files and folders share a left edge, and keeps the same right inset as the Layers tree so row paint stays rounded. - New file from a file's context menu lands beside it.
…base The branch was based on a pre-branches, pre-language-service main. Landing it on feat/site-branches needed: - site-plugin handlers pass MAIN_SCOPE to the now branch-scoped site repositories; plugin sources live on main until branch instances land. - the IDE binds MAIN_SITE_DOC_ID, the branch-aware name for the site doc. - the MCP editor bridge gains the plugin scope, with its own precondition. - the shared CodeMirror module carries main's TypeScript hover and lint theme rules and its jsx/javascript language cases, and the buffer's update listener keeps main's diagnostics alongside the IDE's optional onChange. - the collab relay keeps main's write-capability gate, widened with plugins.edit; the superseded dev-socket helper and its define are gone. - migration 022 becomes 028, after the branches migrations.
- site-plugin and collab tests pass MAIN_SCOPE / the branch id to the repositories and the patch translator, which now take them. - The raw-SQL branch rule required a table name followed by SQL, so an English log line reading "from site plugin" tripped it; the pattern now needs a clause keyword or terminator and is checked both ways. - SitePage's budget covers the plugins.edit gate and the IDE route entry; the editor body still lands in its own chunk.
SITEPLUGIN-001: scaffold a site plugin from the plugins workspace, confirm it opens in the IDE with real source, build and activate it through the permission review and step-up, see it in the merged plugin list, then edit the server entry and watch the state fall back to Draft changed. The IDE had no browser coverage at all, so nothing caught a regression in the draft to build to activate coupling.
Site plugins put uploaded packages and site-authored ones in one list, so its region is labelled "Plugins" rather than "Installed plugins". The capability spec's helper still looked for the old label and took the whole plugin-boundaries block down with it.
The plugin source migration moves to 031, behind the branches ones (027 to 029) and the timestamp rewrite (030) main took since the fork. activatePluginPackageFromDisk sends anything already installed through the upgrade lifecycle, whatever its version, the way main's zip path has since #498: a same-version rebuild and a rollback to an older build must deactivate the running plugin before its files are replaced, and the fresh path would run install on a live plugin with no rollback. The semver comparison main removed is gone with it. The dev-only direct socket hop that the branch added while Vite's WebSocket proxy was broken under Bun 1.3 is dropped: main runs on Bun 1.4.2 and #503 deleted the workaround, so only its stray fragments had survived the replay. The site-plugins doc names migration 031.
DavidBabinec
force-pushed
the
feat/site-plugins
branch
from
September 12, 2026 17:42
7df05af to
7c59b25
Compare
| const update = (text: string): void => { | ||
| for (let i = 0; i < text.length; i++) { | ||
| h ^= BigInt(text.charCodeAt(i) & 0xff) | ||
| h = (h * prime) & mask |
| const update = (text: string): void => { | ||
| for (let i = 0; i < text.length; i++) { | ||
| h ^= BigInt(text.charCodeAt(i) & 0xff) | ||
| h = (h * prime) & mask |
| h ^= BigInt(text.charCodeAt(i) & 0xff) | ||
| h = (h * prime) & mask | ||
| h ^= BigInt(text.charCodeAt(i) >> 8) | ||
| h = (h * prime) & mask |
| h ^= BigInt(text.charCodeAt(i) & 0xff) | ||
| h = (h * prime) & mask | ||
| h ^= BigInt(text.charCodeAt(i) >> 8) | ||
| h = (h * prime) & mask |
mostafasadeghidev
added a commit
to mostafasadeghidev/Instatic
that referenced
this pull request
Sep 16, 2026
CoreBunch#495 claims `031_installed_plugins_source` and CoreBunch#505 claims `032`, so this ALTER moves to `033` before either lands. The runner keys on the full id, so two `031_…` entries would both run — but a shared number still makes whoever merges second renumber, and an ALTER can only be renumbered while no installation has applied it. That is true today, so it moves now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev
added a commit
to mostafasadeghidev/Instatic
that referenced
this pull request
Sep 16, 2026
The note still said CoreBunch#335 claims 031. It now claims 033, because CoreBunch#495 took 031 — the backfill stays at 032 between them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev
added a commit
to mostafasadeghidev/Instatic
that referenced
this pull request
Sep 16, 2026
… and 033 (CoreBunch#335) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 16, 2026
mostafasadeghidev
added a commit
to mostafasadeghidev/Instatic
that referenced
this pull request
Sep 16, 2026
… and 033 (CoreBunch#335) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev
added a commit
to mostafasadeghidev/Instatic
that referenced
this pull request
Sep 16, 2026
CoreBunch#335 moved its ALTER from 031 to 033 because CoreBunch#495 claims 031. This branch runs the same ALTER as `025_…`, recorded by both live installations, so the incoming entry is dropped exactly as the 031 one was: both migration files are unchanged by the merge itself. The notes that tell a future merge what to delete now name 033, and so does the fork gate's comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DavidBabinec
added a commit
that referenced
this pull request
Oct 12, 2026
…ables (#335) * feat(plugins): @own-created contentAccess marker for plugin-created tables Plugins could create tables via cms.content.tables.create but never read or write their entries: the static contentAccess[] allowlist matches by exact slug, and a runtime-chosen slug cannot be pre-declared. This made importer/migration plugins impossible on the sanctioned surface. - Migration 025 (both dialects, additive): data_tables.created_by_plugin_id, stamped by the create handler with the host-authenticated worker identity. - New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers every table the declaring plugin created, resolved against the stored creator (never by slug), with the entry's declared modes; entries combine as a union. Marker modes go through the same install-time modes<->permission coherence check as slug entries. - assertContentTableAccess now takes the resolved DataTable (handlers resolve-then-assert; tree callbacks already had the table). tables.list / search filter through the same matcher, so own-created tables appear. - tables.create slug constrained to kebab-case, reserving the @ namespace; bundle export/import preserves createdByPluginId. - Docs, capability descriptions, SDK comments updated; stale "consent screen renders contentAccess verbatim" claim corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(branches): a fork keeps which plugin created a table Forking a branch copies `data_tables` with an explicit column list, and `created_by_plugin_id` was not in it. The branch's copy of a plugin-created table came out with no creator — so the `@own-created` contentAccess marker no longer matched it, and the table stopped belonging to the plugin that made it. Nothing reported it; the column was simply null on the copy. It is ownership, not content, so it does not belong in the merge diff — it belongs in the copy, beside `created_by_user_id`, which was already there. The test fails without the change (`Received: null`) and passes with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(db): move the migration to 033 — 031 and 032 are claimed #495 claims `031_installed_plugins_source` and #505 claims `032`, so this ALTER moves to `033` before either lands. The runner keys on the full id, so two `031_…` entries would both run — but a shared number still makes whoever merges second renumber, and an ALTER can only be renumbered while no installation has applied it. That is true today, so it moves now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(db): number the created_by_plugin_id migration 031, next after 030 on main --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com>
DavidBabinec
added a commit
that referenced
this pull request
Oct 27, 2026
* fix(import): map unsupported anchor targets to _self or _blank The anchor import rules copied target="" (and _top or a named frame) straight into the node. None of those values is in AnchorTargetSchema, so the publisher's fallback replaced the whole link with module defaults and the page shipped "Click here" pointing at "#". The schema now lives in @core/htmlAttributes with a normalizeAnchorTarget() helper the importer uses for both anchor rules: empty and _top import as _self, a named target as _blank, keywords match case-insensitively, and href and text are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(editor): apply classes to all selected elements (#511) Assisted-by: OpenAI Codex Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com> * fix(settings): make the Esc keycap close the modal it advertises (#499) * fix(settings): make the Esc keycap close the modal it advertises The footer reads `[Esc] close`, and the keycap looks like a button because every `Kbd` depresses on `:active`. Clicking it played that press animation and did nothing; the pointer over the word "close" turned into a text caret and a click just selected the text. An operator reasonably concluded the modal could not be dismissed with the mouse. It could — the backdrop has always closed it, and Escape works — but a control that animates under the cursor and then ignores the click is worse than no control, because it answers the question wrongly. So the hint becomes the button it already looked like. `Kbd` itself is untouched: six other surfaces render it as a genuine keyboard hint where the press styling is right, and only this one sits next to the word "close". The file header claimed there was no dedicated close button. There is one now, so it says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(settings): let the Button primitive own the Esc keycap's alignment and width --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * feat(plugins): @own-created contentAccess marker for plugin-created tables (#335) * feat(plugins): @own-created contentAccess marker for plugin-created tables Plugins could create tables via cms.content.tables.create but never read or write their entries: the static contentAccess[] allowlist matches by exact slug, and a runtime-chosen slug cannot be pre-declared. This made importer/migration plugins impossible on the sanctioned surface. - Migration 025 (both dialects, additive): data_tables.created_by_plugin_id, stamped by the create handler with the host-authenticated worker identity. - New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers every table the declaring plugin created, resolved against the stored creator (never by slug), with the entry's declared modes; entries combine as a union. Marker modes go through the same install-time modes<->permission coherence check as slug entries. - assertContentTableAccess now takes the resolved DataTable (handlers resolve-then-assert; tree callbacks already had the table). tables.list / search filter through the same matcher, so own-created tables appear. - tables.create slug constrained to kebab-case, reserving the @ namespace; bundle export/import preserves createdByPluginId. - Docs, capability descriptions, SDK comments updated; stale "consent screen renders contentAccess verbatim" claim corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(branches): a fork keeps which plugin created a table Forking a branch copies `data_tables` with an explicit column list, and `created_by_plugin_id` was not in it. The branch's copy of a plugin-created table came out with no creator — so the `@own-created` contentAccess marker no longer matched it, and the table stopped belonging to the plugin that made it. Nothing reported it; the column was simply null on the copy. It is ownership, not content, so it does not belong in the merge diff — it belongs in the copy, beside `created_by_user_id`, which was already there. The test fails without the change (`Received: null`) and passes with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(db): move the migration to 033 — 031 and 032 are claimed #495 claims `031_installed_plugins_source` and #505 claims `032`, so this ALTER moves to `033` before either lands. The runner keys on the full id, so two `031_…` entries would both run — but a shared number still makes whoever merges second renumber, and an ALTER can only be renumbered while no installation has applied it. That is true today, so it moves now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(db): number the created_by_plugin_id migration 031, next after 030 on main --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(site-import): resolve cache-busted asset URLs (#539) * fix(site-import): resolve cache-busted asset URLs A query string or fragment was never stripped before looking a path up in the FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The reference was reported as `missing-stylesheet` / `missing-script` while the file sat in the archive, and every style rule, colour token and font in that sheet was silently dropped from the import. Static site generators commonly emit a content hash on stylesheet, script and media URLs, so this affects a whole class of input. Measured on one real 6-page export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the byte-identical site without the queries — a 39% and 31% loss, reported only as warnings a user can click past. The handling was inconsistent rather than absent, which is why it survived: `linkRewrite` stripped the query itself before calling `resolveHref`, so internal page links resolved correctly while stylesheets did not, and `assetPlan` stripped one for MIME sniffing but not for resolution. Fixed at both resolution primitives: - `resolveHref` (stylesheets, scripts, CSS @import, and page links via linkRewrite) - `resolveRelativePath` (CSS url(), node src/href/srcset) and removed the now-redundant strip in `linkRewrite`. Its fragment split stays — the fragment is re-attached to the page ref and is not addressing. This cannot affect the rewrite step. Node props are replaced by the resolved FileMap key and `rewriteProps` keys on that key, while CSS passes the original `rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes only which entry is found. Tests: a unit group on `resolveHref` covering query, fragment, both, and empty-after-strip, plus an assertion that a query does not change traversal handling; and an end-to-end group over `buildImportPlan` asserting that a cache-busted site produces a plan identical to the same site without queries. The end-to-end shape matters here — the unit returned a plausible-looking key and every consumer dutifully failed to find it, so the defect was only ever visible downstream. Verified the new tests fail without the source change (11 failures) and pass with it. bun run build, bun test (7067), bun run lint all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(site-import): drop the dead query strip in fontFormatForPath resolveRelativePath now strips the query before the FileMap lookup, so the only input this helper receives can no longer carry one. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(dashboard): onboarding step 03 follows the Home page being published (#624) Setup seeds one empty, unpublished Home page. The checklist step for it was "Create your first page", completed only when a second page existed, so a user who edited and published Home still saw "Not started" next to a Pages widget reporting 1 published. A one-page site could never complete the step at all. The step is now "Edit the Home page". It reads In progress from first boot and completes once the dashboard pages stats report at least one published page, which is the moment the site is actually on the frontend. The rules move into a pure deriveOnboardingFacts so they are unit-tested without the network; the hook feeds it the same pages stats endpoint the Pages widget already uses. Closes #223 * fix(data): author reassignment rides the row-access boundary (#625) PATCH /data/rows/:id/author read the target row with a bare getDataRow instead of loadRowForAccess, so a persona holding content.edit.any but not data.system.tables.read could read the full contents of any system-table row (unpublished pages and posts, author identity) and re-author it, while GET on the same row correctly answered 404. The handler now resolves the row through the same table-family plus ownership check every other row write uses. * fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626) * fix(ai): read tools and the MCP relay decide row access with the HTTP rules The content read tools (content_get_document, content_list_documents, content_search_documents) called the row repositories directly, so a caller holding ai.chat plus content.edit.own could read every other user's drafts and any system-table row through the tool surface, while GET /data/rows/:id for the same caller answered 404. The MCP relay carried its own copy of the ownership rule and skipped the table-family boundary altogether. One module now owns the decision. server/auth/dataAccess.ts holds the table-family and ownership predicates plus resolveRowAccess and rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay all call it with the identity behind the request. An architecture gate fails any direct row read under server/ai that bypasses it, because this class was fixed on the HTTP side once (GHSA-x69h) and came back through the next reader. * fix(data): drop the row read import the shared access helper made unused * fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627) * fix(mcp): derive OAuth discovery from the configured public origin, never the Host header Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host header, so an unauthenticated client could make the 401 challenge, both .well-known discovery documents, and the resource comparisons echo an attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes only from the configured public origin; without one, loopback requests use their own origin so local development keeps working, and every other request has no issuer: discovery and the token endpoint answer 503, the 401 challenge drops resource_metadata, and OAuth grants are not looked up while personal access tokens keep working. The server warns at boot when no public origin is configured. * test(mcp): assert discovery origins by parsed origin, not string prefix * chore(release): prepare 0.0.21 (#628) * fix(framework): set the root font size the rem scale assumes (#636) The framework converts every type and spacing token to rem against rootFontSize (10 in the Core preset), but nothing set html font-size, so at the 16px browser default every token rendered 1.6x larger than the scale says. A new emitRootFontSize preference makes the framework emit html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the published framework.css and the canvas iframes. The Core preset turns it on for new frameworks; existing sites have no stored value, which means off, so they keep rendering exactly as before. Site settings > Publishing gets a toggle to opt in. * fix(framework): make theme-inverted sections take the alt colors (#635) The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class. * feat(fonts): seed heading, body and mono font role tokens on new sites (#638) * feat(fonts): seed heading, body and mono font role tokens on new sites New sites had no font tokens and the codebase disagreed on names (docs said font-primary, the AI prompt said font-heading), so content made for one site did not resolve in another. Every brand-new site now starts with fallback-only font-heading, font-body and font-mono tokens, the Core preset adds any a site lacks without touching existing ones, and docs, schema comments and the AI prompt use the role names. Existing sites are not migrated. * fix(site-import): fill an untouched font role token instead of renaming Seeding font-heading, font-body and font-mono on new sites made an imported static site that defines one of those variables collide with the seed and get renamed to font-body-2, leaving the role token on the system stack. A role token that is still exactly as seeded is a placeholder, so the conflict now defaults to overwrite; a role token the user edited keeps the rename default. * docs(fonts): describe role tokens in terms of shared content The rationale now names imported pages, copied sections and AI output as the content the role variables serve. * test(framework): read the framework from the preset result in root font size tests applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property. * fix(import): replace import removes the previous site media (#637) A replace import wiped rows, folders and redirects but kept the old media_assets rows, so a second site shipping a file at the same storage path (two sites that embed the same font file) failed on media_assets.public_path after the data had already committed. Replace now deletes the library inside the import transaction and removes the bytes after commit, before the bundle's media is written. It only does so when the import carries media and the shell on main with no other branch, and it spares avatars and plugin-managed media, since those survive a replace. The archive endpoint now shares the JSON endpoint's apply path instead of a synthetic request, and the custom-table delete binds its boolean so replace runs on Postgres. * fix(editor): stop canvas chrome tokens shadowing site custom properties (#639) The editor-chrome stylesheet in the canvas iframe is unlayered and copied admin tokens such as --radius and --text onto :root under their own names. Unlayered beats the site's @layer user-authored, so a site defining those properties rendered with editor values in the canvas and its own values when published. Every forwarded token now lives under --chrome-*, derived from the var(--chrome-*) references in the chrome rules. * fix(ai): serialize ai_messages content before insert on Postgres (#592) On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain array to a text parameter via String(), storing '[object Object]'. Replaying that history yields empty user content and provider calls fail (Anthropic: 400 "user messages must have non-empty content"). JSON.stringify the content at the insert. SQLite passes strings through unchanged, and reads on both dialects JSON.parse `_json` strings, so behavior there is identical. Kept narrow on purpose: stringifying every object param in the PG adapter would turn values bound to jsonb columns into JSON string scalars and break jsonField() queries. Fixes #593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (#667) A CMS-native form whose target table has a select or multiSelect field could never be submitted. The base.option nodes the setup block inserts from the table carry option.value (the slug authors edit in the Data tab), but validateCoercedValue only accepted option.id, the generated nanoid authors never see. Every choice came back as invalid_option. Resolve the submitted string by option id or value while coercing the field and store the option id, which is the documented cell shape for these fields. The id-only checks in validateCoercedValue are removed since coercion now guarantees a known id. Fixes #629 * fix(publish): prune stored runtime assets of superseded page versions (#647) Every full publish stores each page's bundled runtime files in published_runtime_assets under a fresh version path, and nothing ever deleted them. On a busy production site the table reached 206k rows / 1.5 GB (696 MB written in one day), bloating the database and its backups for bytes that are only a fallback behind the disk-baked copies. persistSitePublish now deletes, per page, the stored assets of every version other than the one being activated and the one it supersedes. Nothing re-activates an old version, and keeping the previous one covers visitors still holding its HTML — the same window the disk's two-slot swap covers. Version rows are untouched. The first publish after upgrading drops the backlog for the published pages. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): resolve custom media fields on entry routes (#662) On a post-type entry route, `{currentEntry.<field>}` for a custom media field rendered the raw media id, while the same binding inside a data.rows loop rendered the /uploads/ path: the entry frame spread raw cells and only resolved the built-in featuredMedia. publishedDataRowToEntryItem now resolves the row's media cells with the same helpers the loop source uses, and every entry-stack caller (public entry route, branch preview, draft preview) goes through it. Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep custom properties with underscores or a leading --_ (#617) The property-name gate rejected any custom property containing `_` or starting with `--_`, so the importer and the publisher dropped them and every var() on them resolved to nothing. Custom properties are now checked against the spec's identifier rule; standard property names keep the stricter pattern and the denylist still applies. Custom property names also pass through toKebab unchanged, so `--brandColor` is no longer emitted as `--brand-color`. Fixes #616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618) Site Import turned root colour custom properties into framework tokens, which the framework emits under the normalised slug (`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported `var()` references on the authored name, so they no longer resolved. The slug is now settled at plan time by `normalizeImportedColorTokens`, and every changed name is rewritten in the imported style rules and node inline styles before conflict detection, so the conflict step and the commit see the emitted name. Two names that normalise to the same slug both survive with a numeric suffix instead of one being dropped at commit. Token renames (normalisation and conflict renames) now also rename declarations of the variable in other rules, their priorities, and raw @Keyframes CSS, so a `.dark { --bg: ... }` override follows its token. Conflict rename targets skip the import's other token names and any custom property the imported CSS declares, and a colour custom rename is normalised like the slug it becomes. The rename helpers move from conflicts.ts into siteImport/varRefs.ts, which keeps conflicts.ts under the 700-line module budget. Fixes #615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620) Site Import normalised a root `--font-*` variable at extraction (`--font-Heading` -> `font-heading`), and the store emits that form, but the imported `var(--font-Heading)` references kept the authored name and no longer resolved. Two authored names that normalise alike were deduped into one token, losing a font. Extraction now keeps the authored variable, and `normalizeImportedTokens` (new `siteImport/tokenNames.ts`) settles the emitted name of every imported colour and font token at plan time, in one pass against one set of claimed names, since both kinds share one CSS namespace. The `authored -> emitted` renames go through the existing var() and declaration rewrite, before conflict detection. `normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`. Fixes #619 * fix(publisher): keep safe images in published entry bodies (#664) * fix(publisher): keep safe images in published entry bodies Fixes #595 * fix(publisher): route content render paths through the image profile --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(loops): sort numeric row cells by value (#601) * fix(publisher): emit one merged rel on links and buttons instead of two (#570) base.link and base.button emitted the author's rel from the htmlAttributes bag and their own security rel as two separate attributes. Browsers keep the first, so a nofollow new-tab link published without noopener noreferrer, and the canvas showed a different rel than the page. The HTML importer also dropped every source rel on anchors as if the module regenerated it. The two anchor modules now merge the authored tokens with the security rel through one shared helper on both render paths, and the importer keeps rel. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644) The editor runs relayed MCP tools strictly one at a time, but the server pushed a whole batch at once. Every call's 90s timeout started at push time, so calls queued behind slow ones (render snapshots, a background tab) timed out before the editor reached them. The 120s idle lease was only re-armed by pushes, so a batch whose serial execution outlasted it closed the stream mid-batch: the in-flight call was rejected ("closed with 1 pending tool result(s)") and the editor's late result POST got a 404. - createBridge gains an opt-in `serial` option: the next request is emitted only after the previous one settles, and its timeout starts on emit. Queued calls still reject on abort/destroy without being emitted. The chat bridge is unchanged. - The editor bridge uses serial delivery, and its idle lease re-arms instead of closing while any call is pending. Each pending call is bounded by the per-call timeout, so an orphaned stream still drains and closes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(collab): preserve initial sync delivery under backpressure (#590) * fix(collab): preserve initial sync delivery under backpressure * fix(collab): packetize large stored document delivery * fix(server): answer conditional requests for static files and uploads with 304 (#653) * fix(server): answer conditional requests for static files and uploads with 304 serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin shell files but no ETag or Last-Modified, so the browser had nothing to revalidate with and re-downloaded every file in full. The editor preview iframe reloads every image and font on each rebuild: about 117 requests and 3.7 MB per preview load on one production site. Responses now carry a weak ETag (size + mtime) and Last-Modified, and If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match wins, per RFC 9110). A media replace that keeps the path changes the mtime, so the validator changes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ai): serialize ai_messages content before insert on Postgres (#592) On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain array to a text parameter via String(), storing '[object Object]'. Replaying that history yields empty user content and provider calls fail (Anthropic: 400 "user messages must have non-empty content"). JSON.stringify the content at the insert. SQLite passes strings through unchanged, and reads on both dialects JSON.parse `_json` strings, so behavior there is identical. Kept narrow on purpose: stringifying every object param in the PG adapter would turn values bound to jsonb columns into JSON string scalars and break jsonField() queries. Fixes #593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (#667) A CMS-native form whose target table has a select or multiSelect field could never be submitted. The base.option nodes the setup block inserts from the table carry option.value (the slug authors edit in the Data tab), but validateCoercedValue only accepted option.id, the generated nanoid authors never see. Every choice came back as invalid_option. Resolve the submitted string by option id or value while coercing the field and store the option id, which is the documented cell shape for these fields. The id-only checks in validateCoercedValue are removed since coercion now guarantees a known id. Fixes #629 * fix(publish): prune stored runtime assets of superseded page versions (#647) Every full publish stores each page's bundled runtime files in published_runtime_assets under a fresh version path, and nothing ever deleted them. On a busy production site the table reached 206k rows / 1.5 GB (696 MB written in one day), bloating the database and its backups for bytes that are only a fallback behind the disk-baked copies. persistSitePublish now deletes, per page, the stored assets of every version other than the one being activated and the one it supersedes. Nothing re-activates an old version, and keeping the previous one covers visitors still holding its HTML — the same window the disk's two-slot swap covers. Version rows are untouched. The first publish after upgrading drops the backlog for the published pages. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): resolve custom media fields on entry routes (#662) On a post-type entry route, `{currentEntry.<field>}` for a custom media field rendered the raw media id, while the same binding inside a data.rows loop rendered the /uploads/ path: the entry frame spread raw cells and only resolved the built-in featuredMedia. publishedDataRowToEntryItem now resolves the row's media cells with the same helpers the loop source uses, and every entry-stack caller (public entry route, branch preview, draft preview) goes through it. Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep custom properties with underscores or a leading --_ (#617) The property-name gate rejected any custom property containing `_` or starting with `--_`, so the importer and the publisher dropped them and every var() on them resolved to nothing. Custom properties are now checked against the spec's identifier rule; standard property names keep the stricter pattern and the denylist still applies. Custom property names also pass through toKebab unchanged, so `--brandColor` is no longer emitted as `--brand-color`. Fixes #616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618) Site Import turned root colour custom properties into framework tokens, which the framework emits under the normalised slug (`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported `var()` references on the authored name, so they no longer resolved. The slug is now settled at plan time by `normalizeImportedColorTokens`, and every changed name is rewritten in the imported style rules and node inline styles before conflict detection, so the conflict step and the commit see the emitted name. Two names that normalise to the same slug both survive with a numeric suffix instead of one being dropped at commit. Token renames (normalisation and conflict renames) now also rename declarations of the variable in other rules, their priorities, and raw @Keyframes CSS, so a `.dark { --bg: ... }` override follows its token. Conflict rename targets skip the import's other token names and any custom property the imported CSS declares, and a colour custom rename is normalised like the slug it becomes. The rename helpers move from conflicts.ts into siteImport/varRefs.ts, which keeps conflicts.ts under the 700-line module budget. Fixes #615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620) Site Import normalised a root `--font-*` variable at extraction (`--font-Heading` -> `font-heading`), and the store emits that form, but the imported `var(--font-Heading)` references kept the authored name and no longer resolved. Two authored names that normalise alike were deduped into one token, losing a font. Extraction now keeps the authored variable, and `normalizeImportedTokens` (new `siteImport/tokenNames.ts`) settles the emitted name of every imported colour and font token at plan time, in one pass against one set of claimed names, since both kinds share one CSS namespace. The `authored -> emitted` renames go through the existing var() and declaration rewrite, before conflict detection. `normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`. Fixes #619 * fix(publisher): keep safe images in published entry bodies (#664) * fix(publisher): keep safe images in published entry bodies Fixes #595 * fix(publisher): route content render paths through the image profile --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(loops): sort numeric row cells by value (#601) * fix(publisher): emit one merged rel on links and buttons instead of two (#570) base.link and base.button emitted the author's rel from the htmlAttributes bag and their own security rel as two separate attributes. Browsers keep the first, so a nofollow new-tab link published without noopener noreferrer, and the canvas showed a different rel than the page. The HTML importer also dropped every source rel on anchors as if the module regenerated it. The two anchor modules now merge the authored tokens with the security rel through one shared helper on both render paths, and the importer keeps rel. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644) The editor runs relayed MCP tools strictly one at a time, but the server pushed a whole batch at once. Every call's 90s timeout started at push time, so calls queued behind slow ones (render snapshots, a background tab) timed out before the editor reached them. The 120s idle lease was only re-armed by pushes, so a batch whose serial execution outlasted it closed the stream mid-batch: the in-flight call was rejected ("closed with 1 pending tool result(s)") and the editor's late result POST got a 404. - createBridge gains an opt-in `serial` option: the next request is emitted only after the previous one settles, and its timeout starts on emit. Queued calls still reject on abort/destroy without being emitted. The chat bridge is unchanged. - The editor bridge uses serial delivery, and its idle lease re-arms instead of closing while any call is pending. Each pending call is bounded by the per-call timeout, so an orphaned stream still drains and closes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(collab): preserve initial sync delivery under backpressure (#590) * fix(collab): preserve initial sync delivery under backpressure * fix(collab): packetize large stored document delivery * fix(server): preserve upload MIME when revalidating cached files --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com> Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com> Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com> Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com> Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com> Co-authored-by: tommy230 <tommy@godigitalalchemy.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646) Each runtime preview build POSTs the whole site document and the server re-validates it, re-bundles the page scripts and renders the page. The Preview overlay and the Site Explorer hover card rebuilt on every `site` change, so typing or a collaborator's edits aborted the in-flight build and started another — production logs show bursts of 8 cancelled requests within two seconds — and the preview blanked to "Building…" each time. - useRuntimePreviewDocument builds from the draft only once it has stayed unchanged for 400ms (PREVIEW_SITE_SETTLE_MS). - While a newer build runs, `html` keeps the previous build for the same page and context; both callers show the loading state only when there is nothing to show yet. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572) The shaker read every registry class in a selector as a requirement, including classes inside :not() and each alternative of :is()/:where(). A negated class is the opposite of a requirement and :is() alternatives are each sufficient, so rules like `.nav > li > a:not(.cart-button)` were dropped whenever no node carried the negated class. selectorPartCanMatch now splits a part into the text whose classes are all required and the alternation groups where one alternative is enough; :has() and :nth-child(n of ...) stay required. For selectors the shared lexer reads correctly, drops are a subset of before. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep style and class injection out of data-style and data-class (#561) The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches after the hyphen in data-style and data-class. Node styles or classes were merged into the data attribute and the real attribute was never emitted. Both regexes now require whitespace before the attribute name, and the replacements use a function so $ sequences in values stay inert. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): repair invalid props one by one instead of resetting the node (#574) When Value.Parse rejected a node's props, validateNodeProps replaced every declared prop with the module default, so a link with one unsupported target published as "Click here" pointing at "#" while its href and text sat valid in the store. The catch branch now parses each declared prop on its own and falls back to the default only for the props that still fail. Fast path, successful slow path, optional props and injected keys are unchanged. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: MelmacAlpha <github@201620.de> Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com> Co-authored-by: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> Co-authored-by: Rick Caves <spcxai2025@gmail.com> Co-authored-by: David Babinec <49069339+DavidBabinec@users.noreply.github.com> Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com> Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com> Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com> Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com> Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com> Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
DavidBabinec
added a commit
that referenced
this pull request
Oct 27, 2026
…ition (#573) * fix(site-import): keep a repeated class rule at its later cascade position A stylesheet that styled the same bare class twice had its second rule merged into the first, so the merged rule sat before any rule written between the two occurrences and lost cascade ties it wins in a browser. Each occurrence is now its own rule at its own source position. Both stay class-kind through parsing so cross-sheet conflict detection still sees the file's full definition; the registry demotes repeats at commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(editor): apply classes to all selected elements (#511) Assisted-by: OpenAI Codex Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com> * fix(settings): make the Esc keycap close the modal it advertises (#499) * fix(settings): make the Esc keycap close the modal it advertises The footer reads `[Esc] close`, and the keycap looks like a button because every `Kbd` depresses on `:active`. Clicking it played that press animation and did nothing; the pointer over the word "close" turned into a text caret and a click just selected the text. An operator reasonably concluded the modal could not be dismissed with the mouse. It could — the backdrop has always closed it, and Escape works — but a control that animates under the cursor and then ignores the click is worse than no control, because it answers the question wrongly. So the hint becomes the button it already looked like. `Kbd` itself is untouched: six other surfaces render it as a genuine keyboard hint where the press styling is right, and only this one sits next to the word "close". The file header claimed there was no dedicated close button. There is one now, so it says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(settings): let the Button primitive own the Esc keycap's alignment and width --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * feat(plugins): @own-created contentAccess marker for plugin-created tables (#335) * feat(plugins): @own-created contentAccess marker for plugin-created tables Plugins could create tables via cms.content.tables.create but never read or write their entries: the static contentAccess[] allowlist matches by exact slug, and a runtime-chosen slug cannot be pre-declared. This made importer/migration plugins impossible on the sanctioned surface. - Migration 025 (both dialects, additive): data_tables.created_by_plugin_id, stamped by the create handler with the host-authenticated worker identity. - New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers every table the declaring plugin created, resolved against the stored creator (never by slug), with the entry's declared modes; entries combine as a union. Marker modes go through the same install-time modes<->permission coherence check as slug entries. - assertContentTableAccess now takes the resolved DataTable (handlers resolve-then-assert; tree callbacks already had the table). tables.list / search filter through the same matcher, so own-created tables appear. - tables.create slug constrained to kebab-case, reserving the @ namespace; bundle export/import preserves createdByPluginId. - Docs, capability descriptions, SDK comments updated; stale "consent screen renders contentAccess verbatim" claim corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(branches): a fork keeps which plugin created a table Forking a branch copies `data_tables` with an explicit column list, and `created_by_plugin_id` was not in it. The branch's copy of a plugin-created table came out with no creator — so the `@own-created` contentAccess marker no longer matched it, and the table stopped belonging to the plugin that made it. Nothing reported it; the column was simply null on the copy. It is ownership, not content, so it does not belong in the merge diff — it belongs in the copy, beside `created_by_user_id`, which was already there. The test fails without the change (`Received: null`) and passes with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(db): move the migration to 033 — 031 and 032 are claimed #495 claims `031_installed_plugins_source` and #505 claims `032`, so this ALTER moves to `033` before either lands. The runner keys on the full id, so two `031_…` entries would both run — but a shared number still makes whoever merges second renumber, and an ALTER can only be renumbered while no installation has applied it. That is true today, so it moves now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(db): number the created_by_plugin_id migration 031, next after 030 on main --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(site-import): resolve cache-busted asset URLs (#539) * fix(site-import): resolve cache-busted asset URLs A query string or fragment was never stripped before looking a path up in the FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The reference was reported as `missing-stylesheet` / `missing-script` while the file sat in the archive, and every style rule, colour token and font in that sheet was silently dropped from the import. Static site generators commonly emit a content hash on stylesheet, script and media URLs, so this affects a whole class of input. Measured on one real 6-page export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the byte-identical site without the queries — a 39% and 31% loss, reported only as warnings a user can click past. The handling was inconsistent rather than absent, which is why it survived: `linkRewrite` stripped the query itself before calling `resolveHref`, so internal page links resolved correctly while stylesheets did not, and `assetPlan` stripped one for MIME sniffing but not for resolution. Fixed at both resolution primitives: - `resolveHref` (stylesheets, scripts, CSS @import, and page links via linkRewrite) - `resolveRelativePath` (CSS url(), node src/href/srcset) and removed the now-redundant strip in `linkRewrite`. Its fragment split stays — the fragment is re-attached to the page ref and is not addressing. This cannot affect the rewrite step. Node props are replaced by the resolved FileMap key and `rewriteProps` keys on that key, while CSS passes the original `rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes only which entry is found. Tests: a unit group on `resolveHref` covering query, fragment, both, and empty-after-strip, plus an assertion that a query does not change traversal handling; and an end-to-end group over `buildImportPlan` asserting that a cache-busted site produces a plan identical to the same site without queries. The end-to-end shape matters here — the unit returned a plausible-looking key and every consumer dutifully failed to find it, so the defect was only ever visible downstream. Verified the new tests fail without the source change (11 failures) and pass with it. bun run build, bun test (7067), bun run lint all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(site-import): drop the dead query strip in fontFormatForPath resolveRelativePath now strips the query before the FileMap lookup, so the only input this helper receives can no longer carry one. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(dashboard): onboarding step 03 follows the Home page being published (#624) Setup seeds one empty, unpublished Home page. The checklist step for it was "Create your first page", completed only when a second page existed, so a user who edited and published Home still saw "Not started" next to a Pages widget reporting 1 published. A one-page site could never complete the step at all. The step is now "Edit the Home page". It reads In progress from first boot and completes once the dashboard pages stats report at least one published page, which is the moment the site is actually on the frontend. The rules move into a pure deriveOnboardingFacts so they are unit-tested without the network; the hook feeds it the same pages stats endpoint the Pages widget already uses. Closes #223 * fix(data): author reassignment rides the row-access boundary (#625) PATCH /data/rows/:id/author read the target row with a bare getDataRow instead of loadRowForAccess, so a persona holding content.edit.any but not data.system.tables.read could read the full contents of any system-table row (unpublished pages and posts, author identity) and re-author it, while GET on the same row correctly answered 404. The handler now resolves the row through the same table-family plus ownership check every other row write uses. * fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626) * fix(ai): read tools and the MCP relay decide row access with the HTTP rules The content read tools (content_get_document, content_list_documents, content_search_documents) called the row repositories directly, so a caller holding ai.chat plus content.edit.own could read every other user's drafts and any system-table row through the tool surface, while GET /data/rows/:id for the same caller answered 404. The MCP relay carried its own copy of the ownership rule and skipped the table-family boundary altogether. One module now owns the decision. server/auth/dataAccess.ts holds the table-family and ownership predicates plus resolveRowAccess and rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay all call it with the identity behind the request. An architecture gate fails any direct row read under server/ai that bypasses it, because this class was fixed on the HTTP side once (GHSA-x69h) and came back through the next reader. * fix(data): drop the row read import the shared access helper made unused * fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627) * fix(mcp): derive OAuth discovery from the configured public origin, never the Host header Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host header, so an unauthenticated client could make the 401 challenge, both .well-known discovery documents, and the resource comparisons echo an attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes only from the configured public origin; without one, loopback requests use their own origin so local development keeps working, and every other request has no issuer: discovery and the token endpoint answer 503, the 401 challenge drops resource_metadata, and OAuth grants are not looked up while personal access tokens keep working. The server warns at boot when no public origin is configured. * test(mcp): assert discovery origins by parsed origin, not string prefix * chore(release): prepare 0.0.21 (#628) * fix(framework): set the root font size the rem scale assumes (#636) The framework converts every type and spacing token to rem against rootFontSize (10 in the Core preset), but nothing set html font-size, so at the 16px browser default every token rendered 1.6x larger than the scale says. A new emitRootFontSize preference makes the framework emit html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the published framework.css and the canvas iframes. The Core preset turns it on for new frameworks; existing sites have no stored value, which means off, so they keep rendering exactly as before. Site settings > Publishing gets a toggle to opt in. * fix(framework): make theme-inverted sections take the alt colors (#635) The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class. * feat(fonts): seed heading, body and mono font role tokens on new sites (#638) * feat(fonts): seed heading, body and mono font role tokens on new sites New sites had no font tokens and the codebase disagreed on names (docs said font-primary, the AI prompt said font-heading), so content made for one site did not resolve in another. Every brand-new site now starts with fallback-only font-heading, font-body and font-mono tokens, the Core preset adds any a site lacks without touching existing ones, and docs, schema comments and the AI prompt use the role names. Existing sites are not migrated. * fix(site-import): fill an untouched font role token instead of renaming Seeding font-heading, font-body and font-mono on new sites made an imported static site that defines one of those variables collide with the seed and get renamed to font-body-2, leaving the role token on the system stack. A role token that is still exactly as seeded is a placeholder, so the conflict now defaults to overwrite; a role token the user edited keeps the rename default. * docs(fonts): describe role tokens in terms of shared content The rationale now names imported pages, copied sections and AI output as the content the role variables serve. * test(framework): read the framework from the preset result in root font size tests applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property. * fix(import): replace import removes the previous site media (#637) A replace import wiped rows, folders and redirects but kept the old media_assets rows, so a second site shipping a file at the same storage path (two sites that embed the same font file) failed on media_assets.public_path after the data had already committed. Replace now deletes the library inside the import transaction and removes the bytes after commit, before the bundle's media is written. It only does so when the import carries media and the shell on main with no other branch, and it spares avatars and plugin-managed media, since those survive a replace. The archive endpoint now shares the JSON endpoint's apply path instead of a synthetic request, and the custom-table delete binds its boolean so replace runs on Postgres. * fix(editor): stop canvas chrome tokens shadowing site custom properties (#639) The editor-chrome stylesheet in the canvas iframe is unlayered and copied admin tokens such as --radius and --text onto :root under their own names. Unlayered beats the site's @layer user-authored, so a site defining those properties rendered with editor values in the canvas and its own values when published. Every forwarded token now lives under --chrome-*, derived from the var(--chrome-*) references in the chrome rules. * fix(ai): serialize ai_messages content before insert on Postgres (#592) On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain array to a text parameter via String(), storing '[object Object]'. Replaying that history yields empty user content and provider calls fail (Anthropic: 400 "user messages must have non-empty content"). JSON.stringify the content at the insert. SQLite passes strings through unchanged, and reads on both dialects JSON.parse `_json` strings, so behavior there is identical. Kept narrow on purpose: stringifying every object param in the PG adapter would turn values bound to jsonb columns into JSON string scalars and break jsonField() queries. Fixes #593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (#667) A CMS-native form whose target table has a select or multiSelect field could never be submitted. The base.option nodes the setup block inserts from the table carry option.value (the slug authors edit in the Data tab), but validateCoercedValue only accepted option.id, the generated nanoid authors never see. Every choice came back as invalid_option. Resolve the submitted string by option id or value while coercing the field and store the option id, which is the documented cell shape for these fields. The id-only checks in validateCoercedValue are removed since coercion now guarantees a known id. Fixes #629 * fix(publish): prune stored runtime assets of superseded page versions (#647) Every full publish stores each page's bundled runtime files in published_runtime_assets under a fresh version path, and nothing ever deleted them. On a busy production site the table reached 206k rows / 1.5 GB (696 MB written in one day), bloating the database and its backups for bytes that are only a fallback behind the disk-baked copies. persistSitePublish now deletes, per page, the stored assets of every version other than the one being activated and the one it supersedes. Nothing re-activates an old version, and keeping the previous one covers visitors still holding its HTML — the same window the disk's two-slot swap covers. Version rows are untouched. The first publish after upgrading drops the backlog for the published pages. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): resolve custom media fields on entry routes (#662) On a post-type entry route, `{currentEntry.<field>}` for a custom media field rendered the raw media id, while the same binding inside a data.rows loop rendered the /uploads/ path: the entry frame spread raw cells and only resolved the built-in featuredMedia. publishedDataRowToEntryItem now resolves the row's media cells with the same helpers the loop source uses, and every entry-stack caller (public entry route, branch preview, draft preview) goes through it. Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep custom properties with underscores or a leading --_ (#617) The property-name gate rejected any custom property containing `_` or starting with `--_`, so the importer and the publisher dropped them and every var() on them resolved to nothing. Custom properties are now checked against the spec's identifier rule; standard property names keep the stricter pattern and the denylist still applies. Custom property names also pass through toKebab unchanged, so `--brandColor` is no longer emitted as `--brand-color`. Fixes #616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618) Site Import turned root colour custom properties into framework tokens, which the framework emits under the normalised slug (`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported `var()` references on the authored name, so they no longer resolved. The slug is now settled at plan time by `normalizeImportedColorTokens`, and every changed name is rewritten in the imported style rules and node inline styles before conflict detection, so the conflict step and the commit see the emitted name. Two names that normalise to the same slug both survive with a numeric suffix instead of one being dropped at commit. Token renames (normalisation and conflict renames) now also rename declarations of the variable in other rules, their priorities, and raw @Keyframes CSS, so a `.dark { --bg: ... }` override follows its token. Conflict rename targets skip the import's other token names and any custom property the imported CSS declares, and a colour custom rename is normalised like the slug it becomes. The rename helpers move from conflicts.ts into siteImport/varRefs.ts, which keeps conflicts.ts under the 700-line module budget. Fixes #615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620) Site Import normalised a root `--font-*` variable at extraction (`--font-Heading` -> `font-heading`), and the store emits that form, but the imported `var(--font-Heading)` references kept the authored name and no longer resolved. Two authored names that normalise alike were deduped into one token, losing a font. Extraction now keeps the authored variable, and `normalizeImportedTokens` (new `siteImport/tokenNames.ts`) settles the emitted name of every imported colour and font token at plan time, in one pass against one set of claimed names, since both kinds share one CSS namespace. The `authored -> emitted` renames go through the existing var() and declaration rewrite, before conflict detection. `normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`. Fixes #619 * fix(publisher): keep safe images in published entry bodies (#664) * fix(publisher): keep safe images in published entry bodies Fixes #595 * fix(publisher): route content render paths through the image profile --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(loops): sort numeric row cells by value (#601) * fix(publisher): emit one merged rel on links and buttons instead of two (#570) base.link and base.button emitted the author's rel from the htmlAttributes bag and their own security rel as two separate attributes. Browsers keep the first, so a nofollow new-tab link published without noopener noreferrer, and the canvas showed a different rel than the page. The HTML importer also dropped every source rel on anchors as if the module regenerated it. The two anchor modules now merge the authored tokens with the security rel through one shared helper on both render paths, and the importer keeps rel. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644) The editor runs relayed MCP tools strictly one at a time, but the server pushed a whole batch at once. Every call's 90s timeout started at push time, so calls queued behind slow ones (render snapshots, a background tab) timed out before the editor reached them. The 120s idle lease was only re-armed by pushes, so a batch whose serial execution outlasted it closed the stream mid-batch: the in-flight call was rejected ("closed with 1 pending tool result(s)") and the editor's late result POST got a 404. - createBridge gains an opt-in `serial` option: the next request is emitted only after the previous one settles, and its timeout starts on emit. Queued calls still reject on abort/destroy without being emitted. The chat bridge is unchanged. - The editor bridge uses serial delivery, and its idle lease re-arms instead of closing while any call is pending. Each pending call is bounded by the per-call timeout, so an orphaned stream still drains and closes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(collab): preserve initial sync delivery under backpressure (#590) * fix(collab): preserve initial sync delivery under backpressure * fix(collab): packetize large stored document delivery * fix(server): answer conditional requests for static files and uploads with 304 (#653) * fix(server): answer conditional requests for static files and uploads with 304 serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin shell files but no ETag or Last-Modified, so the browser had nothing to revalidate with and re-downloaded every file in full. The editor preview iframe reloads every image and font on each rebuild: about 117 requests and 3.7 MB per preview load on one production site. Responses now carry a weak ETag (size + mtime) and Last-Modified, and If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match wins, per RFC 9110). A media replace that keeps the path changes the mtime, so the validator changes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ai): serialize ai_messages content before insert on Postgres (#592) On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain array to a text parameter via String(), storing '[object Object]'. Replaying that history yields empty user content and provider calls fail (Anthropic: 400 "user messages must have non-empty content"). JSON.stringify the content at the insert. SQLite passes strings through unchanged, and reads on both dialects JSON.parse `_json` strings, so behavior there is identical. Kept narrow on purpose: stringifying every object param in the PG adapter would turn values bound to jsonb columns into JSON string scalars and break jsonField() queries. Fixes #593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (#667) A CMS-native form whose target table has a select or multiSelect field could never be submitted. The base.option nodes the setup block inserts from the table carry option.value (the slug authors edit in the Data tab), but validateCoercedValue only accepted option.id, the generated nanoid authors never see. Every choice came back as invalid_option. Resolve the submitted string by option id or value while coercing the field and store the option id, which is the documented cell shape for these fields. The id-only checks in validateCoercedValue are removed since coercion now guarantees a known id. Fixes #629 * fix(publish): prune stored runtime assets of superseded page versions (#647) Every full publish stores each page's bundled runtime files in published_runtime_assets under a fresh version path, and nothing ever deleted them. On a busy production site the table reached 206k rows / 1.5 GB (696 MB written in one day), bloating the database and its backups for bytes that are only a fallback behind the disk-baked copies. persistSitePublish now deletes, per page, the stored assets of every version other than the one being activated and the one it supersedes. Nothing re-activates an old version, and keeping the previous one covers visitors still holding its HTML — the same window the disk's two-slot swap covers. Version rows are untouched. The first publish after upgrading drops the backlog for the published pages. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): resolve custom media fields on entry routes (#662) On a post-type entry route, `{currentEntry.<field>}` for a custom media field rendered the raw media id, while the same binding inside a data.rows loop rendered the /uploads/ path: the entry frame spread raw cells and only resolved the built-in featuredMedia. publishedDataRowToEntryItem now resolves the row's media cells with the same helpers the loop source uses, and every entry-stack caller (public entry route, branch preview, draft preview) goes through it. Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep custom properties with underscores or a leading --_ (#617) The property-name gate rejected any custom property containing `_` or starting with `--_`, so the importer and the publisher dropped them and every var() on them resolved to nothing. Custom properties are now checked against the spec's identifier rule; standard property names keep the stricter pattern and the denylist still applies. Custom property names also pass through toKebab unchanged, so `--brandColor` is no longer emitted as `--brand-color`. Fixes #616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618) Site Import turned root colour custom properties into framework tokens, which the framework emits under the normalised slug (`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported `var()` references on the authored name, so they no longer resolved. The slug is now settled at plan time by `normalizeImportedColorTokens`, and every changed name is rewritten in the imported style rules and node inline styles before conflict detection, so the conflict step and the commit see the emitted name. Two names that normalise to the same slug both survive with a numeric suffix instead of one being dropped at commit. Token renames (normalisation and conflict renames) now also rename declarations of the variable in other rules, their priorities, and raw @Keyframes CSS, so a `.dark { --bg: ... }` override follows its token. Conflict rename targets skip the import's other token names and any custom property the imported CSS declares, and a colour custom rename is normalised like the slug it becomes. The rename helpers move from conflicts.ts into siteImport/varRefs.ts, which keeps conflicts.ts under the 700-line module budget. Fixes #615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620) Site Import normalised a root `--font-*` variable at extraction (`--font-Heading` -> `font-heading`), and the store emits that form, but the imported `var(--font-Heading)` references kept the authored name and no longer resolved. Two authored names that normalise alike were deduped into one token, losing a font. Extraction now keeps the authored variable, and `normalizeImportedTokens` (new `siteImport/tokenNames.ts`) settles the emitted name of every imported colour and font token at plan time, in one pass against one set of claimed names, since both kinds share one CSS namespace. The `authored -> emitted` renames go through the existing var() and declaration rewrite, before conflict detection. `normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`. Fixes #619 * fix(publisher): keep safe images in published entry bodies (#664) * fix(publisher): keep safe images in published entry bodies Fixes #595 * fix(publisher): route content render paths through the image profile --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(loops): sort numeric row cells by value (#601) * fix(publisher): emit one merged rel on links and buttons instead of two (#570) base.link and base.button emitted the author's rel from the htmlAttributes bag and their own security rel as two separate attributes. Browsers keep the first, so a nofollow new-tab link published without noopener noreferrer, and the canvas showed a different rel than the page. The HTML importer also dropped every source rel on anchors as if the module regenerated it. The two anchor modules now merge the authored tokens with the security rel through one shared helper on both render paths, and the importer keeps rel. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644) The editor runs relayed MCP tools strictly one at a time, but the server pushed a whole batch at once. Every call's 90s timeout started at push time, so calls queued behind slow ones (render snapshots, a background tab) timed out before the editor reached them. The 120s idle lease was only re-armed by pushes, so a batch whose serial execution outlasted it closed the stream mid-batch: the in-flight call was rejected ("closed with 1 pending tool result(s)") and the editor's late result POST got a 404. - createBridge gains an opt-in `serial` option: the next request is emitted only after the previous one settles, and its timeout starts on emit. Queued calls still reject on abort/destroy without being emitted. The chat bridge is unchanged. - The editor bridge uses serial delivery, and its idle lease re-arms instead of closing while any call is pending. Each pending call is bounded by the per-call timeout, so an orphaned stream still drains and closes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(collab): preserve initial sync delivery under backpressure (#590) * fix(collab): preserve initial sync delivery under backpressure * fix(collab): packetize large stored document delivery * fix(server): preserve upload MIME when revalidating cached files --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com> Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com> Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com> Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com> Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com> Co-authored-by: tommy230 <tommy@godigitalalchemy.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646) Each runtime preview build POSTs the whole site document and the server re-validates it, re-bundles the page scripts and renders the page. The Preview overlay and the Site Explorer hover card rebuilt on every `site` change, so typing or a collaborator's edits aborted the in-flight build and started another — production logs show bursts of 8 cancelled requests within two seconds — and the preview blanked to "Building…" each time. - useRuntimePreviewDocument builds from the draft only once it has stayed unchanged for 400ms (PREVIEW_SITE_SETTLE_MS). - While a newer build runs, `html` keeps the previous build for the same page and context; both callers show the loading state only when there is nothing to show yet. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572) The shaker read every registry class in a selector as a requirement, including classes inside :not() and each alternative of :is()/:where(). A negated class is the opposite of a requirement and :is() alternatives are each sufficient, so rules like `.nav > li > a:not(.cart-button)` were dropped whenever no node carried the negated class. selectorPartCanMatch now splits a part into the text whose classes are all required and the alternation groups where one alternative is enough; :has() and :nth-child(n of ...) stay required. For selectors the shared lexer reads correctly, drops are a subset of before. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep style and class injection out of data-style and data-class (#561) The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches after the hyphen in data-style and data-class. Node styles or classes were merged into the data attribute and the real attribute was never emitted. Both regexes now require whitespace before the attribute name, and the replacements use a function so $ sequences in values stay inert. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): repair invalid props one by one instead of resetting the node (#574) When Value.Parse rejected a node's props, validateNodeProps replaced every declared prop with the module default, so a link with one unsupported target published as "Click here" pointing at "#" while its href and text sat valid in the store. The catch branch now parses each declared prop on its own and falls back to the default only for the props that still fail. Fast path, successful slow path, optional props and injected keys are unchanged. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): preserve repeats after empty conditional class rules --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: MelmacAlpha <github@201620.de> Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com> Co-authored-by: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> Co-authored-by: Rick Caves <spcxai2025@gmail.com> Co-authored-by: David Babinec <49069339+DavidBabinec@users.noreply.github.com> Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com> Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com> Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com> Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com> Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com> Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
DavidBabinec
added a commit
that referenced
this pull request
Oct 27, 2026
…ks (#560) * fix(import): close each style block's open @media before joining blocks A browser parses each <style> element as its own stylesheet, so an unclosed @media or rule at the end of one block ends at that block. The importer joins a page's blocks into one source, so one missing `}` pulled every later block into the media query. Close each block's open braces, comment, or string before joining. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(editor): apply classes to all selected elements (#511) Assisted-by: OpenAI Codex Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com> * fix(settings): make the Esc keycap close the modal it advertises (#499) * fix(settings): make the Esc keycap close the modal it advertises The footer reads `[Esc] close`, and the keycap looks like a button because every `Kbd` depresses on `:active`. Clicking it played that press animation and did nothing; the pointer over the word "close" turned into a text caret and a click just selected the text. An operator reasonably concluded the modal could not be dismissed with the mouse. It could — the backdrop has always closed it, and Escape works — but a control that animates under the cursor and then ignores the click is worse than no control, because it answers the question wrongly. So the hint becomes the button it already looked like. `Kbd` itself is untouched: six other surfaces render it as a genuine keyboard hint where the press styling is right, and only this one sits next to the word "close". The file header claimed there was no dedicated close button. There is one now, so it says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(settings): let the Button primitive own the Esc keycap's alignment and width --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * feat(plugins): @own-created contentAccess marker for plugin-created tables (#335) * feat(plugins): @own-created contentAccess marker for plugin-created tables Plugins could create tables via cms.content.tables.create but never read or write their entries: the static contentAccess[] allowlist matches by exact slug, and a runtime-chosen slug cannot be pre-declared. This made importer/migration plugins impossible on the sanctioned surface. - Migration 025 (both dialects, additive): data_tables.created_by_plugin_id, stamped by the create handler with the host-authenticated worker identity. - New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers every table the declaring plugin created, resolved against the stored creator (never by slug), with the entry's declared modes; entries combine as a union. Marker modes go through the same install-time modes<->permission coherence check as slug entries. - assertContentTableAccess now takes the resolved DataTable (handlers resolve-then-assert; tree callbacks already had the table). tables.list / search filter through the same matcher, so own-created tables appear. - tables.create slug constrained to kebab-case, reserving the @ namespace; bundle export/import preserves createdByPluginId. - Docs, capability descriptions, SDK comments updated; stale "consent screen renders contentAccess verbatim" claim corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(branches): a fork keeps which plugin created a table Forking a branch copies `data_tables` with an explicit column list, and `created_by_plugin_id` was not in it. The branch's copy of a plugin-created table came out with no creator — so the `@own-created` contentAccess marker no longer matched it, and the table stopped belonging to the plugin that made it. Nothing reported it; the column was simply null on the copy. It is ownership, not content, so it does not belong in the merge diff — it belongs in the copy, beside `created_by_user_id`, which was already there. The test fails without the change (`Received: null`) and passes with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(db): move the migration to 033 — 031 and 032 are claimed #495 claims `031_installed_plugins_source` and #505 claims `032`, so this ALTER moves to `033` before either lands. The runner keys on the full id, so two `031_…` entries would both run — but a shared number still makes whoever merges second renumber, and an ALTER can only be renumbered while no installation has applied it. That is true today, so it moves now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(db): number the created_by_plugin_id migration 031, next after 030 on main --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(site-import): resolve cache-busted asset URLs (#539) * fix(site-import): resolve cache-busted asset URLs A query string or fragment was never stripped before looking a path up in the FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The reference was reported as `missing-stylesheet` / `missing-script` while the file sat in the archive, and every style rule, colour token and font in that sheet was silently dropped from the import. Static site generators commonly emit a content hash on stylesheet, script and media URLs, so this affects a whole class of input. Measured on one real 6-page export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the byte-identical site without the queries — a 39% and 31% loss, reported only as warnings a user can click past. The handling was inconsistent rather than absent, which is why it survived: `linkRewrite` stripped the query itself before calling `resolveHref`, so internal page links resolved correctly while stylesheets did not, and `assetPlan` stripped one for MIME sniffing but not for resolution. Fixed at both resolution primitives: - `resolveHref` (stylesheets, scripts, CSS @import, and page links via linkRewrite) - `resolveRelativePath` (CSS url(), node src/href/srcset) and removed the now-redundant strip in `linkRewrite`. Its fragment split stays — the fragment is re-attached to the page ref and is not addressing. This cannot affect the rewrite step. Node props are replaced by the resolved FileMap key and `rewriteProps` keys on that key, while CSS passes the original `rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes only which entry is found. Tests: a unit group on `resolveHref` covering query, fragment, both, and empty-after-strip, plus an assertion that a query does not change traversal handling; and an end-to-end group over `buildImportPlan` asserting that a cache-busted site produces a plan identical to the same site without queries. The end-to-end shape matters here — the unit returned a plausible-looking key and every consumer dutifully failed to find it, so the defect was only ever visible downstream. Verified the new tests fail without the source change (11 failures) and pass with it. bun run build, bun test (7067), bun run lint all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(site-import): drop the dead query strip in fontFormatForPath resolveRelativePath now strips the query before the FileMap lookup, so the only input this helper receives can no longer carry one. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(dashboard): onboarding step 03 follows the Home page being published (#624) Setup seeds one empty, unpublished Home page. The checklist step for it was "Create your first page", completed only when a second page existed, so a user who edited and published Home still saw "Not started" next to a Pages widget reporting 1 published. A one-page site could never complete the step at all. The step is now "Edit the Home page". It reads In progress from first boot and completes once the dashboard pages stats report at least one published page, which is the moment the site is actually on the frontend. The rules move into a pure deriveOnboardingFacts so they are unit-tested without the network; the hook feeds it the same pages stats endpoint the Pages widget already uses. Closes #223 * fix(data): author reassignment rides the row-access boundary (#625) PATCH /data/rows/:id/author read the target row with a bare getDataRow instead of loadRowForAccess, so a persona holding content.edit.any but not data.system.tables.read could read the full contents of any system-table row (unpublished pages and posts, author identity) and re-author it, while GET on the same row correctly answered 404. The handler now resolves the row through the same table-family plus ownership check every other row write uses. * fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626) * fix(ai): read tools and the MCP relay decide row access with the HTTP rules The content read tools (content_get_document, content_list_documents, content_search_documents) called the row repositories directly, so a caller holding ai.chat plus content.edit.own could read every other user's drafts and any system-table row through the tool surface, while GET /data/rows/:id for the same caller answered 404. The MCP relay carried its own copy of the ownership rule and skipped the table-family boundary altogether. One module now owns the decision. server/auth/dataAccess.ts holds the table-family and ownership predicates plus resolveRowAccess and rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay all call it with the identity behind the request. An architecture gate fails any direct row read under server/ai that bypasses it, because this class was fixed on the HTTP side once (GHSA-x69h) and came back through the next reader. * fix(data): drop the row read import the shared access helper made unused * fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627) * fix(mcp): derive OAuth discovery from the configured public origin, never the Host header Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host header, so an unauthenticated client could make the 401 challenge, both .well-known discovery documents, and the resource comparisons echo an attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes only from the configured public origin; without one, loopback requests use their own origin so local development keeps working, and every other request has no issuer: discovery and the token endpoint answer 503, the 401 challenge drops resource_metadata, and OAuth grants are not looked up while personal access tokens keep working. The server warns at boot when no public origin is configured. * test(mcp): assert discovery origins by parsed origin, not string prefix * chore(release): prepare 0.0.21 (#628) * fix(framework): set the root font size the rem scale assumes (#636) The framework converts every type and spacing token to rem against rootFontSize (10 in the Core preset), but nothing set html font-size, so at the 16px browser default every token rendered 1.6x larger than the scale says. A new emitRootFontSize preference makes the framework emit html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the published framework.css and the canvas iframes. The Core preset turns it on for new frameworks; existing sites have no stored value, which means off, so they keep rendering exactly as before. Site settings > Publishing gets a toggle to opt in. * fix(framework): make theme-inverted sections take the alt colors (#635) The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class. * feat(fonts): seed heading, body and mono font role tokens on new sites (#638) * feat(fonts): seed heading, body and mono font role tokens on new sites New sites had no font tokens and the codebase disagreed on names (docs said font-primary, the AI prompt said font-heading), so content made for one site did not resolve in another. Every brand-new site now starts with fallback-only font-heading, font-body and font-mono tokens, the Core preset adds any a site lacks without touching existing ones, and docs, schema comments and the AI prompt use the role names. Existing sites are not migrated. * fix(site-import): fill an untouched font role token instead of renaming Seeding font-heading, font-body and font-mono on new sites made an imported static site that defines one of those variables collide with the seed and get renamed to font-body-2, leaving the role token on the system stack. A role token that is still exactly as seeded is a placeholder, so the conflict now defaults to overwrite; a role token the user edited keeps the rename default. * docs(fonts): describe role tokens in terms of shared content The rationale now names imported pages, copied sections and AI output as the content the role variables serve. * test(framework): read the framework from the preset result in root font size tests applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property. * fix(import): replace import removes the previous site media (#637) A replace import wiped rows, folders and redirects but kept the old media_assets rows, so a second site shipping a file at the same storage path (two sites that embed the same font file) failed on media_assets.public_path after the data had already committed. Replace now deletes the library inside the import transaction and removes the bytes after commit, before the bundle's media is written. It only does so when the import carries media and the shell on main with no other branch, and it spares avatars and plugin-managed media, since those survive a replace. The archive endpoint now shares the JSON endpoint's apply path instead of a synthetic request, and the custom-table delete binds its boolean so replace runs on Postgres. * fix(editor): stop canvas chrome tokens shadowing site custom properties (#639) The editor-chrome stylesheet in the canvas iframe is unlayered and copied admin tokens such as --radius and --text onto :root under their own names. Unlayered beats the site's @layer user-authored, so a site defining those properties rendered with editor values in the canvas and its own values when published. Every forwarded token now lives under --chrome-*, derived from the var(--chrome-*) references in the chrome rules. * fix(ai): serialize ai_messages content before insert on Postgres (#592) On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain array to a text parameter via String(), storing '[object Object]'. Replaying that history yields empty user content and provider calls fail (Anthropic: 400 "user messages must have non-empty content"). JSON.stringify the content at the insert. SQLite passes strings through unchanged, and reads on both dialects JSON.parse `_json` strings, so behavior there is identical. Kept narrow on purpose: stringifying every object param in the PG adapter would turn values bound to jsonb columns into JSON string scalars and break jsonField() queries. Fixes #593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (#667) A CMS-native form whose target table has a select or multiSelect field could never be submitted. The base.option nodes the setup block inserts from the table carry option.value (the slug authors edit in the Data tab), but validateCoercedValue only accepted option.id, the generated nanoid authors never see. Every choice came back as invalid_option. Resolve the submitted string by option id or value while coercing the field and store the option id, which is the documented cell shape for these fields. The id-only checks in validateCoercedValue are removed since coercion now guarantees a known id. Fixes #629 * fix(publish): prune stored runtime assets of superseded page versions (#647) Every full publish stores each page's bundled runtime files in published_runtime_assets under a fresh version path, and nothing ever deleted them. On a busy production site the table reached 206k rows / 1.5 GB (696 MB written in one day), bloating the database and its backups for bytes that are only a fallback behind the disk-baked copies. persistSitePublish now deletes, per page, the stored assets of every version other than the one being activated and the one it supersedes. Nothing re-activates an old version, and keeping the previous one covers visitors still holding its HTML — the same window the disk's two-slot swap covers. Version rows are untouched. The first publish after upgrading drops the backlog for the published pages. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): resolve custom media fields on entry routes (#662) On a post-type entry route, `{currentEntry.<field>}` for a custom media field rendered the raw media id, while the same binding inside a data.rows loop rendered the /uploads/ path: the entry frame spread raw cells and only resolved the built-in featuredMedia. publishedDataRowToEntryItem now resolves the row's media cells with the same helpers the loop source uses, and every entry-stack caller (public entry route, branch preview, draft preview) goes through it. Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep custom properties with underscores or a leading --_ (#617) The property-name gate rejected any custom property containing `_` or starting with `--_`, so the importer and the publisher dropped them and every var() on them resolved to nothing. Custom properties are now checked against the spec's identifier rule; standard property names keep the stricter pattern and the denylist still applies. Custom property names also pass through toKebab unchanged, so `--brandColor` is no longer emitted as `--brand-color`. Fixes #616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618) Site Import turned root colour custom properties into framework tokens, which the framework emits under the normalised slug (`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported `var()` references on the authored name, so they no longer resolved. The slug is now settled at plan time by `normalizeImportedColorTokens`, and every changed name is rewritten in the imported style rules and node inline styles before conflict detection, so the conflict step and the commit see the emitted name. Two names that normalise to the same slug both survive with a numeric suffix instead of one being dropped at commit. Token renames (normalisation and conflict renames) now also rename declarations of the variable in other rules, their priorities, and raw @Keyframes CSS, so a `.dark { --bg: ... }` override follows its token. Conflict rename targets skip the import's other token names and any custom property the imported CSS declares, and a colour custom rename is normalised like the slug it becomes. The rename helpers move from conflicts.ts into siteImport/varRefs.ts, which keeps conflicts.ts under the 700-line module budget. Fixes #615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620) Site Import normalised a root `--font-*` variable at extraction (`--font-Heading` -> `font-heading`), and the store emits that form, but the imported `var(--font-Heading)` references kept the authored name and no longer resolved. Two authored names that normalise alike were deduped into one token, losing a font. Extraction now keeps the authored variable, and `normalizeImportedTokens` (new `siteImport/tokenNames.ts`) settles the emitted name of every imported colour and font token at plan time, in one pass against one set of claimed names, since both kinds share one CSS namespace. The `authored -> emitted` renames go through the existing var() and declaration rewrite, before conflict detection. `normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`. Fixes #619 * fix(publisher): keep safe images in published entry bodies (#664) * fix(publisher): keep safe images in published entry bodies Fixes #595 * fix(publisher): route content render paths through the image profile --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(loops): sort numeric row cells by value (#601) * fix(publisher): emit one merged rel on links and buttons instead of two (#570) base.link and base.button emitted the author's rel from the htmlAttributes bag and their own security rel as two separate attributes. Browsers keep the first, so a nofollow new-tab link published without noopener noreferrer, and the canvas showed a different rel than the page. The HTML importer also dropped every source rel on anchors as if the module regenerated it. The two anchor modules now merge the authored tokens with the security rel through one shared helper on both render paths, and the importer keeps rel. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644) The editor runs relayed MCP tools strictly one at a time, but the server pushed a whole batch at once. Every call's 90s timeout started at push time, so calls queued behind slow ones (render snapshots, a background tab) timed out before the editor reached them. The 120s idle lease was only re-armed by pushes, so a batch whose serial execution outlasted it closed the stream mid-batch: the in-flight call was rejected ("closed with 1 pending tool result(s)") and the editor's late result POST got a 404. - createBridge gains an opt-in `serial` option: the next request is emitted only after the previous one settles, and its timeout starts on emit. Queued calls still reject on abort/destroy without being emitted. The chat bridge is unchanged. - The editor bridge uses serial delivery, and its idle lease re-arms instead of closing while any call is pending. Each pending call is bounded by the per-call timeout, so an orphaned stream still drains and closes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(collab): preserve initial sync delivery under backpressure (#590) * fix(collab): preserve initial sync delivery under backpressure * fix(collab): packetize large stored document delivery * fix(server): answer conditional requests for static files and uploads with 304 (#653) * fix(server): answer conditional requests for static files and uploads with 304 serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin shell files but no ETag or Last-Modified, so the browser had nothing to revalidate with and re-downloaded every file in full. The editor preview iframe reloads every image and font on each rebuild: about 117 requests and 3.7 MB per preview load on one production site. Responses now carry a weak ETag (size + mtime) and Last-Modified, and If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match wins, per RFC 9110). A media replace that keeps the path changes the mtime, so the validator changes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ai): serialize ai_messages content before insert on Postgres (#592) On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain array to a text parameter via String(), storing '[object Object]'. Replaying that history yields empty user content and provider calls fail (Anthropic: 400 "user messages must have non-empty content"). JSON.stringify the content at the insert. SQLite passes strings through unchanged, and reads on both dialects JSON.parse `_json` strings, so behavior there is identical. Kept narrow on purpose: stringifying every object param in the PG adapter would turn values bound to jsonb columns into JSON string scalars and break jsonField() queries. Fixes #593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (#667) A CMS-native form whose target table has a select or multiSelect field could never be submitted. The base.option nodes the setup block inserts from the table carry option.value (the slug authors edit in the Data tab), but validateCoercedValue only accepted option.id, the generated nanoid authors never see. Every choice came back as invalid_option. Resolve the submitted string by option id or value while coercing the field and store the option id, which is the documented cell shape for these fields. The id-only checks in validateCoercedValue are removed since coercion now guarantees a known id. Fixes #629 * fix(publish): prune stored runtime assets of superseded page versions (#647) Every full publish stores each page's bundled runtime files in published_runtime_assets under a fresh version path, and nothing ever deleted them. On a busy production site the table reached 206k rows / 1.5 GB (696 MB written in one day), bloating the database and its backups for bytes that are only a fallback behind the disk-baked copies. persistSitePublish now deletes, per page, the stored assets of every version other than the one being activated and the one it supersedes. Nothing re-activates an old version, and keeping the previous one covers visitors still holding its HTML — the same window the disk's two-slot swap covers. Version rows are untouched. The first publish after upgrading drops the backlog for the published pages. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): resolve custom media fields on entry routes (#662) On a post-type entry route, `{currentEntry.<field>}` for a custom media field rendered the raw media id, while the same binding inside a data.rows loop rendered the /uploads/ path: the entry frame spread raw cells and only resolved the built-in featuredMedia. publishedDataRowToEntryItem now resolves the row's media cells with the same helpers the loop source uses, and every entry-stack caller (public entry route, branch preview, draft preview) goes through it. Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep custom properties with underscores or a leading --_ (#617) The property-name gate rejected any custom property containing `_` or starting with `--_`, so the importer and the publisher dropped them and every var() on them resolved to nothing. Custom properties are now checked against the spec's identifier rule; standard property names keep the stricter pattern and the denylist still applies. Custom property names also pass through toKebab unchanged, so `--brandColor` is no longer emitted as `--brand-color`. Fixes #616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618) Site Import turned root colour custom properties into framework tokens, which the framework emits under the normalised slug (`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported `var()` references on the authored name, so they no longer resolved. The slug is now settled at plan time by `normalizeImportedColorTokens`, and every changed name is rewritten in the imported style rules and node inline styles before conflict detection, so the conflict step and the commit see the emitted name. Two names that normalise to the same slug both survive with a numeric suffix instead of one being dropped at commit. Token renames (normalisation and conflict renames) now also rename declarations of the variable in other rules, their priorities, and raw @Keyframes CSS, so a `.dark { --bg: ... }` override follows its token. Conflict rename targets skip the import's other token names and any custom property the imported CSS declares, and a colour custom rename is normalised like the slug it becomes. The rename helpers move from conflicts.ts into siteImport/varRefs.ts, which keeps conflicts.ts under the 700-line module budget. Fixes #615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620) Site Import normalised a root `--font-*` variable at extraction (`--font-Heading` -> `font-heading`), and the store emits that form, but the imported `var(--font-Heading)` references kept the authored name and no longer resolved. Two authored names that normalise alike were deduped into one token, losing a font. Extraction now keeps the authored variable, and `normalizeImportedTokens` (new `siteImport/tokenNames.ts`) settles the emitted name of every imported colour and font token at plan time, in one pass against one set of claimed names, since both kinds share one CSS namespace. The `authored -> emitted` renames go through the existing var() and declaration rewrite, before conflict detection. `normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`. Fixes #619 * fix(publisher): keep safe images in published entry bodies (#664) * fix(publisher): keep safe images in published entry bodies Fixes #595 * fix(publisher): route content render paths through the image profile --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(loops): sort numeric row cells by value (#601) * fix(publisher): emit one merged rel on links and buttons instead of two (#570) base.link and base.button emitted the author's rel from the htmlAttributes bag and their own security rel as two separate attributes. Browsers keep the first, so a nofollow new-tab link published without noopener noreferrer, and the canvas showed a different rel than the page. The HTML importer also dropped every source rel on anchors as if the module regenerated it. The two anchor modules now merge the authored tokens with the security rel through one shared helper on both render paths, and the importer keeps rel. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644) The editor runs relayed MCP tools strictly one at a time, but the server pushed a whole batch at once. Every call's 90s timeout started at push time, so calls queued behind slow ones (render snapshots, a background tab) timed out before the editor reached them. The 120s idle lease was only re-armed by pushes, so a batch whose serial execution outlasted it closed the stream mid-batch: the in-flight call was rejected ("closed with 1 pending tool result(s)") and the editor's late result POST got a 404. - createBridge gains an opt-in `serial` option: the next request is emitted only after the previous one settles, and its timeout starts on emit. Queued calls still reject on abort/destroy without being emitted. The chat bridge is unchanged. - The editor bridge uses serial delivery, and its idle lease re-arms instead of closing while any call is pending. Each pending call is bounded by the per-call timeout, so an orphaned stream still drains and closes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(collab): preserve initial sync delivery under backpressure (#590) * fix(collab): preserve initial sync delivery under backpressure * fix(collab): packetize large stored document delivery * fix(server): preserve upload MIME when revalidating cached files --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com> Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com> Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com> Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com> Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com> Co-authored-by: tommy230 <tommy@godigitalalchemy.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646) Each runtime preview build POSTs the whole site document and the server re-validates it, re-bundles the page scripts and renders the page. The Preview overlay and the Site Explorer hover card rebuilt on every `site` change, so typing or a collaborator's edits aborted the in-flight build and started another — production logs show bursts of 8 cancelled requests within two seconds — and the preview blanked to "Building…" each time. - useRuntimePreviewDocument builds from the draft only once it has stayed unchanged for 400ms (PREVIEW_SITE_SETTLE_MS). - While a newer build runs, `html` keeps the previous build for the same page and context; both callers show the loading state only when there is nothing to show yet. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572) The shaker read every registry class in a selector as a requirement, including classes inside :not() and each alternative of :is()/:where(). A negated class is the opposite of a requirement and :is() alternatives are each sufficient, so rules like `.nav > li > a:not(.cart-button)` were dropped whenever no node carried the negated class. selectorPartCanMatch now splits a part into the text whose classes are all required and the alternation groups where one alternative is enough; :has() and :nth-child(n of ...) stay required. For selectors the shared lexer reads correctly, drops are a subset of before. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep style and class injection out of data-style and data-class (#561) The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches after the hyphen in data-style and data-class. Node styles or classes were merged into the data attribute and the real attribute was never emitted. Both regexes now require whitespace before the attribute name, and the replacements use a function so $ sequences in values stay inert. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): repair invalid props one by one instead of resetting the node (#574) When Value.Parse rejected a node's props, validateNodeProps replaced every declared prop with the module default, so a link with one unsupported target published as "Click here" pointing at "#" while its href and text sat valid in the store. The catch branch now parses each declared prop on its own and falls back to the default only for the props that still fail. Fast path, successful slow path, optional props and injected keys are unchanged. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): close CSS strings after a trailing escape --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: MelmacAlpha <github@201620.de> Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com> Co-authored-by: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> Co-authored-by: Rick Caves <spcxai2025@gmail.com> Co-authored-by: David Babinec <49069339+DavidBabinec@users.noreply.github.com> Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com> Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com> Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com> Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com> Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com> Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
DavidBabinec
added a commit
that referenced
this pull request
Oct 27, 2026
… non-secure origins (#576) * fix(agent): hash code assets without Web Crypto so AI tools work over non-secure origins The runtime code-asset tools hashed content with `crypto.subtle.digest`, which is only defined in a secure context (HTTPS or localhost). When the CMS is reached over a LAN/Tailscale IP on plain http:// — a non-secure context — `crypto.subtle` is undefined, so the first code-asset tool call threw "crypto.subtle is undefined" and the agent hung. Add a dependency-free `sha256Hex` (src/core/utils/sha256.ts) that produces the identical 64-char digest in any context, and use it for the read→patch optimistic-concurrency fingerprint. The hash is a self-consistent, client-only fingerprint — never cryptographic-security-bearing — so a pure-JS SHA-256 is a drop-in. New test verifies it byte-for-byte against native WebCrypto across UTF-8 and all padding boundaries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 78dbb7a63251bca132bb5ed58f0b15e179817acf) * fix(editor): apply classes to all selected elements (#511) Assisted-by: OpenAI Codex Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com> * fix(settings): make the Esc keycap close the modal it advertises (#499) * fix(settings): make the Esc keycap close the modal it advertises The footer reads `[Esc] close`, and the keycap looks like a button because every `Kbd` depresses on `:active`. Clicking it played that press animation and did nothing; the pointer over the word "close" turned into a text caret and a click just selected the text. An operator reasonably concluded the modal could not be dismissed with the mouse. It could — the backdrop has always closed it, and Escape works — but a control that animates under the cursor and then ignores the click is worse than no control, because it answers the question wrongly. So the hint becomes the button it already looked like. `Kbd` itself is untouched: six other surfaces render it as a genuine keyboard hint where the press styling is right, and only this one sits next to the word "close". The file header claimed there was no dedicated close button. There is one now, so it says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(settings): let the Button primitive own the Esc keycap's alignment and width --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * feat(plugins): @own-created contentAccess marker for plugin-created tables (#335) * feat(plugins): @own-created contentAccess marker for plugin-created tables Plugins could create tables via cms.content.tables.create but never read or write their entries: the static contentAccess[] allowlist matches by exact slug, and a runtime-chosen slug cannot be pre-declared. This made importer/migration plugins impossible on the sanctioned surface. - Migration 025 (both dialects, additive): data_tables.created_by_plugin_id, stamped by the create handler with the host-authenticated worker identity. - New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers every table the declaring plugin created, resolved against the stored creator (never by slug), with the entry's declared modes; entries combine as a union. Marker modes go through the same install-time modes<->permission coherence check as slug entries. - assertContentTableAccess now takes the resolved DataTable (handlers resolve-then-assert; tree callbacks already had the table). tables.list / search filter through the same matcher, so own-created tables appear. - tables.create slug constrained to kebab-case, reserving the @ namespace; bundle export/import preserves createdByPluginId. - Docs, capability descriptions, SDK comments updated; stale "consent screen renders contentAccess verbatim" claim corrected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(branches): a fork keeps which plugin created a table Forking a branch copies `data_tables` with an explicit column list, and `created_by_plugin_id` was not in it. The branch's copy of a plugin-created table came out with no creator — so the `@own-created` contentAccess marker no longer matched it, and the table stopped belonging to the plugin that made it. Nothing reported it; the column was simply null on the copy. It is ownership, not content, so it does not belong in the merge diff — it belongs in the copy, beside `created_by_user_id`, which was already there. The test fails without the change (`Received: null`) and passes with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(db): move the migration to 033 — 031 and 032 are claimed #495 claims `031_installed_plugins_source` and #505 claims `032`, so this ALTER moves to `033` before either lands. The runner keys on the full id, so two `031_…` entries would both run — but a shared number still makes whoever merges second renumber, and an ALTER can only be renumbered while no installation has applied it. That is true today, so it moves now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(db): number the created_by_plugin_id migration 031, next after 030 on main --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(site-import): resolve cache-busted asset URLs (#539) * fix(site-import): resolve cache-busted asset URLs A query string or fragment was never stripped before looking a path up in the FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The reference was reported as `missing-stylesheet` / `missing-script` while the file sat in the archive, and every style rule, colour token and font in that sheet was silently dropped from the import. Static site generators commonly emit a content hash on stylesheet, script and media URLs, so this affects a whole class of input. Measured on one real 6-page export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the byte-identical site without the queries — a 39% and 31% loss, reported only as warnings a user can click past. The handling was inconsistent rather than absent, which is why it survived: `linkRewrite` stripped the query itself before calling `resolveHref`, so internal page links resolved correctly while stylesheets did not, and `assetPlan` stripped one for MIME sniffing but not for resolution. Fixed at both resolution primitives: - `resolveHref` (stylesheets, scripts, CSS @import, and page links via linkRewrite) - `resolveRelativePath` (CSS url(), node src/href/srcset) and removed the now-redundant strip in `linkRewrite`. Its fragment split stays — the fragment is re-attached to the page ref and is not addressing. This cannot affect the rewrite step. Node props are replaced by the resolved FileMap key and `rewriteProps` keys on that key, while CSS passes the original `rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes only which entry is found. Tests: a unit group on `resolveHref` covering query, fragment, both, and empty-after-strip, plus an assertion that a query does not change traversal handling; and an end-to-end group over `buildImportPlan` asserting that a cache-busted site produces a plan identical to the same site without queries. The end-to-end shape matters here — the unit returned a plausible-looking key and every consumer dutifully failed to find it, so the defect was only ever visible downstream. Verified the new tests fail without the source change (11 failures) and pass with it. bun run build, bun test (7067), bun run lint all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(site-import): drop the dead query strip in fontFormatForPath resolveRelativePath now strips the query before the FileMap lookup, so the only input this helper receives can no longer carry one. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(dashboard): onboarding step 03 follows the Home page being published (#624) Setup seeds one empty, unpublished Home page. The checklist step for it was "Create your first page", completed only when a second page existed, so a user who edited and published Home still saw "Not started" next to a Pages widget reporting 1 published. A one-page site could never complete the step at all. The step is now "Edit the Home page". It reads In progress from first boot and completes once the dashboard pages stats report at least one published page, which is the moment the site is actually on the frontend. The rules move into a pure deriveOnboardingFacts so they are unit-tested without the network; the hook feeds it the same pages stats endpoint the Pages widget already uses. Closes #223 * fix(data): author reassignment rides the row-access boundary (#625) PATCH /data/rows/:id/author read the target row with a bare getDataRow instead of loadRowForAccess, so a persona holding content.edit.any but not data.system.tables.read could read the full contents of any system-table row (unpublished pages and posts, author identity) and re-author it, while GET on the same row correctly answered 404. The handler now resolves the row through the same table-family plus ownership check every other row write uses. * fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626) * fix(ai): read tools and the MCP relay decide row access with the HTTP rules The content read tools (content_get_document, content_list_documents, content_search_documents) called the row repositories directly, so a caller holding ai.chat plus content.edit.own could read every other user's drafts and any system-table row through the tool surface, while GET /data/rows/:id for the same caller answered 404. The MCP relay carried its own copy of the ownership rule and skipped the table-family boundary altogether. One module now owns the decision. server/auth/dataAccess.ts holds the table-family and ownership predicates plus resolveRowAccess and rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay all call it with the identity behind the request. An architecture gate fails any direct row read under server/ai that bypasses it, because this class was fixed on the HTTP side once (GHSA-x69h) and came back through the next reader. * fix(data): drop the row read import the shared access helper made unused * fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627) * fix(mcp): derive OAuth discovery from the configured public origin, never the Host header Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host header, so an unauthenticated client could make the 401 challenge, both .well-known discovery documents, and the resource comparisons echo an attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes only from the configured public origin; without one, loopback requests use their own origin so local development keeps working, and every other request has no issuer: discovery and the token endpoint answer 503, the 401 challenge drops resource_metadata, and OAuth grants are not looked up while personal access tokens keep working. The server warns at boot when no public origin is configured. * test(mcp): assert discovery origins by parsed origin, not string prefix * chore(release): prepare 0.0.21 (#628) * fix(framework): set the root font size the rem scale assumes (#636) The framework converts every type and spacing token to rem against rootFontSize (10 in the Core preset), but nothing set html font-size, so at the 16px browser default every token rendered 1.6x larger than the scale says. A new emitRootFontSize preference makes the framework emit html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the published framework.css and the canvas iframes. The Core preset turns it on for new frameworks; existing sites have no stored value, which means off, so they keep rendering exactly as before. Site settings > Publishing gets a toggle to opt in. * fix(framework): make theme-inverted sections take the alt colors (#635) The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class. * feat(fonts): seed heading, body and mono font role tokens on new sites (#638) * feat(fonts): seed heading, body and mono font role tokens on new sites New sites had no font tokens and the codebase disagreed on names (docs said font-primary, the AI prompt said font-heading), so content made for one site did not resolve in another. Every brand-new site now starts with fallback-only font-heading, font-body and font-mono tokens, the Core preset adds any a site lacks without touching existing ones, and docs, schema comments and the AI prompt use the role names. Existing sites are not migrated. * fix(site-import): fill an untouched font role token instead of renaming Seeding font-heading, font-body and font-mono on new sites made an imported static site that defines one of those variables collide with the seed and get renamed to font-body-2, leaving the role token on the system stack. A role token that is still exactly as seeded is a placeholder, so the conflict now defaults to overwrite; a role token the user edited keeps the rename default. * docs(fonts): describe role tokens in terms of shared content The rationale now names imported pages, copied sections and AI output as the content the role variables serve. * test(framework): read the framework from the preset result in root font size tests applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property. * fix(import): replace import removes the previous site media (#637) A replace import wiped rows, folders and redirects but kept the old media_assets rows, so a second site shipping a file at the same storage path (two sites that embed the same font file) failed on media_assets.public_path after the data had already committed. Replace now deletes the library inside the import transaction and removes the bytes after commit, before the bundle's media is written. It only does so when the import carries media and the shell on main with no other branch, and it spares avatars and plugin-managed media, since those survive a replace. The archive endpoint now shares the JSON endpoint's apply path instead of a synthetic request, and the custom-table delete binds its boolean so replace runs on Postgres. * fix(editor): stop canvas chrome tokens shadowing site custom properties (#639) The editor-chrome stylesheet in the canvas iframe is unlayered and copied admin tokens such as --radius and --text onto :root under their own names. Unlayered beats the site's @layer user-authored, so a site defining those properties rendered with editor values in the canvas and its own values when published. Every forwarded token now lives under --chrome-*, derived from the var(--chrome-*) references in the chrome rules. * fix(ai): serialize ai_messages content before insert on Postgres (#592) On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain array to a text parameter via String(), storing '[object Object]'. Replaying that history yields empty user content and provider calls fail (Anthropic: 400 "user messages must have non-empty content"). JSON.stringify the content at the insert. SQLite passes strings through unchanged, and reads on both dialects JSON.parse `_json` strings, so behavior there is identical. Kept narrow on purpose: stringifying every object param in the PG adapter would turn values bound to jsonb columns into JSON string scalars and break jsonField() queries. Fixes #593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (#667) A CMS-native form whose target table has a select or multiSelect field could never be submitted. The base.option nodes the setup block inserts from the table carry option.value (the slug authors edit in the Data tab), but validateCoercedValue only accepted option.id, the generated nanoid authors never see. Every choice came back as invalid_option. Resolve the submitted string by option id or value while coercing the field and store the option id, which is the documented cell shape for these fields. The id-only checks in validateCoercedValue are removed since coercion now guarantees a known id. Fixes #629 * fix(publish): prune stored runtime assets of superseded page versions (#647) Every full publish stores each page's bundled runtime files in published_runtime_assets under a fresh version path, and nothing ever deleted them. On a busy production site the table reached 206k rows / 1.5 GB (696 MB written in one day), bloating the database and its backups for bytes that are only a fallback behind the disk-baked copies. persistSitePublish now deletes, per page, the stored assets of every version other than the one being activated and the one it supersedes. Nothing re-activates an old version, and keeping the previous one covers visitors still holding its HTML — the same window the disk's two-slot swap covers. Version rows are untouched. The first publish after upgrading drops the backlog for the published pages. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): resolve custom media fields on entry routes (#662) On a post-type entry route, `{currentEntry.<field>}` for a custom media field rendered the raw media id, while the same binding inside a data.rows loop rendered the /uploads/ path: the entry frame spread raw cells and only resolved the built-in featuredMedia. publishedDataRowToEntryItem now resolves the row's media cells with the same helpers the loop source uses, and every entry-stack caller (public entry route, branch preview, draft preview) goes through it. Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep custom properties with underscores or a leading --_ (#617) The property-name gate rejected any custom property containing `_` or starting with `--_`, so the importer and the publisher dropped them and every var() on them resolved to nothing. Custom properties are now checked against the spec's identifier rule; standard property names keep the stricter pattern and the denylist still applies. Custom property names also pass through toKebab unchanged, so `--brandColor` is no longer emitted as `--brand-color`. Fixes #616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618) Site Import turned root colour custom properties into framework tokens, which the framework emits under the normalised slug (`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported `var()` references on the authored name, so they no longer resolved. The slug is now settled at plan time by `normalizeImportedColorTokens`, and every changed name is rewritten in the imported style rules and node inline styles before conflict detection, so the conflict step and the commit see the emitted name. Two names that normalise to the same slug both survive with a numeric suffix instead of one being dropped at commit. Token renames (normalisation and conflict renames) now also rename declarations of the variable in other rules, their priorities, and raw @Keyframes CSS, so a `.dark { --bg: ... }` override follows its token. Conflict rename targets skip the import's other token names and any custom property the imported CSS declares, and a colour custom rename is normalised like the slug it becomes. The rename helpers move from conflicts.ts into siteImport/varRefs.ts, which keeps conflicts.ts under the 700-line module budget. Fixes #615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620) Site Import normalised a root `--font-*` variable at extraction (`--font-Heading` -> `font-heading`), and the store emits that form, but the imported `var(--font-Heading)` references kept the authored name and no longer resolved. Two authored names that normalise alike were deduped into one token, losing a font. Extraction now keeps the authored variable, and `normalizeImportedTokens` (new `siteImport/tokenNames.ts`) settles the emitted name of every imported colour and font token at plan time, in one pass against one set of claimed names, since both kinds share one CSS namespace. The `authored -> emitted` renames go through the existing var() and declaration rewrite, before conflict detection. `normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`. Fixes #619 * fix(publisher): keep safe images in published entry bodies (#664) * fix(publisher): keep safe images in published entry bodies Fixes #595 * fix(publisher): route content render paths through the image profile --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(loops): sort numeric row cells by value (#601) * fix(publisher): emit one merged rel on links and buttons instead of two (#570) base.link and base.button emitted the author's rel from the htmlAttributes bag and their own security rel as two separate attributes. Browsers keep the first, so a nofollow new-tab link published without noopener noreferrer, and the canvas showed a different rel than the page. The HTML importer also dropped every source rel on anchors as if the module regenerated it. The two anchor modules now merge the authored tokens with the security rel through one shared helper on both render paths, and the importer keeps rel. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644) The editor runs relayed MCP tools strictly one at a time, but the server pushed a whole batch at once. Every call's 90s timeout started at push time, so calls queued behind slow ones (render snapshots, a background tab) timed out before the editor reached them. The 120s idle lease was only re-armed by pushes, so a batch whose serial execution outlasted it closed the stream mid-batch: the in-flight call was rejected ("closed with 1 pending tool result(s)") and the editor's late result POST got a 404. - createBridge gains an opt-in `serial` option: the next request is emitted only after the previous one settles, and its timeout starts on emit. Queued calls still reject on abort/destroy without being emitted. The chat bridge is unchanged. - The editor bridge uses serial delivery, and its idle lease re-arms instead of closing while any call is pending. Each pending call is bounded by the per-call timeout, so an orphaned stream still drains and closes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(collab): preserve initial sync delivery under backpressure (#590) * fix(collab): preserve initial sync delivery under backpressure * fix(collab): packetize large stored document delivery * fix(server): answer conditional requests for static files and uploads with 304 (#653) * fix(server): answer conditional requests for static files and uploads with 304 serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin shell files but no ETag or Last-Modified, so the browser had nothing to revalidate with and re-downloaded every file in full. The editor preview iframe reloads every image and font on each rebuild: about 117 requests and 3.7 MB per preview load on one production site. Responses now carry a weak ETag (size + mtime) and Last-Modified, and If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match wins, per RFC 9110). A media replace that keeps the path changes the mtime, so the validator changes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ai): serialize ai_messages content before insert on Postgres (#592) On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain array to a text parameter via String(), storing '[object Object]'. Replaying that history yields empty user content and provider calls fail (Anthropic: 400 "user messages must have non-empty content"). JSON.stringify the content at the insert. SQLite passes strings through unchanged, and reads on both dialects JSON.parse `_json` strings, so behavior there is identical. Kept narrow on purpose: stringifying every object param in the PG adapter would turn values bound to jsonb columns into JSON string scalars and break jsonField() queries. Fixes #593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (#667) A CMS-native form whose target table has a select or multiSelect field could never be submitted. The base.option nodes the setup block inserts from the table carry option.value (the slug authors edit in the Data tab), but validateCoercedValue only accepted option.id, the generated nanoid authors never see. Every choice came back as invalid_option. Resolve the submitted string by option id or value while coercing the field and store the option id, which is the documented cell shape for these fields. The id-only checks in validateCoercedValue are removed since coercion now guarantees a known id. Fixes #629 * fix(publish): prune stored runtime assets of superseded page versions (#647) Every full publish stores each page's bundled runtime files in published_runtime_assets under a fresh version path, and nothing ever deleted them. On a busy production site the table reached 206k rows / 1.5 GB (696 MB written in one day), bloating the database and its backups for bytes that are only a fallback behind the disk-baked copies. persistSitePublish now deletes, per page, the stored assets of every version other than the one being activated and the one it supersedes. Nothing re-activates an old version, and keeping the previous one covers visitors still holding its HTML — the same window the disk's two-slot swap covers. Version rows are untouched. The first publish after upgrading drops the backlog for the published pages. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): resolve custom media fields on entry routes (#662) On a post-type entry route, `{currentEntry.<field>}` for a custom media field rendered the raw media id, while the same binding inside a data.rows loop rendered the /uploads/ path: the entry frame spread raw cells and only resolved the built-in featuredMedia. publishedDataRowToEntryItem now resolves the row's media cells with the same helpers the loop source uses, and every entry-stack caller (public entry route, branch preview, draft preview) goes through it. Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep custom properties with underscores or a leading --_ (#617) The property-name gate rejected any custom property containing `_` or starting with `--_`, so the importer and the publisher dropped them and every var() on them resolved to nothing. Custom properties are now checked against the spec's identifier rule; standard property names keep the stricter pattern and the denylist still applies. Custom property names also pass through toKebab unchanged, so `--brandColor` is no longer emitted as `--brand-color`. Fixes #616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618) Site Import turned root colour custom properties into framework tokens, which the framework emits under the normalised slug (`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported `var()` references on the authored name, so they no longer resolved. The slug is now settled at plan time by `normalizeImportedColorTokens`, and every changed name is rewritten in the imported style rules and node inline styles before conflict detection, so the conflict step and the commit see the emitted name. Two names that normalise to the same slug both survive with a numeric suffix instead of one being dropped at commit. Token renames (normalisation and conflict renames) now also rename declarations of the variable in other rules, their priorities, and raw @Keyframes CSS, so a `.dark { --bg: ... }` override follows its token. Conflict rename targets skip the import's other token names and any custom property the imported CSS declares, and a colour custom rename is normalised like the slug it becomes. The rename helpers move from conflicts.ts into siteImport/varRefs.ts, which keeps conflicts.ts under the 700-line module budget. Fixes #615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620) Site Import normalised a root `--font-*` variable at extraction (`--font-Heading` -> `font-heading`), and the store emits that form, but the imported `var(--font-Heading)` references kept the authored name and no longer resolved. Two authored names that normalise alike were deduped into one token, losing a font. Extraction now keeps the authored variable, and `normalizeImportedTokens` (new `siteImport/tokenNames.ts`) settles the emitted name of every imported colour and font token at plan time, in one pass against one set of claimed names, since both kinds share one CSS namespace. The `authored -> emitted` renames go through the existing var() and declaration rewrite, before conflict detection. `normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`. Fixes #619 * fix(publisher): keep safe images in published entry bodies (#664) * fix(publisher): keep safe images in published entry bodies Fixes #595 * fix(publisher): route content render paths through the image profile --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(loops): sort numeric row cells by value (#601) * fix(publisher): emit one merged rel on links and buttons instead of two (#570) base.link and base.button emitted the author's rel from the htmlAttributes bag and their own security rel as two separate attributes. Browsers keep the first, so a nofollow new-tab link published without noopener noreferrer, and the canvas showed a different rel than the page. The HTML importer also dropped every source rel on anchors as if the module regenerated it. The two anchor modules now merge the authored tokens with the security rel through one shared helper on both render paths, and the importer keeps rel. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644) The editor runs relayed MCP tools strictly one at a time, but the server pushed a whole batch at once. Every call's 90s timeout started at push time, so calls queued behind slow ones (render snapshots, a background tab) timed out before the editor reached them. The 120s idle lease was only re-armed by pushes, so a batch whose serial execution outlasted it closed the stream mid-batch: the in-flight call was rejected ("closed with 1 pending tool result(s)") and the editor's late result POST got a 404. - createBridge gains an opt-in `serial` option: the next request is emitted only after the previous one settles, and its timeout starts on emit. Queued calls still reject on abort/destroy without being emitted. The chat bridge is unchanged. - The editor bridge uses serial delivery, and its idle lease re-arms instead of closing while any call is pending. Each pending call is bounded by the per-call timeout, so an orphaned stream still drains and closes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(collab): preserve initial sync delivery under backpressure (#590) * fix(collab): preserve initial sync delivery under backpressure * fix(collab): packetize large stored document delivery * fix(server): preserve upload MIME when revalidating cached files --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com> Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com> Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com> Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com> Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com> Co-authored-by: tommy230 <tommy@godigitalalchemy.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> * fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646) Each runtime preview build POSTs the whole site document and the server re-validates it, re-bundles the page scripts and renders the page. The Preview overlay and the Site Explorer hover card rebuilt on every `site` change, so typing or a collaborator's edits aborted the in-flight build and started another — production logs show bursts of 8 cancelled requests within two seconds — and the preview blanked to "Building…" each time. - useRuntimePreviewDocument builds from the draft only once it has stayed unchanged for 400ms (PREVIEW_SITE_SETTLE_MS). - While a newer build runs, `html` keeps the previous build for the same page and context; both callers show the loading state only when there is nothing to show yet. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572) The shaker read every registry class in a selector as a requirement, including classes inside :not() and each alternative of :is()/:where(). A negated class is the opposite of a requirement and :is() alternatives are each sufficient, so rules like `.nav > li > a:not(.cart-button)` were dropped whenever no node carried the negated class. selectorPartCanMatch now splits a part into the text whose classes are all required and the alternation groups where one alternative is enough; :has() and :nth-child(n of ...) stay required. For selectors the shared lexer reads correctly, drops are a subset of before. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): keep style and class injection out of data-style and data-class (#561) The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches after the hyphen in data-style and data-class. Node styles or classes were merged into the data attribute and the real attribute was never emitted. Both regexes now require whitespace before the attribute name, and the replacements use a function so $ sequences in values stay inert. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(publisher): repair invalid props one by one instead of resetting the node (#574) When Value.Parse rejected a node's props, validateNodeProps replaced every declared prop with the module default, so a link with one unsupported target published as "Click here" pointing at "#" while its href and text sat valid in the store. The catch branch now parses each declared prop on its own and falls back to the default only for the props that still fail. Fast path, successful slow path, optional props and injected keys are unchanged. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: MelmacAlpha <github@201620.de> Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com> Co-authored-by: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com> Co-authored-by: Rick Caves <spcxai2025@gmail.com> Co-authored-by: David Babinec <49069339+DavidBabinec@users.noreply.github.com> Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com> Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com> Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com> Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com> Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com> Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com> Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com> Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
DavidBabinec
added a commit
that referenced
this pull request
Oct 27, 2026
* fix(import): keep the content a compound submit button wraps
base.submit was a leaf whose only content was the label string, with a
'Submit' fallback for buttons with no text. Submit buttons that carry an
icon instead of text (an inline svg, an icon-font <i>, an <img>) lost the
icon on import and published as the literal word "Submit".
base.submit now accepts children and renders them when it has any, falling
back to the label when it has none (the base.link contract, shared between
the publisher render and the canvas via forms/content.ts). The importer
recurses into submit buttons with element children instead of flattening
them to a label.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(editor): apply classes to all selected elements (#511)
Assisted-by: OpenAI Codex
Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>
* fix(settings): make the Esc keycap close the modal it advertises (#499)
* fix(settings): make the Esc keycap close the modal it advertises
The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.
It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.
So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".
The file header claimed there was no dedicated close button. There is one
now, so it says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(settings): let the Button primitive own the Esc keycap's alignment and width
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)
* feat(plugins): @own-created contentAccess marker for plugin-created tables
Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.
- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
every table the declaring plugin created, resolved against the stored
creator (never by slug), with the entry's declared modes; entries combine
as a union. Marker modes go through the same install-time modes<->permission
coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
resolve-then-assert; tree callbacks already had the table). tables.list /
search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
renders contentAccess verbatim" claim corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(branches): a fork keeps which plugin created a table
Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.
It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.
The test fails without the change (`Received: null`) and passes with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(db): move the migration to 033 — 031 and 032 are claimed
#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(db): number the created_by_plugin_id migration 031, next after 030 on main
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(site-import): resolve cache-busted asset URLs (#539)
* fix(site-import): resolve cache-busted asset URLs
A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.
Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.
The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.
Fixed at both resolution primitives:
- `resolveHref` (stylesheets, scripts, CSS @import, and page links via
linkRewrite)
- `resolveRelativePath` (CSS url(), node src/href/srcset)
and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.
This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.
Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.
bun run build, bun test (7067), bun run lint all pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(site-import): drop the dead query strip in fontFormatForPath
resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(dashboard): onboarding step 03 follows the Home page being published (#624)
Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed only when a second page existed,
so a user who edited and published Home still saw "Not started" next to
a Pages widget reporting 1 published. A one-page site could never
complete the step at all.
The step is now "Edit the Home page". It reads In progress from first
boot and completes once the dashboard pages stats report at least one
published page, which is the moment the site is actually on the
frontend. The rules move into a pure deriveOnboardingFacts so they are
unit-tested without the network; the hook feeds it the same pages stats
endpoint the Pages widget already uses.
Closes #223
* fix(data): author reassignment rides the row-access boundary (#625)
PATCH /data/rows/:id/author read the target row with a bare getDataRow
instead of loadRowForAccess, so a persona holding content.edit.any but
not data.system.tables.read could read the full contents of any
system-table row (unpublished pages and posts, author identity) and
re-author it, while GET on the same row correctly answered 404. The
handler now resolves the row through the same table-family plus
ownership check every other row write uses.
* fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626)
* fix(ai): read tools and the MCP relay decide row access with the HTTP rules
The content read tools (content_get_document, content_list_documents,
content_search_documents) called the row repositories directly, so a
caller holding ai.chat plus content.edit.own could read every other
user's drafts and any system-table row through the tool surface, while
GET /data/rows/:id for the same caller answered 404. The MCP relay
carried its own copy of the ownership rule and skipped the table-family
boundary altogether.
One module now owns the decision. server/auth/dataAccess.ts holds the
table-family and ownership predicates plus resolveRowAccess and
rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay
all call it with the identity behind the request. An architecture gate
fails any direct row read under server/ai that bypasses it, because
this class was fixed on the HTTP side once (GHSA-x69h) and came back
through the next reader.
* fix(data): drop the row read import the shared access helper made unused
* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627)
* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header
Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host
header, so an unauthenticated client could make the 401 challenge, both
.well-known discovery documents, and the resource comparisons echo an
attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes
only from the configured public origin; without one, loopback requests
use their own origin so local development keeps working, and every other
request has no issuer: discovery and the token endpoint answer 503, the
401 challenge drops resource_metadata, and OAuth grants are not looked
up while personal access tokens keep working. The server warns at boot
when no public origin is configured.
* test(mcp): assert discovery origins by parsed origin, not string prefix
* chore(release): prepare 0.0.21 (#628)
* fix(framework): set the root font size the rem scale assumes (#636)
The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.
* fix(framework): make theme-inverted sections take the alt colors (#635)
The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.
* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)
* feat(fonts): seed heading, body and mono font role tokens on new sites
New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.
* fix(site-import): fill an untouched font role token instead of renaming
Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.
* docs(fonts): describe role tokens in terms of shared content
The rationale now names imported pages, copied sections and AI output as the content the role variables serve.
* test(framework): read the framework from the preset result in root font size tests
applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.
* fix(import): replace import removes the previous site media (#637)
A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.
* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)
The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): answer conditional requests for static files and uploads with 304 (#653)
* fix(server): answer conditional requests for static files and uploads with 304
serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.
Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): preserve upload MIME when revalidating cached files
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)
Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.
- useRuntimePreviewDocument builds from the draft only once it has stayed
unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
and context; both callers show the loading state only when there is
nothing to show yet.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)
The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.
selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep style and class injection out of data-style and data-class (#561)
The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): repair invalid props one by one instead of resetting the node (#574)
When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(site-import): upload and rewrite imported video sources and posters (#661)
* fix(site-import): upload and re-point imported video sources
The HTML importer stores a <video>/<source> URL in base.video's
`videoUrl` prop, but the site-import asset pipeline only normalised and
rewrote `src`, `href` and `srcset`. Imported videos kept their relative
path and the file was never uploaded.
Add `videoUrl` to the URL-bearing props and make assetPlan.ts the single
owner of that list so applyAssetRewrites can no longer drift from it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): answer conditional requests for static files and uploads with 304 (#653)
* fix(server): answer conditional requests for static files and uploads with 304
serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.
Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): preserve upload MIME when revalidating cached files
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)
Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.
- useRuntimePreviewDocument builds from the draft only once it has stayed
unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
and context; both callers show the loading state only when there is
nothing to show yet.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)
The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.
selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep style and class injection out of data-style and data-class (#561)
The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): repair invalid props one by one instead of resetting the node (#574)
When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(import): retain and rewrite native video posters
* docs(import): group video mapping separately from anchor rules
---------
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(import): map unsupported anchor targets to _self or _blank (#575)
* fix(import): map unsupported anchor targets to _self or _blank
The anchor import rules copied target="" (and _top or a named frame)
straight into the node. None of those values is in AnchorTargetSchema, so
the publisher's fallback replaced the whole link with module defaults and
the page shipped "Click here" pointing at "#". The schema now lives in
@core/htmlAttributes with a normalizeAnchorTarget() helper the importer
uses for both anchor rules: empty and _top import as _self, a named target
as _blank, keywords match case-insensitively, and href and text are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(editor): apply classes to all selected elements (#511)
Assisted-by: OpenAI Codex
Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>
* fix(settings): make the Esc keycap close the modal it advertises (#499)
* fix(settings): make the Esc keycap close the modal it advertises
The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.
It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.
So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".
The file header claimed there was no dedicated close button. There is one
now, so it says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(settings): let the Button primitive own the Esc keycap's alignment and width
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)
* feat(plugins): @own-created contentAccess marker for plugin-created tables
Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.
- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
every table the declaring plugin created, resolved against the stored
creator (never by slug), with the entry's declared modes; entries combine
as a union. Marker modes go through the same install-time modes<->permission
coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
resolve-then-assert; tree callbacks already had the table). tables.list /
search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
renders contentAccess verbatim" claim corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(branches): a fork keeps which plugin created a table
Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.
It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.
The test fails without the change (`Received: null`) and passes with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(db): move the migration to 033 — 031 and 032 are claimed
#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(db): number the created_by_plugin_id migration 031, next after 030 on main
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(site-import): resolve cache-busted asset URLs (#539)
* fix(site-import): resolve cache-busted asset URLs
A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.
Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.
The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.
Fixed at both resolution primitives:
- `resolveHref` (stylesheets, scripts, CSS @import, and page links via
linkRewrite)
- `resolveRelativePath` (CSS url(), node src/href/srcset)
and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.
This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.
Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.
bun run build, bun test (7067), bun run lint all pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(site-import): drop the dead query strip in fontFormatForPath
resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(dashboard): onboarding step 03 follows the Home page being published (#624)
Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed …
DavidBabinec
added a commit
that referenced
this pull request
Oct 27, 2026
* fix(collab): publish presence for users without a display name
"Your name" is optional at setup and in the profile, but the editor only
published presence when the user had a display name, and the relay derived
the session identity from the raw display name. A user without one
published no presence at all: peers saw no cursor or selection ring, and
the "someone else is editing" guards never fired for them.
userDisplayLabel (src/core/users/displayName.ts) returns the trimmed
display name, else the email address. The relay's session identity and the
editor's published presence both use it, so they keep matching the
anti-spoofing check; collaborators now see the email of a user without a
display name. The account menu, profile tab, activity widget, users table,
audit labels and UserAvatar use the same helper, replacing displayUserName
and the inline fallbacks.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore(release): prepare 0.0.21 (#628)
* fix(framework): set the root font size the rem scale assumes (#636)
The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.
* fix(framework): make theme-inverted sections take the alt colors (#635)
The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.
* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)
* feat(fonts): seed heading, body and mono font role tokens on new sites
New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.
* fix(site-import): fill an untouched font role token instead of renaming
Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.
* docs(fonts): describe role tokens in terms of shared content
The rationale now names imported pages, copied sections and AI output as the content the role variables serve.
* test(framework): read the framework from the preset result in root font size tests
applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.
* fix(import): replace import removes the previous site media (#637)
A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.
* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)
The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): answer conditional requests for static files and uploads with 304 (#653)
* fix(server): answer conditional requests for static files and uploads with 304
serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.
Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): preserve upload MIME when revalidating cached files
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)
Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.
- useRuntimePreviewDocument builds from the draft only once it has stayed
unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
and context; both callers show the loading state only when there is
nothing to show yet.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)
The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.
selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep style and class injection out of data-style and data-class (#561)
The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): repair invalid props one by one instead of resetting the node (#574)
When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(site-import): upload and rewrite imported video sources and posters (#661)
* fix(site-import): upload and re-point imported video sources
The HTML importer stores a <video>/<source> URL in base.video's
`videoUrl` prop, but the site-import asset pipeline only normalised and
rewrote `src`, `href` and `srcset`. Imported videos kept their relative
path and the file was never uploaded.
Add `videoUrl` to the URL-bearing props and make assetPlan.ts the single
owner of that list so applyAssetRewrites can no longer drift from it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): answer conditional requests for static files and uploads with 304 (#653)
* fix(server): answer conditional requests for static files and uploads with 304
serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.
Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): preserve upload MIME when revalidating cached files
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)
Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.
- useRuntimePreviewDocument builds from the draft only once it has stayed
unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
and context; both callers show the loading state only when there is
nothing to show yet.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)
The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.
selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep style and class injection out of data-style and data-class (#561)
The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): repair invalid props one by one instead of resetting the node (#574)
When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(import): retain and rewrite native video posters
* docs(import): group video mapping separately from anchor rules
---------
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(import): map unsupported anchor targets to _self or _blank (#575)
* fix(import): map unsupported anchor targets to _self or _blank
The anchor import rules copied target="" (and _top or a named frame)
straight into the node. None of those values is in AnchorTargetSchema, so
the publisher's fallback replaced the whole link with module defaults and
the page shipped "Click here" pointing at "#". The schema now lives in
@core/htmlAttributes with a normalizeAnchorTarget() helper the importer
uses for both anchor rules: empty and _top import as _self, a named target
as _blank, keywords match case-insensitively, and href and text are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(editor): apply classes to all selected elements (#511)
Assisted-by: OpenAI Codex
Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>
* fix(settings): make the Esc keycap close the modal it advertises (#499)
* fix(settings): make the Esc keycap close the modal it advertises
The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.
It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.
So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".
The file header claimed there was no dedicated close button. There is one
now, so it says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(settings): let the Button primitive own the Esc keycap's alignment and width
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)
* feat(plugins): @own-created contentAccess marker for plugin-created tables
Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.
- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
every table the declaring plugin created, resolved against the stored
creator (never by slug), with the entry's declared modes; entries combine
as a union. Marker modes go through the same install-time modes<->permission
coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
resolve-then-assert; tree callbacks already had the table). tables.list /
search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
renders contentAccess verbatim" claim corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(branches): a fork keeps which plugin created a table
Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.
It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.
The test fails without the change (`Received: null`) and passes with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(db): move the migration to 033 — 031 and 032 are claimed
#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(db): number the created_by_plugin_id migration 031, next after 030 on main
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(site-import): resolve cache-busted asset URLs (#539)
* fix(site-import): resolve cache-busted asset URLs
A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.
Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.
The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.
Fixed at both resolution primitives:
- `resolveHref` (stylesheets, scripts, CSS @import, and page links via
linkRewrite)
- `resolveRelativePath` (CSS url(), node src/href/srcset)
and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.
This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.
Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.
bun run build, bun test (7067), bun run lint all pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(site-import): drop the dead query strip in fontFormatForPath
resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(dashboard): onboarding step 03 follows the Home page being published (#624)
Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed only when a second page existed,
so a user who edited and published Home still saw "Not started" next to
a Pages widget reporting 1 published. A one-page site could never
complete the step at all.
The step is now "Edit the Home page". It reads In progress from first
boot and completes once the dashboard pages stats report at least one
published page, which is the moment the site is actually on the
frontend. The rules move into a pure deriveOnboardingFacts so they are
unit-tested without the network; the hook feeds it the same pages stats
endpoint the Pages widget already uses.
Closes #223
* fix(data): author reassignment rides the row-access boundary (#625)
PATCH /data/rows/:id/author read the target row with a bare getDataRow
instead of loadRowForAccess, so a persona holding content.edit.any but
not data.system.tables.read could read the full contents of any
system-table row (unpublished pages and posts, author identity) and
re-author it, while GET on the same row correctly answered 404. The
handler now resolves the row through the same table-family plus
ownership check every other row write uses.
* fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626)
* fix(ai): read tools and the MCP relay decide row access with the HTTP rules
The content read tools (content_get_document, content_list_documents,
content_search_documents) called the row repositories directly, so a
caller holding ai.chat plus content.edit.own could read every other
user's drafts and any system-table row through the tool surface, while
GET /data/rows/:id for the same caller answered 404. The MCP relay
carried its own copy of the ownership rule and skipped the table-family
boundary altogether.
One module now owns the decision. server/auth/dataAccess.ts holds the
table-family and ownership predicates plus resolveRowAccess and
rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay
all call it with the identity behind the request. An architecture gate
fails any direct row read under server/ai that bypasses it, because
this class was fixed on the HTTP side once (GHSA-x69h) and came back
through the next reader.
* fix(data): drop the row read import the shared access helper made unused
* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627)
* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header
Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host
header, so an unauthenticated client could make the 401 challenge, both
.well-known discovery documents, and the resource comparisons echo an
attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes
only from the configured public origin; without one, loopback requests
use their own origin so local development keeps working, and every other
request has no issuer: discovery and the token endpoint answer 503, the
401 challenge drops resource_metadata, and OAuth grants are not looked
up while personal access tokens keep working. The server warns at boot
when no public origin is configured.
* test(mcp): assert discovery origins by parsed origin, not string prefix
* chore(release): prepare 0.0.21 (#628)
* fix(framework): set the root font size the rem scale assumes (#636)
The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.
* fix(framework): make theme-inverted sections take the alt colors (#635)
The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.
* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)
* feat(fonts): seed heading, body and mono font role tokens on new sites
New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.
* fix(site-import): fill an untouched font role token instead of renaming
Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.
* docs(fonts): describe role tokens in terms of shared content
The rationale now names imported pages, copied sections and AI output as the content the role variables serve.
* test(framework): read the framework from the preset result in root font size tests
applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.
* fix(import): replace import removes the previous site media (#637)
A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.
* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)
The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (…
DavidBabinec
added a commit
that referenced
this pull request
Oct 27, 2026
* fix(media): let the upload queue window stay closed mid-upload
The close button worked, then an effect undid it. The effect depended on
both `uploadQueue.active` and the window's open state, so closing the
window re-ran it, `active && !open` was true, and it opened again on the
next commit. While a transfer was in flight the window could not be shut.
The open state moves into `useUploadQueueWindow(active)`, which opens the
window on the transition into an active upload — detected during render
against the last `active` it saw — and on nothing else. Closing hides the
transfer; it doesn't cancel it. The next upload opens the window again,
and a finished transfer still leaves it open until dismissed. Doing it in
render rather than an effect also drops the set-state-in-effect disable
and the one-commit open lag.
The test drives the real hook through what a user does — upload starts,
close, progress re-renders — and fails against the old effect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): answer conditional requests for static files and uploads with 304 (#653)
* fix(server): answer conditional requests for static files and uploads with 304
serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.
Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): preserve upload MIME when revalidating cached files
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)
Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.
- useRuntimePreviewDocument builds from the draft only once it has stayed
unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
and context; both callers show the loading state only when there is
nothing to show yet.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)
The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.
selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep style and class injection out of data-style and data-class (#561)
The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): repair invalid props one by one instead of resetting the node (#574)
When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(site-import): upload and rewrite imported video sources and posters (#661)
* fix(site-import): upload and re-point imported video sources
The HTML importer stores a <video>/<source> URL in base.video's
`videoUrl` prop, but the site-import asset pipeline only normalised and
rewrote `src`, `href` and `srcset`. Imported videos kept their relative
path and the file was never uploaded.
Add `videoUrl` to the URL-bearing props and make assetPlan.ts the single
owner of that list so applyAssetRewrites can no longer drift from it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): answer conditional requests for static files and uploads with 304 (#653)
* fix(server): answer conditional requests for static files and uploads with 304
serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.
Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.
- createBridge gains an opt-in `serial` option: the next request is emitted
only after the previous one settles, and its timeout starts on emit.
Queued calls still reject on abort/destroy without being emitted. The chat
bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
of closing while any call is pending. Each pending call is bounded by the
per-call timeout, so an orphaned stream still drains and closes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(collab): preserve initial sync delivery under backpressure (#590)
* fix(collab): preserve initial sync delivery under backpressure
* fix(collab): packetize large stored document delivery
* fix(server): preserve upload MIME when revalidating cached files
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)
Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.
- useRuntimePreviewDocument builds from the draft only once it has stayed
unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
and context; both callers show the loading state only when there is
nothing to show yet.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)
The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.
selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep style and class injection out of data-style and data-class (#561)
The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): repair invalid props one by one instead of resetting the node (#574)
When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(import): retain and rewrite native video posters
* docs(import): group video mapping separately from anchor rules
---------
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(import): map unsupported anchor targets to _self or _blank (#575)
* fix(import): map unsupported anchor targets to _self or _blank
The anchor import rules copied target="" (and _top or a named frame)
straight into the node. None of those values is in AnchorTargetSchema, so
the publisher's fallback replaced the whole link with module defaults and
the page shipped "Click here" pointing at "#". The schema now lives in
@core/htmlAttributes with a normalizeAnchorTarget() helper the importer
uses for both anchor rules: empty and _top import as _self, a named target
as _blank, keywords match case-insensitively, and href and text are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(editor): apply classes to all selected elements (#511)
Assisted-by: OpenAI Codex
Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>
* fix(settings): make the Esc keycap close the modal it advertises (#499)
* fix(settings): make the Esc keycap close the modal it advertises
The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.
It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.
So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".
The file header claimed there was no dedicated close button. There is one
now, so it says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(settings): let the Button primitive own the Esc keycap's alignment and width
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)
* feat(plugins): @own-created contentAccess marker for plugin-created tables
Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.
- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
every table the declaring plugin created, resolved against the stored
creator (never by slug), with the entry's declared modes; entries combine
as a union. Marker modes go through the same install-time modes<->permission
coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
resolve-then-assert; tree callbacks already had the table). tables.list /
search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
renders contentAccess verbatim" claim corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(branches): a fork keeps which plugin created a table
Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.
It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.
The test fails without the change (`Received: null`) and passes with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(db): move the migration to 033 — 031 and 032 are claimed
#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(db): number the created_by_plugin_id migration 031, next after 030 on main
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(site-import): resolve cache-busted asset URLs (#539)
* fix(site-import): resolve cache-busted asset URLs
A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.
Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.
The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.
Fixed at both resolution primitives:
- `resolveHref` (stylesheets, scripts, CSS @import, and page links via
linkRewrite)
- `resolveRelativePath` (CSS url(), node src/href/srcset)
and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.
This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.
Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.
bun run build, bun test (7067), bun run lint all pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(site-import): drop the dead query strip in fontFormatForPath
resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(dashboard): onboarding step 03 follows the Home page being published (#624)
Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed only when a second page existed,
so a user who edited and published Home still saw "Not started" next to
a Pages widget reporting 1 published. A one-page site could never
complete the step at all.
The step is now "Edit the Home page". It reads In progress from first
boot and completes once the dashboard pages stats report at least one
published page, which is the moment the site is actually on the
frontend. The rules move into a pure deriveOnboardingFacts so they are
unit-tested without the network; the hook feeds it the same pages stats
endpoint the Pages widget already uses.
Closes #223
* fix(data): author reassignment rides the row-access boundary (#625)
PATCH /data/rows/:id/author read the target row with a bare getDataRow
instead of loadRowForAccess, so a persona holding content.edit.any but
not data.system.tables.read could read the full contents of any
system-table row (unpublished pages and posts, author identity) and
re-author it, while GET on the same row correctly answered 404. The
handler now resolves the row through the same table-family plus
ownership check every other row write uses.
* fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626)
* fix(ai): read tools and the MCP relay decide row access with the HTTP rules
The content read tools (content_get_document, content_list_documents,
content_search_documents) called the row repositories directly, so a
caller holding ai.chat plus content.edit.own could read every other
user's drafts and any system-table row through the tool surface, while
GET /data/rows/:id for the same caller answered 404. The MCP relay
carried its own copy of the ownership rule and skipped the table-family
boundary altogether.
One module now owns the decision. server/auth/dataAccess.ts holds the
table-family and ownership predicates plus resolveRowAccess and
rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay
all call it with the identity behind the request. An architecture gate
fails any direct row read under server/ai that bypasses it, because
this class was fixed on the HTTP side once (GHSA-x69h) and came back
through the next reader.
* fix(data): drop the row read import the shared access helper made unused
* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627)
* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header
Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host
header, so an unauthenticated client could make the 401 challenge, both
.well-known discovery documents, and the resource comparisons echo an
attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes
only from the configured public origin; without one, loopback requests
use their own origin so local development keeps working, and every other
request has no issuer: discovery and the token endpoint answer 503, the
401 challenge drops resource_metadata, and OAuth grants are not looked
up while personal access tokens keep working. The server warns at boot
when no public origin is configured.
* test(mcp): assert discovery origins by parsed origin, not string prefix
* chore(release): prepare 0.0.21 (#628)
* fix(framework): set the root font size the rem scale assumes (#636)
The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.
* fix(framework): make theme-inverted sections take the alt colors (#635)
The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.
* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)
* feat(fonts): seed heading, body and mono font role tokens on new sites
New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.
* fix(site-import): fill an untouched font role token instead of renaming
Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.
* docs(fonts): describe role tokens in terms of shared content
The rationale now names imported pages, copied sections and AI output as the content the role variables serve.
* test(framework): read the framework from the preset result in root font size tests
applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.
* fix(import): replace import removes the previous site media (#637)
A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.
* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)
The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.
* fix(ai): serialize ai_messages content before insert on Postgres (#592)
On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").
JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.
Fixes #593.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(forms): accept the option value a form control submits for select fields (#667)
A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.
Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.
Fixes #629
* fix(publish): prune stored runtime assets of superseded page versions (#647)
Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.
persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): resolve custom media fields on entry routes (#662)
On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.
publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(publisher): keep custom properties with underscores or a leading --_ (#617)
The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.
Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.
Fixes #616
* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)
Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.
The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.
Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.
The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.
Fixes #615
* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)
Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.
Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.
`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.
Fixes #619
* fix(publisher): keep safe images in published entry bodies (#664)
* fix(publisher): keep safe images in published entry bodies
Fixes #595
* fix(publisher): route content render paths through the image profile
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
* fix(loops): sort numeric row cells by value (#601)
* fix(publisher): emit one merged rel on links and buttons instead of two (#570)
base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.
The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)
The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call…
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Site plugins: author a plugin inside the site itself, in a full-screen IDE, and activate it through the same lifecycle an uploaded package uses.
plugins/<local-id>/as ordinary site files (SiteFileType: 'plugin'), so it forks, previews and merges with the site like any other content./admin/plugins/develop/<local-id>: file tree, co-edited CodeMirror buffer, diagnostics strip, and an AI chat panel scoped to the plugin. Two admins can type in one file character by character, with per-peer carets, over the existing collab socket.@core/plugin-buildcore with fail-closed import containment, then activation goes through the normal permission review and step-up. Five builds are retained so a bad one can be rolled back.installed_plugins.sourcerecords it.plugins.edit, a dedicated authoring capability, separate from installing.Feature doc:
docs/features/site-plugins.md.Why
Writing a plugin previously meant leaving the CMS, building a zip, and uploading it. That is a poor loop for the small, site-specific extensions this system is meant to encourage, and it puts the source outside the thing that versions it.
Impact
@core/plugin-buildis now shared by both paths. Migration 029 addsinstalled_plugins.source, additive with a constant default.feat/site-branchesand should merge after it. The branches work is a hard dependency: this branch was rebased onto it, and its migration sits behind the two the branches PR adds.Verification
E2E
SITEPLUGIN-001covers the real loop end to end: scaffold a plugin, confirm it opens in the IDE with real source, build and activate it through the permission review and step-up, see it in the merged list, then edit the server entry and watch the state fall back to "Draft changed". The IDE had no browser coverage before this.Notes for review
This branch was based on a two-month-old
mainand carried an early version of the collab layer thatmainlater landed differently. The rebase dropped those superseded commits and replayed only the plugin work, resolving each conflict by keeping the evolved code and porting the plugin's contribution onto it. Two commits were dropped entirely becausemainalready had the same fix in better form.Rebase onto main (2026-09-12)
Replayed the 35 plugin commits onto main after #484 (site branches), #528 (branch entity adapters), and #529 (ISO timestamps) landed; the superseded migration-renumbering commit was dropped. Settled in one follow-up commit:
031_installed_plugins_source, behind the branches ones (027 to 029) and the timestamp rewrite (030). A dev database that ran the branch's earlier029will not boot; delete it.activatePluginPackageFromDisksends anything already installed through the upgrade lifecycle regardless of version, matching main's zip path since fix(plugins): treat a same-version upload as a reinstall, not a first install #498 (a same-version rebuild or a rollback must deactivate the running plugin first). The semver comparison main removed is gone.Open design question, not decided here: main's publish-time
sweepStalePluginVersionAssetsdeletes every version directory of an installed plugin except the current one. A site plugin keeps up to five builds under the same tree as rollback targets (retention.ts), so a user-initiated site publish would delete them (the coupled republish after a build does not reach the sweep). The invariants gate forbids publish machinery from branching on'site-local', so the fix is either an allowlisted exemption in the sweep or keeping retained builds outside the published asset tree; a first attempt at the former was reverted on that gate.