smoodly 0.0.34 → 0.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,6 +64,7 @@ import { PublishControl } from "../ui/PublishControl.js";
64
64
  import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
65
65
  import { HistoryModal, historyRows } from "../ui/HistoryModal.js";
66
66
  import { draftToken } from "../../draft-token.js";
67
+ import { useUnloadGuard } from "../shell/router.js";
67
68
  import { isStale, staleInfoOf } from "../shell/stale.js";
68
69
  import { StaleBanner } from "../ui/StaleBanner.js";
69
70
  import { DriftNotice } from "../ui/DriftNotice.js";
@@ -150,9 +151,14 @@ export function EditorView({ ops, registry, id }) {
150
151
  const r = await ops.pages.saveDraft(id, target, t, token());
151
152
  if (r.ok) {
152
153
  // The badge and Publish follow each save without a re-get: saveDraft answers with the version id and the new token.
153
- setRecord((rec) => rec?.locales[target]
154
- ? { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId, draftSavedAt: r.data.draft?.savedAt ?? null } } }
155
- : rec);
154
+ // The ref moves with the ack, not with the next render: a save that follows an unmounted view's
155
+ // (close()) must carry this token, and no render will ever run to refresh it (review I3).
156
+ const rec = recordRef.current;
157
+ if (rec?.locales[target]) {
158
+ const next = { ...rec, locales: { ...rec.locales, [target]: { ...rec.locales[target], draftVersionId: r.data.versionId, draftSavedAt: r.data.draft?.savedAt ?? null } } };
159
+ recordRef.current = next;
160
+ setRecord(next);
161
+ }
156
162
  setDirty(false);
157
163
  presence?.announce(pageTarget(target), r.data.draft?.savedAt ?? null);
158
164
  }
