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.
- package/README.md +811 -1321
- package/dist/admin/editor/EditorView.js +10 -3
- package/dist/admin/editor/autosave.d.ts +6 -1
- package/dist/admin/editor/autosave.js +43 -3
- package/dist/admin/forms/list.js +10 -1
- package/dist/admin/shell/EntryForm.js +3 -2
- package/dist/admin/shell/GlobalForm.js +3 -2
- package/dist/admin/shell/router.d.ts +5 -0
- package/dist/admin/shell/router.js +13 -0
- package/dist/next/canvas-slot.d.ts +7 -0
- package/dist/next/canvas-slot.js +9 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/page-renderer.d.ts +2 -0
- package/dist/next/page-renderer.js +12 -8
- package/dist/next/preview-pill.d.ts +9 -0
- package/dist/next/preview-pill.js +56 -0
- package/dist/next/preview-route.d.ts +1 -0
- package/dist/next/preview-route.js +30 -0
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/sql-space.d.ts +14 -0
- package/dist/sql-space.js +51 -23
- package/dist/sql.d.ts +5 -0
- package/dist/sql.js +18 -2
- package/migrations/20260929135523_smoodly_v6.sql +200 -0
- package/package.json +1 -5
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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),
|
|
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 =
|
|
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),
|
|
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(() =>
|
|
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
|
}
|
package/dist/admin/forms/list.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,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));
|
package/dist/next/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/next/index.js
CHANGED
|
@@ -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
|
|
6
|
-
// the app, exactly like
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/dist/schema-version.d.ts
CHANGED
|
@@ -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 =
|
|
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;
|
package/dist/schema-version.js
CHANGED
|
@@ -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 =
|
|
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$/;
|
package/dist/sql-space.d.ts
CHANGED
|
@@ -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 =
|
|
189
|
-
const write =
|
|
190
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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;
|