Repository navigation
chore(release): sync upstream and prepare 0.0.24 - #15
Merged
Merged
Conversation
…eBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… fields (CoreBunch#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 CoreBunch#629
…CoreBunch#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>
…h#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>
…--_ (CoreBunch#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 CoreBunch#616
…is emitted under (CoreBunch#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 CoreBunch#615
…n is emitted under (CoreBunch#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 CoreBunch#619
…#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#595 * fix(publisher): route content render paths through the image profile --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
…wo (CoreBunch#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>
…e while calls are pending (CoreBunch#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>
…unch#590) * fix(collab): preserve initial sync delivery under backpressure * fix(collab): packetize large stored document delivery
… with 304 (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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>
…the last build meanwhile (CoreBunch#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>
…ss rules (CoreBunch#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>
…data-class (CoreBunch#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>
…the node (CoreBunch#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>
…rs (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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>
…unch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 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> * 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 (CoreBunch#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 (CoreBunch#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 CoreBunch#223 * fix(data): author reassignment rides the row-access boundary (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#628) * fix(framework): set the root font size the rem scale assumes (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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>
…ition (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 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> * 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 (CoreBunch#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 (CoreBunch#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 CoreBunch#223 * fix(data): author reassignment rides the row-access boundary (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#628) * fix(framework): set the root font size the rem scale assumes (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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>
…ks (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 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> * 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 (CoreBunch#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 (CoreBunch#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 CoreBunch#223 * fix(data): author reassignment rides the row-access boundary (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#628) * fix(framework): set the root font size the rem scale assumes (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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>
… non-secure origins (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 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> * 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 (CoreBunch#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 (CoreBunch#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 CoreBunch#223 * fix(data): author reassignment rides the row-access boundary (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#628) * fix(framework): set the root font size the rem scale assumes (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 CoreBunch#593. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(forms): accept the option value a form control submits for select fields (CoreBunch#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 CoreBunch#629 * fix(publish): prune stored runtime assets of superseded page versions (CoreBunch#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 (CoreBunch#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 --_ (CoreBunch#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 CoreBunch#616 * fix(siteImport): rewrite var() references to the slug a colour token is emitted under (CoreBunch#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 CoreBunch#615 * fix(siteImport): rewrite var() references to the variable a font token is emitted under (CoreBunch#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 CoreBunch#619 * fix(publisher): keep safe images in published entry bodies (CoreBunch#664) * fix(publisher): keep safe images in published entry bodies Fixes CoreBunch#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 (CoreBunch#601) * fix(publisher): emit one merged rel on links and buttons instead of two (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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 (CoreBunch#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>
…ch#571) * 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 …
…unch#631) * 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 --_ (…
…unch#659) * 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…
…e the editor (CoreBunch#630) * fix(settings): reload the cached site document after an import outside the editor Outside the Site editor the Settings modal caches the site document in a standalone store and never refetched it, so after a site import, a plugin install or a data-workspace write to site tables it kept showing (and saving on top of) the pre-import document until a full page reload. Any CMS_SITE_RELOAD_EVENT other than the store's own save announcement now marks the cached copy stale (also while the first load is in flight); the next render refetches after pending saves have settled, immediately while the modal is open. A load whose fetch started before a save discards its response and fetches again, so it cannot roll back that save or rewind its shell conflict base. The test preload resets the store between tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(settings): refresh visible fields after external imports --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com>
…ch#582) * fix(import): accept site archives up to the documented limit * fix(server): retain JSON body limits for large archive imports --------- Co-authored-by: DavidBabinec <hello@davidbabinec.com>
…poll (CoreBunch#645) * fix(agent): wait for snapshot frames on DOM events, not a 16ms timer poll waitForAgentRenderFrame polled every 16ms with chained setTimeouts. In a background tab Chrome throttles chained timers to once per second, and to once per minute after five minutes hidden, so an MCP render snapshot routed to an unfocused editor could stall for minutes. It now re-checks on a MutationObserver over the canvas DOM and readiness markers; observer callbacks are microtasks and are not throttled. A 250ms timer stays as a fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(agent): enforce snapshot readiness deadline --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com>
…nch#597) * fix(import): preserve font display in the native font library * fix(import): normalize font display keyword casing --------- Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(publisher): minify published runtime script bundles Published module bundles carry their npm dependencies, and esbuild ran without minify, so GSAP alone shipped at ~170 kB. Publish builds are now minified (GSAP: 170 kB to 71 kB, 28 kB gzipped). Canvas builds stay unminified for their inline source maps; classic scripts are still emitted byte-for-byte. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs(publisher): describe runtime bundle minification --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: DavidBabinec <hello@davidbabinec.com>
* fix(publish): keep individual page publishes routable Publish the selected page through the site snapshot pipeline using published shared resources and other published pages. Preserve unrelated drafts and their metadata, and reject missing publication context before changing the live site. * test(router): isolate cached responses between installs
Brings in 35 upstream commits (publish, import, collab, MCP, backup fixes). Conflicts resolved keeping fork loop/outlet/slot import additions alongside upstream anchor-target normalisation and rel passthrough; dropped the fork's duplicate publish-minify flag in favour of upstream's minifyOutput. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Review skippedToo many files! This PR contains 198 files, which is 98 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.
|
flyingwebie
marked this pull request as ready for review
October 30, 2026 16:24
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
CoreBunch/Instaticmain(publish, import, collab, MCP, backup, agent fixes).Conflict resolution
src/core/htmlImport/{rules,walkAndMap}.ts,docs/features/html-import.md: kept the fork's loop/outlet/slot import support alongside upstream's anchor-target normalisation andrelpassthrough.server/publish/runtime/bundleScripts.ts: fork and upstream both added publish minification; kept upstream'sminifyOutputand dropped the duplicate key.Verification
bun run build✅bun run lint✅bun test: 7608 pass / 0 fail before the minify fix; server suite re-run after it, 0 fail.After merge, tag
v0.0.24to publishghcr.io/flyingwebie/instatic:0.0.24.🤖 Generated with Claude Code