@@ -166,6 +172,7 @@ export function EditorView({ ops, registry, id }) {
166
172
  // autosave (spec 2026-09-13 §4); the save ack refreshes the canvas
167
173
  // through the same inline-session deferral as the page's.
168
174
  const globalEdit = useGlobalEdit({ ops, onSaved: (name) => { setGlobalDirty(false); inlineRef.current?.onSaved(); presence?.announce({ kind: "global", id: name, locale }, null); } });
175
+ useUnloadGuard(dirty || saveState !== "saved" || globalDirty || globalEdit.saveState !== "saved");
169
176
  const patchGlobal = (name, path, value) => {
170
177
  globalEdit.patch(name, path, value);
171
178
  setGlobalDirty(true);
@@ -12,6 +12,11 @@ export type DraftAutosave = {
12
12
  subscribe(listener: () => void): () => void;
13
13
  /** Cancel pending timers; call on unmount. */
14
14
  dispose(): void;
15
+ /** Unmount: save a pending edit now, cancel the timers, and retry nothing
16
+ * afterwards (spec 2026-09-29 pre-install fixes §4). */
17
+ close(): Promise<boolean>;
18
+ /** Undo `close()` when the hook's effect runs again (React StrictMode mounts, cleans up, mounts). */
19
+ reopen(): void;
15
20
  /** Leave `stale` after the record was reloaded: timers cleared, the reloaded value counts as saved. */
16
21
  reset(): void;
17
22
  };
@@ -26,7 +31,7 @@ export declare function createDraftAutosave<T>(opts: {
26
31
  export type DraftAutosaveOptions<T> = Parameters<typeof createDraftAutosave<T>>[0];
27
32
  /** React glue: one controller per mount, always calling the latest
28
33
  * `read`/`persist`/`onSaved` (their identities change on every render —
29
- * `ops` and `slug` are props), timers cleared on unmount. */
34
+ * `ops` and `slug` are props), a pending edit saved on unmount. */
30
35
  export declare function useDraftAutosave<T>(opts: DraftAutosaveOptions<T>): {
31
36
  saveState: SaveState;
32
37
  mark: () => void;
@@ -12,13 +12,24 @@ export function createDraftAutosave(opts) {
12
12
  let revision = 0;
13
13
  let savedRevision = 0;
14
14
  let state = "saved";
15
+ let closed = false;
16
+ /** The save on the wire, if any: close() waits for it instead of racing it with a second save. */
17
+ let inflight = null;
15
18
  const listeners = new Set();
16
19
  const setState = (next) => {
17
20
  state = next;
18
21
  for (const l of listeners)
19
22
  l();
20
23
  };
21
- const save = async () => {
24
+ const save = () => {
25
+ const run = send();
26
+ inflight = run;
27
+ const clear = () => { if (inflight === run)
28
+ inflight = null; };
29
+ run.then(clear, clear);
30
+ return run;
31
+ };
32
+ const send = async () => {
22
33
  const current = opts.read();
23
34
  if (current === null)
24
35
  return true;
@@ -60,6 +71,10 @@ export function createDraftAutosave(opts) {
60
71
  }
61
72
  else {
62
73
  // Keep local state; retry with a doubling, capped delay.
74
+ if (closed) {
75
+ setState("error");
76
+ return false;
77
+ }
63
78
  setState("error");
64
79
  const delay = retryDelay;
65
80
  retryDelay = Math.min(delay * 2, retryCapMs);
@@ -102,6 +117,28 @@ export function createDraftAutosave(opts) {
102
117
  retryTimer = null;
103
118
  }
104
119
  },
120
+ close() {
121
+ closed = true;
122
+ if (debounceTimer) {
123
+ clearTimeout(debounceTimer);
124
+ debounceTimer = null;
125
+ }
126
+ if (retryTimer) {
127
+ clearTimeout(retryTimer);
128
+ retryTimer = null;
129
+ }
130
+ // A save in flight holds the token the next one must carry, and only its ack moves it (review I3):
131
+ // wait, then send whatever the ack did not cover.
132
+ const remainder = () => {
133
+ if (state === "stale")
134
+ return Promise.resolve(false);
135
+ if (revision === savedRevision)
136
+ return Promise.resolve(true);
137
+ return save();
138
+ };
139
+ return inflight ? inflight.then(remainder) : remainder();
140
+ },
141
+ reopen() { closed = false; },
105
142
  reset() {
106
143
  if (debounceTimer) {
107
144
  clearTimeout(debounceTimer);
@@ -119,7 +156,7 @@ export function createDraftAutosave(opts) {
119
156
  }
120
157
  /** React glue: one controller per mount, always calling the latest
121
158
  * `read`/`persist`/`onSaved` (their identities change on every render —
122
- * `ops` and `slug` are props), timers cleared on unmount. */
159
+ * `ops` and `slug` are props), a pending edit saved on unmount. */
123
160
  export function useDraftAutosave(opts) {
124
161
  const latest = useRef(opts);
125
162
  latest.current = opts;
@@ -131,7 +168,10 @@ export function useDraftAutosave(opts) {
131
168
  retryBaseMs: opts.retryBaseMs,
132
169
  retryCapMs: opts.retryCapMs,
133
170
  }));
134
- useEffect(() => () => controller.dispose(), [controller]);
171
+ useEffect(() => {
172
+ controller.reopen();
173
+ return () => { void controller.close(); };
174
+ }, [controller]);
135
175
  const saveState = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
136
176
  return { saveState, mark: controller.mark, flush: controller.flush, reset: controller.reset };
137
177
  }
@@ -1,3 +1,6 @@
1
+ // The list widget's logic as pure functions over plain arrays and objects,
2
+ // so it is tested without a DOM. FieldWidget renders; this file decides.
3
+ import { optionsOf } from "../../fields.js";
1
4
  /** An empty item: an object of its subfields' defaults, else the default. */
2
5
  export function emptyValue(d) {
3
6
  if (d.type === "object") {
@@ -9,7 +12,13 @@ export function emptyValue(d) {
9
12
  }
10
13
  return out;
11
14
  }
12
- return d.default;
15
+ if (d.default !== undefined)
16
+ return d.default;
17
+ // A required choice shows its first option (FieldWidget renders no blank
18
+ // one), so it stores it too — a new item never looks set while it is empty.
19
+ if ((d.type === "select" || d.type === "radio") && d.optional !== true)
20
+ return optionsOf(d.options)[0]?.value;
21
+ return undefined;
13
22
  }
14
23
  export function addItem(items, list) {
15
24
  if (typeof list.max === "number" && items.length >= list.max)
@@ -34,7 +34,7 @@ import { ResizeHandle } from "../ui/ResizeHandle.js";
34
34
  import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-list.js";
35
35
  import { useAdminHref } from "./base.js";
36
36
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
37
- import { navigate, useLeaveGuard } from "./router.js";
37
+ import { navigate, useUnloadGuard } from "./router.js";
38
38
  import { LIVE_URL_CONFIRM, LIVE_URL_OPTION, liveUrlChanges } from "./live-url.js";
39
39
  import { confirm, confirmWithOption, DISCARD_CONFIRM } from "../ui/confirm.js";
40
40
  import { draftToken } from "../../draft-token.js";
@@ -139,6 +139,7 @@ export function EntryForm({ ops, registry, collection, id }) {
139
139
  const r = await ops.entries.save(collection, id, locale, value, token(), { redirect: redirectRef.current });
140
140
  if (r.ok) {
141
141
  redirectRef.current = true;
142
+ recordRef.current = r.data;
142
143
  setRecord(r.data);
143
144
  setSaveErrors({});
144
145
  presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
@@ -155,7 +156,7 @@ export function EntryForm({ ops, registry, collection, id }) {
155
156
  setDirty(false); },
156
157
  });
157
158
  // Reload and close ask only while a save is pending or in flight.
158
- useLeaveGuard(dirty || saveState !== "saved");
159
+ useUnloadGuard(dirty || saveState !== "saved");
159
160
  const refTargets = useMemo(() => {
160
161
  const out = {};
161
162
  for (const [key, d] of Object.entries(meta?.fields ?? {})) {
@@ -29,7 +29,7 @@ import { ResizeHandle } from "../ui/ResizeHandle.js";
29
29
  import { useAdminHref } from "./base.js";
30
30
  import { GlobalFields } from "./GlobalFields.js";
31
31
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
32
- import { useLeaveGuard } from "./router.js";
32
+ import { useUnloadGuard } from "./router.js";
33
33
  import { confirm, DISCARD_CONFIRM } from "../ui/confirm.js";
34
34
  import { draftToken } from "../../draft-token.js";
35
35
  import { isStale, staleInfoOf } from "./stale.js";
@@ -78,6 +78,7 @@ export function GlobalForm({ ops, registry, name }) {
78
78
  persist: async (value) => {
79
79
  const r = await ops.globals.save(name, locale, value, token());
80
80
  if (r.ok) {
81
+ recordRef.current = r.data;
81
82
  setRecord(r.data);
82
83
  presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
83
84
  }
@@ -87,7 +88,7 @@ export function GlobalForm({ ops, registry, name }) {
87
88
  },
88
89
  onSaved: () => setDirty(false),
89
90
  });
90
- useLeaveGuard(dirty || saveState !== "saved");
91
+ useUnloadGuard(dirty || saveState !== "saved");
91
92
  const refTargets = useMemo(() => {
92
93
  const out = {};
93
94
  for (const [key, d] of Object.entries(item?.fields ?? {})) {
@@ -39,3 +39,8 @@ export declare function useRoute(base: string): Route;
39
39
  /** Register the calling view as dirty: navigate() asks before leaving,
40
40
  * and beforeunload covers reload, closing the tab and a real link out. */
41
41
  export declare function useLeaveGuard(dirty: boolean): void;
42
+ /** The tab-close half of useLeaveGuard alone, for a view that saves on
43
+ * unmount (the page editor, spec 2026-09-29 pre-install fixes §4): an
44
+ * in-app navigation loses nothing, so only reload, closing the tab and a
45
+ * real link out ask, while a change or a save is pending. */
46
+ export declare function useUnloadGuard(pending: boolean): void;
@@ -94,3 +94,16 @@ export function useLeaveGuard(dirty) {
94
94
  return () => { guards.delete(guard); window.removeEventListener("beforeunload", onBeforeUnload); };
95
95
  }, [dirty]);
96
96
  }
97
+ /** The tab-close half of useLeaveGuard alone, for a view that saves on
98
+ * unmount (the page editor, spec 2026-09-29 pre-install fixes §4): an
99
+ * in-app navigation loses nothing, so only reload, closing the tab and a
100
+ * real link out ask, while a change or a save is pending. */
101
+ export function useUnloadGuard(pending) {
102
+ useEffect(() => {
103
+ if (!pending)
104
+ return;
105
+ const onBeforeUnload = (e) => { e.preventDefault(); e.returnValue = ""; };
106
+ window.addEventListener("beforeunload", onBeforeUnload);
107
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
108
+ }, [pending]);
109
+ }
@@ -0,0 +1,7 @@
1
+ import type { ReactNode } from "react";
2
+ export declare const CanvasBridgeSlot: import("react").ComponentType<{}>;
3
+ export declare const CanvasNetSlot: import("react").ComponentType<{
4
+ id: string;
5
+ title: string;
6
+ children?: ReactNode;
7
+ }>;
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ // The one Smoodly client module a live page carries (spec 2026-09-29
3
+ // pre-install fixes §1). Next ships every client module a route's server
4
+ // graph imports, rendered or not; this stub is that module, and the bridge
5
+ // and the canvas net load from it lazily — only when the renderer is in
6
+ // editor mode, so a live page downloads neither.
7
+ import dynamic from "next/dynamic";
8
+ export const CanvasBridgeSlot = dynamic(() => import("../admin/next/bridge.js").then((m) => m.EditorBridge));
9
+ export const CanvasNetSlot = dynamic(() => import("./canvas-boundary.js").then((m) => m.CanvasBoundary));
@@ -3,3 +3,5 @@ export type { PageRendererOptions, PathRenderOptions, PageRegistration } from ".
3
3
  export { metadataFrom } from "./meta.ts";
4
4
  export { createSmoodlyQueries } from "./queries.ts";
5
5
  export type { SmoodlyQueries, QueryOptions, EntriesQuery, TypedEntryItem, GlobalValue } from "./queries.ts";
6
+ export { previewRoute } from "./preview-route.ts";
7
+ export { previewPill } from "./preview-pill.tsx";
@@ -1,3 +1,5 @@
1
1
  export { createPageRenderer } from "./page-renderer.js";
2
2
  export { metadataFrom } from "./meta.js";
3
3
  export { createSmoodlyQueries } from "./queries.js";
4
+ export { previewRoute } from "./preview-route.js";
5
+ export { previewPill } from "./preview-pill.js";
@@ -30,6 +30,8 @@ export type PageRendererOptions = {
30
30
  /** Locale-relative CMS path → site URL. Default: the path itself. With
31
31
  * next-intl: `(path, locale) => getPathname({ locale, href: path })`. */
32
32
  href?: (path: string, locale: string) => string;
33
+ /** Where the preview pill's Exit goes (spec 2026-09-29 pre-install fixes §2): the route that exports `previewRoute`. Default `/api/preview`. */
34
+ previewRoute?: string;
33
35
  };
34
36
  export type PathRenderOptions = {
35
37
  locale?: string;
@@ -1,16 +1,16 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The site-route seam (spec 2026-09-05 §5): the two route files call
3
3
  // renderSmoodlyPath; everything else — request gating, draft mode, the
4
- // cache keyed by (locale, path) and tagged by id, 404s, the editor
5
- // bridge — lives here, over the framework-free site layer. Bound once by
6
- // the app, exactly like createAdminOpHandler.
4
+ // cache keyed by (locale, path, target) and tagged by id, 404s, the editor
5
+ // bridge (through the lazy canvas stub) — lives here, over the
6
+ // framework-free site layer. Bound once by the app, exactly like
7
+ // createAdminOpHandler.
7
8
  import { Suspense } from "react";
8
9
  import { unstable_cache } from "next/cache";
9
10
  import { draftMode } from "next/headers";
10
11
  import { notFound, permanentRedirect, unstable_rethrow } from "next/navigation";
11
12
  import { connection } from "next/server";
12
- import { EditorBridge } from "../admin/next/bridge.js";
13
- import { CanvasBoundary } from "./canvas-boundary.js";
13
+ import { CanvasBridgeSlot, CanvasNetSlot } from "./canvas-slot.js";
14
14
  import { DRAFT_META_NAME } from "../admin/editor/canvas-diagnosis.js";
15
15
  import { fixedPathFor } from "../localize.js";
16
16
  import { decodeSegment, joinPath } from "../paths.js";
@@ -18,12 +18,13 @@ import { renderPage } from "../render.js";
18
18
  import { entryTag, pageTag, globalTag } from "../revalidate.js";
19
19
  import { pageContextOf } from "../site.js";
20
20
  import { metadataFrom } from "./meta.js";
21
+ import { previewPill } from "./preview-pill.js";
21
22
  /** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
22
23
  export const CACHE_COMPONENTS_WARNING = "smoodly: Next's cacheComponents is on, and Smoodly does not support it — a missing page answers 200 with noindex instead of 404, and a redirect answers 200 instead of 308. Turn cacheComponents off in next.config.ts; the Next.js guide's \"Cache Components\" section says why (https://smoodly.io/docs/frameworks/nextjs).";
23
24
  /** The canvas net (spec 2026-09-27 section guard §5): Server Components hand a section that failed to the
24
25
  * browser, where CanvasBoundary catches it. Defined here, not in the client file: a function exported
25
26
  * from a "use client" module is a reference on the server and cannot be called. */
26
- const canvasWrap = (child, section) => (_jsx(Suspense, { fallback: null, children: _jsx(CanvasBoundary, { id: section.id, title: section.title, children: child }) }));
27
+ const canvasWrap = (child, section) => (_jsx(Suspense, { fallback: null, children: _jsx(CanvasNetSlot, { id: section.id, title: section.title, children: child }) }));
27
28
  export function createPageRenderer(options) {
28
29
  const { site } = options;
29
30
  const href = options.href ?? ((path) => path);
@@ -34,7 +35,10 @@ export function createPageRenderer(options) {
34
35
  async function load(target, locale, path, draft, version) {
35
36
  if (draft)
36
37
  return site.load(target, locale, path, { draft: true, ...(version !== undefined ? { version } : {}) });
37
- return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path], {
38
+ // Keyed by what the unit holds, not only where: an address that changes
39
+ // owner — a deploy handing it to a code record, which fires no tag —
40
+ // can never serve the previous occupant (spec 2026-09-29 pre-install fixes §5).
41
+ return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path, target.kind, target.id], {
38
42
  tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
39
43
  })();
40
44
  }
@@ -115,7 +119,7 @@ export function createPageRenderer(options) {
115
119
  // The draft marker tells the page editor's canvas diagnosis that Smoodly
116
120
  // rendered this response (spec 2026-09-27 shadowed-page detector §3);
117
121
  // React hoists the meta into the head. Never outside draft mode.
118
- return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
122
+ return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx(CanvasBridgeSlot, {}) : null, draft && !editor && !version ? previewPill({ route: options.previewRoute }) : null, render(hit, editor, locale)] }));
119
123
  }
120
124
  async function metadata(pathFor, opts, accept) {
121
125
  // generateMetadata runs alongside the page render, so it gates too.
@@ -0,0 +1,9 @@
1
+ import { type ReactElement } from "react";
2
+ /** The pill's whole client side. `closed` lives in the closure, so a hidden
3
+ * pill stays hidden across client navigations and returns on a reload. */
4
+ export declare const PREVIEW_PILL_SCRIPT: string;
5
+ /** The pill for a route that does not go through the renderer: render it
6
+ * when `(await draftMode()).isEnabled`, never in the editor's canvas. */
7
+ export declare function previewPill(opts?: {
8
+ route?: string;
9
+ }): ReactElement;
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The preview pill (spec 2026-09-29 pre-install fixes §2): while draft mode
3
+ // is on, outside the editor's canvas, a floating pill says the page shows
4
+ // unpublished changes and offers Exit preview. It is an inline script
5
+ // defining a custom element with its styles in a Shadow DOM — no React,
6
+ // no client module, never in the site's bundles (the live-page rule, §1).
7
+ // The renderer emits it only in draft mode, so a live page's HTML is
8
+ // unchanged. A strict Content-Security-Policy blocks the inline script;
9
+ // the Next.js guide says so. The script is a constant: the route rides on
10
+ // the element, so nothing is interpolated into code.
11
+ import { createElement } from "react";
12
+ const CSS = ":host{all:initial}:host([hidden]){display:none}" +
13
+ ".pill{position:fixed;bottom:16px;z-index:2147483647;display:flex;align-items:center;gap:8px;padding:6px 6px 6px 14px;" +
14
+ "border-radius:999px;background:#151727;color:#fff;font:500 13px/1.2 system-ui,-apple-system,sans-serif;" +
15
+ "box-shadow:0 4px 16px rgba(0,0,0,.25);cursor:grab;user-select:none;touch-action:none}" +
16
+ ".pill[data-pos=left]{left:16px}.pill[data-pos=center]{left:50%;transform:translateX(-50%)}.pill[data-pos=right]{right:16px}" +
17
+ ".pill.dragging{cursor:grabbing}" +
18
+ "a{color:#151727;background:#fff;border-radius:999px;padding:5px 10px;text-decoration:none;font-weight:600}" +
19
+ "button{all:unset;cursor:pointer;width:24px;height:24px;border-radius:999px;display:grid;place-items:center;color:#fff;opacity:.7}" +
20
+ "button:hover,button:focus-visible{opacity:1;outline:2px solid #fff}";
21
+ const HTML = '<div class="pill" role="region" aria-label="Smoodly preview">' +
22
+ "<span>Preview \\u00b7 unpublished changes</span>" +
23
+ '<a href="#">Exit preview</a>' +
24
+ '<button type="button" aria-label="Hide until reload">\\u00d7</button>' +
25
+ "</div>";
26
+ /** The pill's whole client side. `closed` lives in the closure, so a hidden
27
+ * pill stays hidden across client navigations and returns on a reload. */
28
+ export const PREVIEW_PILL_SCRIPT = "(function(){" +
29
+ 'if(customElements.get("smoodly-preview-pill"))return;' +
30
+ 'var KEY="smoodly-pill-position",closed=false;' +
31
+ 'function load(){try{var v=localStorage.getItem(KEY);return v==="left"||v==="right"?v:"center"}catch(e){return"center"}}' +
32
+ "function save(v){try{localStorage.setItem(KEY,v)}catch(e){}}" +
33
+ "class Pill extends HTMLElement{connectedCallback(){" +
34
+ "if(closed){this.hidden=true;return}" +
35
+ "if(this.shadowRoot)return;" +
36
+ 'var root=this.attachShadow({mode:"open"}),self=this;' +
37
+ `root.innerHTML="<style>${CSS}</style>${HTML.replace(/"/g, '\\"')}";` +
38
+ 'var pill=root.querySelector(".pill"),link=root.querySelector("a"),close=root.querySelector("button");' +
39
+ 'pill.setAttribute("data-pos",load());' +
40
+ 'link.addEventListener("click",function(e){e.preventDefault();var route=self.getAttribute("data-route")||"/api/preview";' +
41
+ 'location.href=route+(route.indexOf("?")<0?"?":"&")+"disable&path="+encodeURIComponent(location.pathname+location.search)});' +
42
+ 'close.addEventListener("click",function(){closed=true;self.hidden=true});' +
43
+ "var start=null;" +
44
+ 'pill.addEventListener("pointerdown",function(e){if(e.target.closest("a,button"))return;start=e.clientX;pill.setPointerCapture(e.pointerId);pill.classList.add("dragging")});' +
45
+ 'pill.addEventListener("pointermove",function(e){if(start!==null)pill.style.translate=(e.clientX-start)+"px 0"});' +
46
+ 'pill.addEventListener("pointerup",function(e){if(start===null)return;start=null;pill.classList.remove("dragging");pill.style.translate="";' +
47
+ 'var x=e.clientX/innerWidth,pos=x<1/3?"left":x>2/3?"right":"center";pill.setAttribute("data-pos",pos);save(pos)});' +
48
+ 'pill.addEventListener("pointercancel",function(){if(start===null)return;start=null;pill.classList.remove("dragging");pill.style.translate=""});' +
49
+ "}}" +
50
+ 'customElements.define("smoodly-preview-pill",Pill);' +
51
+ "})();";
52
+ /** The pill for a route that does not go through the renderer: render it
53
+ * when `(await draftMode()).isEnabled`, never in the editor's canvas. */
54
+ export function previewPill(opts) {
55
+ return (_jsxs(_Fragment, { children: [_jsx("script", { dangerouslySetInnerHTML: { __html: PREVIEW_PILL_SCRIPT } }), createElement("smoodly-preview-pill", { "data-route": opts?.route ?? "/api/preview" })] }));
56
+ }
@@ -0,0 +1 @@
1
+ export declare function previewRoute(request: Request): Promise<Response>;
@@ -0,0 +1,30 @@
1
+ // The draft-mode switch every starter mounts at app/api/preview/route.ts
2
+ // (spec 2026-09-29 pre-install fixes §2). Turning drafts OFF is harmless,
3
+ // so `?disable` needs no secret — the preview pill's Exit links here.
4
+ // Turning them ON needs SMOODLY_PREVIEW_SECRET and fails closed when it is
5
+ // unset. Either way it returns to a same-origin path.
6
+ import { draftMode } from "next/headers";
7
+ import { redirect } from "next/navigation";
8
+ export async function previewRoute(request) {
9
+ const url = new URL(request.url);
10
+ const draft = await draftMode();
11
+ if (url.searchParams.has("disable")) {
12
+ draft.disable();
13
+ }
14
+ else {
15
+ const secret = process.env.SMOODLY_PREVIEW_SECRET;
16
+ if (!secret || url.searchParams.get("secret") !== secret)
17
+ return new Response("Invalid secret", { status: 401 });
18
+ draft.enable();
19
+ }
20
+ redirect(sameOriginPath(url.searchParams.get("path") ?? "/", url));
21
+ }
22
+ /** The path as this origin resolves it, or "/" when it would leave the site. A browser's URL parser strips
23
+ * tab, CR and LF before it resolves a Location, so "/\t/evil.test" is "//evil.test" to it: resolve the
24
+ * way it does and compare origins, and refuse control characters and backslashes outright. */
25
+ function sameOriginPath(raw, base) {
26
+ if (!raw.startsWith("/") || /[\u0000-\u001f\u007f\\]/.test(raw))
27
+ return "/";
28
+ const to = new URL(raw, base);
29
+ return to.origin === base.origin ? to.pathname + to.search + to.hash : "/";
30
+ }
@@ -1,7 +1,7 @@
1
1
  /** The minimum schema version the package needs. Moves ONLY in a release
2
2
  * that adds a table, column, function or policy the package reads; a
3
3
  * unit test holds it equal to the highest file in `migrations/`. */
4
- export declare const SCHEMA_VERSION = 5;
4
+ export declare const SCHEMA_VERSION = 6;
5
5
  export declare const SCHEMA_VERSION_KEY = "schema_version";
6
6
  export declare const CORE_FILE_RE: RegExp;
7
7
  export declare const VIEWS_FILE_RE: RegExp;
@@ -7,7 +7,7 @@
7
7
  /** The minimum schema version the package needs. Moves ONLY in a release
8
8
  * that adds a table, column, function or policy the package reads; a
9
9
  * unit test holds it equal to the highest file in `migrations/`. */
10
- export const SCHEMA_VERSION = 5;
10
+ export const SCHEMA_VERSION = 6;
11
11
  export const SCHEMA_VERSION_KEY = "schema_version";
12
12
  export const CORE_FILE_RE = /^(\d{14})_smoodly_v(\d+)\.sql$/;
13
13
  export const VIEWS_FILE_RE = /^(\d{14})_smoodly_views\.sql$/;
@@ -7,6 +7,17 @@ export declare const SPACE_COL = "\"space_id\" uuid not null default '00000000-0
7
7
  export declare const CONTENT_TABLES: readonly ["pages", "page_locales", "page_versions", "entries", "entry_locales", "entry_versions", "refs", "paths", "assets"];
8
8
  export type ContentTable = (typeof CONTENT_TABLES)[number];
9
9
  export declare function currentSpaceSQL(): string;
10
+ /** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
11
+ * the write key's insert, update and delete, each alone for its action; its
12
+ * reads go through the table's one SELECT policy. `table` is as SQL names
13
+ * it (`"pages"`, `storage.objects`). Drops the old `for all` policy first. */
14
+ export declare function writePoliciesSQL(table: string, write: string, prefix: string): string;
15
+ /** The content tables' and members' policies. A table with a public read
16
+ * has ONE select policy — its own space, not revoked, and a write key or
17
+ * the published condition — and split write policies. A table without
18
+ * one (refs, members) keeps its `for all` write policy, which is also its
19
+ * only select. */
20
+ export declare function contentPoliciesSQL(): string;
10
21
  export declare function spaceLayerSQL(): string;
11
22
  /** The media bucket and its object policies (spec 2026-09-08 media §3,
12
23
  * §12). Public read: the CDN and next/image fetch without tokens. Writes:
@@ -15,6 +26,9 @@ export declare function spaceLayerSQL(): string;
15
26
  * Schema-qualified calls: storage policies do not run with public on
16
27
  * the search path. Last in the migration, after the functions exist. */
17
28
  export declare function storageSQL(): string;
29
+ /** The media bucket's write policies: insert, update, delete for a write
30
+ * key under its own space's prefix; reads are the public select above. */
31
+ export declare function mediaPoliciesSQL(): string;
18
32
  /** The three mutation RPCs are the stores' verbs, never a browser's (spec
19
33
  * 2026-09-26 security pass §2): executable by the service role and by
20
34
  * `authenticated` (the role a cloud write key carries), not by anon or
package/dist/sql-space.js CHANGED
@@ -49,6 +49,43 @@ const PUBLISHED_READ = {
49
49
  const NEEDS_SPACE_INDEX = [
50
50
  "page_locales", "page_versions", "entry_locales", "entry_versions", "refs",
51
51
  ];
52
+ /** One permissive policy per action (spec 2026-09-29 pre-install fixes §3):
53
+ * the write key's insert, update and delete, each alone for its action; its
54
+ * reads go through the table's one SELECT policy. `table` is as SQL names
55
+ * it (`"pages"`, `storage.objects`). Drops the old `for all` policy first. */
56
+ export function writePoliciesSQL(table, write, prefix) {
57
+ return [
58
+ `drop policy if exists "${prefix}_write" on ${table};`,
59
+ `drop policy if exists "${prefix}_insert" on ${table};\ncreate policy "${prefix}_insert" on ${table} for insert\n with check (${write});`,
60
+ `drop policy if exists "${prefix}_update" on ${table};\ncreate policy "${prefix}_update" on ${table} for update\n using (${write})\n with check (${write});`,
61
+ `drop policy if exists "${prefix}_delete" on ${table};\ncreate policy "${prefix}_delete" on ${table} for delete\n using (${write});`,
62
+ ].join("\n\n");
63
+ }
64
+ const OWN = `"space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"())`;
65
+ const WRITE = `${OWN} and (select "smoodly_key_kind"()) = 'write'`;
66
+ /** The content tables' and members' policies. A table with a public read
67
+ * has ONE select policy — its own space, not revoked, and a write key or
68
+ * the published condition — and split write policies. A table without
69
+ * one (refs, members) keeps its `for all` write policy, which is also its
70
+ * only select. */
71
+ export function contentPoliciesSQL() {
72
+ const statements = [];
73
+ for (const table of [...CONTENT_TABLES, "members"]) {
74
+ const published = PUBLISHED_READ[table];
75
+ if (published === null) {
76
+ statements.push(`drop policy if exists "smoodly_write" on "${table}";\n` +
77
+ `create policy "smoodly_write" on "${table}" for all\n` +
78
+ ` using (${WRITE})\n` +
79
+ ` with check (${WRITE});`);
80
+ continue;
81
+ }
82
+ statements.push(writePoliciesSQL(`"${table}"`, WRITE, "smoodly"));
83
+ statements.push(`drop policy if exists "smoodly_read" on "${table}";\n` +
84
+ `create policy "smoodly_read" on "${table}" for select\n` +
85
+ ` using (${OWN} and ((select "smoodly_key_kind"()) = 'write' or ${published}));`);
86
+ }
87
+ return statements.join("\n\n");
88
+ }
52
89
  export function spaceLayerSQL() {
53
90
  const statements = [];
54
91
  statements.push(`
@@ -185,21 +222,9 @@ $$;`.trim());
185
222
  // an InitPlan, evaluated once per statement rather than once per row
186
223
  // — needed for smoodly_space_key_revoked, a security definer function
187
224
  // Postgres never inlines; applied to all three calls for the same win.
188
- const own = `"space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"())`;
189
- const write = `${own} and (select "smoodly_key_kind"()) = 'write'`;
190
- for (const table of [...CONTENT_TABLES, "members"]) {
191
- statements.push(`drop policy if exists "smoodly_write" on "${table}";\n` +
192
- `create policy "smoodly_write" on "${table}" for all\n` +
193
- ` using (${write})\n` +
194
- ` with check (${write});`);
195
- const published = PUBLISHED_READ[table];
196
- if (published !== null) {
197
- const predicate = `${own}${published ? ` and ${published}` : ""}`;
198
- statements.push(`drop policy if exists "smoodly_read" on "${table}";\n` +
199
- `create policy "smoodly_read" on "${table}" for select\n` +
200
- ` using (${predicate});`);
201
- }
202
- }
225
+ const own = OWN;
226
+ const write = WRITE;
227
+ statements.push(contentPoliciesSQL());
203
228
  // A write key reads its own space's row — the console URL the Members
204
229
  // page links to (spec 2026-09-25 §3). Nothing else touches spaces or
205
230
  // space_keys but the two definer functions and the service role.
@@ -235,10 +260,6 @@ $$;`.trim());
235
260
  * Schema-qualified calls: storage policies do not run with public on
236
261
  * the search path. Last in the migration, after the functions exist. */
237
262
  export function storageSQL() {
238
- const write = `"bucket_id" = 'media'` +
239
- ` and (select public."smoodly_key_kind"()) = 'write'` +
240
- ` and not (select public."smoodly_space_key_revoked"())` +
241
- ` and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text`;
242
263
  return `
243
264
  insert into storage.buckets ("id", "name", "public", "file_size_limit")
244
265
  values ('media', 'media', true, 104857600)
@@ -248,10 +269,16 @@ drop policy if exists "smoodly_media_read" on storage.objects;
248
269
  create policy "smoodly_media_read" on storage.objects for select
249
270
  using ("bucket_id" = 'media');
250
271
 
251
- drop policy if exists "smoodly_media_write" on storage.objects;
252
- create policy "smoodly_media_write" on storage.objects for all
253
- using (${write})
254
- with check (${write});`.trim();
272
+ ${mediaPoliciesSQL()}`.trim();
273
+ }
274
+ /** The media bucket's write policies: insert, update, delete for a write
275
+ * key under its own space's prefix; reads are the public select above. */
276
+ export function mediaPoliciesSQL() {
277
+ const write = `"bucket_id" = 'media'` +
278
+ ` and (select public."smoodly_key_kind"()) = 'write'` +
279
+ ` and not (select public."smoodly_space_key_revoked"())` +
280
+ ` and split_part("name", '/', 1) = (select public."smoodly_current_space"())::text`;
281
+ return writePoliciesSQL("storage.objects", write, "smoodly_media");
255
282
  }
256
283
  /** The three mutation RPCs are the stores' verbs, never a browser's (spec
257
284
  * 2026-09-26 security pass §2): executable by the service role and by
@@ -263,5 +290,6 @@ export function grantsSQL() {
263
290
  `revoke execute on function "smoodly_reorder"(text, uuid[]) from public, anon;`,
264
291
  `revoke execute on function "smoodly_reorder_pages"(uuid[]) from public, anon;`,
265
292
  `revoke execute on function "smoodly_rewrite_paths"(jsonb, jsonb) from public, anon;`,
293
+ `revoke execute on function "smoodly_space_row_cap"() from public, anon;`,
266
294
  ].join("\n");
267
295
  }
package/dist/sql.d.ts CHANGED
@@ -1,3 +1,8 @@
1
1
  import { type CollectionSchema } from "./collections.ts";
2
2
  export declare function collectionSQL(collection: CollectionSchema<any>): string;
3
+ /** Foreign keys the planner needs an index for (spec 2026-09-29 pre-install
4
+ * fixes §3): a restrict or set-null check scans the referencing table
5
+ * without one, and the versions' read policies probe published_version_id
6
+ * on every public read. The composite indexes that exist lead with space_id. */
7
+ export declare function foreignKeyIndexesSQL(): string;
3
8
  export declare function coreTablesSQL(): string;