smoodly 0.0.34 → 0.0.36
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 +807 -1326
- package/dist/admin/drift.js +3 -2
- package/dist/admin/editor/EditorView.js +10 -3
- package/dist/admin/editor/SeoSettings.d.ts +7 -1
- package/dist/admin/editor/SeoSettings.js +2 -2
- package/dist/admin/editor/autosave.d.ts +6 -1
- package/dist/admin/editor/autosave.js +43 -3
- package/dist/admin/field-label.d.ts +2 -0
- package/dist/admin/field-label.js +2 -0
- package/dist/admin/forms/FieldWidget.js +2 -2
- package/dist/admin/forms/list.js +10 -1
- package/dist/admin/next/create-admin.d.ts +4 -4
- package/dist/admin/next/create-admin.js +5 -5
- package/dist/admin/next/index.d.ts +1 -1
- package/dist/admin/serialize.d.ts +8 -1
- package/dist/admin/serialize.js +23 -7
- package/dist/admin/shell/AdminApp.d.ts +3 -3
- package/dist/admin/shell/AdminApp.js +2 -2
- package/dist/admin/shell/EntryForm.js +11 -3
- package/dist/admin/shell/GlobalForm.js +3 -2
- package/dist/admin/shell/presence-realtime.d.ts +2 -2
- package/dist/admin/shell/router.d.ts +5 -0
- package/dist/admin/shell/router.js +13 -0
- package/dist/cli/space-io.js +1 -1
- package/dist/collections.d.ts +4 -0
- package/dist/collections.js +9 -1
- package/dist/entry-page.d.ts +12 -10
- package/dist/entry-store.d.ts +29 -0
- package/dist/entry-store.js +53 -0
- package/dist/env.d.ts +5 -5
- package/dist/env.js +2 -2
- package/dist/fields.d.ts +13 -7
- package/dist/fields.js +2 -2
- package/dist/global.d.ts +4 -7
- package/dist/index.d.ts +10 -39
- package/dist/index.js +1 -1
- package/dist/migrations.js +1 -1
- package/dist/next/canvas-slot.d.ts +7 -0
- package/dist/next/canvas-slot.js +9 -0
- package/dist/next/index.d.ts +3 -1
- package/dist/next/index.js +3 -1
- package/dist/next/meta.d.ts +6 -4
- package/dist/next/meta.js +14 -17
- package/dist/next/page-renderer.d.ts +2 -0
- package/dist/next/page-renderer.js +18 -11
- 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/next/queries.d.ts +10 -11
- package/dist/next/queries.js +7 -2
- package/dist/page.d.ts +5 -2
- package/dist/page.js +3 -1
- package/dist/pairing.d.ts +10 -10
- package/dist/path-index.d.ts +3 -0
- package/dist/path-index.js +7 -0
- package/dist/read-shapes.d.ts +64 -0
- package/dist/read-shapes.js +52 -0
- package/dist/render.d.ts +3 -3
- package/dist/resolve.d.ts +12 -0
- package/dist/resolve.js +29 -7
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/schema.d.ts +22 -1
- package/dist/seo.d.ts +18 -0
- package/dist/seo.js +53 -0
- package/dist/site.d.ts +15 -16
- package/dist/site.js +64 -58
- package/dist/sql-space.d.ts +15 -0
- package/dist/sql-space.js +54 -39
- package/dist/sql.d.ts +11 -1
- package/dist/sql.js +78 -25
- package/dist/supabase-entry-store.d.ts +3 -1
- package/dist/supabase-entry-store.js +39 -0
- package/dist/supabase-path-index.d.ts +1 -0
- package/dist/supabase-path-index.js +16 -0
- package/migrations/20260929135523_smoodly_v6.sql +200 -0
- package/migrations/20260930040956_smoodly_v7.sql +84 -0
- package/package.json +1 -5
package/dist/admin/drift.js
CHANGED
|
@@ -2,6 +2,7 @@ import { optionValues } from "../fields.js";
|
|
|
2
2
|
import { isHidden } from "../conditions.js";
|
|
3
3
|
import { allowedOf } from "../toolset.js";
|
|
4
4
|
import { CUSTOM_NODE_TYPE } from "../custom/schema.js";
|
|
5
|
+
import { SEO_KEY } from "../seo.js";
|
|
5
6
|
import { CSS_KEY, GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY } from "../global.js";
|
|
6
7
|
export const SEVERITY = {
|
|
7
8
|
"unknown-section": "broken", "unknown-element": "broken", "unknown-shape": "broken", "unknown-collection": "broken",
|
|
@@ -209,7 +210,7 @@ export function pageDrift(registry, template, tree) {
|
|
|
209
210
|
out.push(...holderDrift(registry, { scope: "page", name: shape.name }, "page.fields", { fields: tree.fields ?? {} }, { fields: shape.fields }));
|
|
210
211
|
return out;
|
|
211
212
|
}
|
|
212
|
-
const RESERVED = new Set(["slug", SETTINGS_KEY, STYLES_KEY, CSS_KEY]);
|
|
213
|
+
const RESERVED = new Set(["slug", SEO_KEY, SETTINGS_KEY, STYLES_KEY, CSS_KEY]);
|
|
213
214
|
function localization(subject, descs, input, out) {
|
|
214
215
|
if (!input.shared || !input.own)
|
|
215
216
|
return;
|
|
@@ -231,7 +232,7 @@ export function entryDrift(registry, name, input) {
|
|
|
231
232
|
const collection = registry.collections.find((c) => c.name === name);
|
|
232
233
|
if (collection) {
|
|
233
234
|
const subject = { scope: "collection", name };
|
|
234
|
-
const { slug: _slug, ...fields } = input.fields;
|
|
235
|
+
const { slug: _slug, [SEO_KEY]: _seo, ...fields } = input.fields;
|
|
235
236
|
out.push(...holderDrift(registry, subject, "", { fields }, { fields: collection.fields }));
|
|
236
237
|
localization(subject, collection.fields, input, out);
|
|
237
238
|
return out;
|
|
@@ -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);
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
export declare const SEO_TITLE_MAX = 60;
|
|
2
2
|
export declare const SEO_DESCRIPTION_MAX = 160;
|
|
3
3
|
export type SeoKey = "title" | "description" | "ogImage" | "noindex";
|
|
4
|
-
export declare function SeoSettings({ meta, onPatch }: {
|
|
4
|
+
export declare function SeoSettings({ meta, onPatch, titleHint, descriptionHint, imageHint }: {
|
|
5
5
|
meta: Record<string, unknown>;
|
|
6
6
|
onPatch(key: SeoKey, value: unknown): void;
|
|
7
|
+
/** What an empty title does; the page editor's default names the page title (an entry's names its title field). */
|
|
8
|
+
titleHint?: string;
|
|
9
|
+
/** What an empty description does, when it falls back on a field. */
|
|
10
|
+
descriptionHint?: string;
|
|
11
|
+
/** What an empty social image does, when it falls back on a field. */
|
|
12
|
+
imageHint?: string;
|
|
7
13
|
}): import("react").JSX.Element;
|
|
@@ -8,8 +8,8 @@ const str = (v) => (typeof v === "string" ? v : "");
|
|
|
8
8
|
function Counter({ n, max }) {
|
|
9
9
|
return _jsxs("span", { className: n > max ? "sm-counter--over" : undefined, children: [n, "/", max] });
|
|
10
10
|
}
|
|
11
|
-
export function SeoSettings({ meta, onPatch }) {
|
|
11
|
+
export function SeoSettings({ meta, onPatch, titleHint, descriptionHint, imageHint }) {
|
|
12
12
|
const title = str(meta.title);
|
|
13
13
|
const description = str(meta.description);
|
|
14
|
-
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint:
|
|
14
|
+
return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: `Used in search results and link previews. ${titleHint ?? "Empty: the page title."}`, children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), hint: descriptionHint, children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(MediaWidget, { descriptor: { type: "image" }, value: meta.ogImage, onChange: (v) => onPatch("ogImage", v), label: "Social image", hint: imageHint ? `1200×630 recommended. ${imageHint}` : "1200×630 recommended" }), _jsx(Field, { label: "Hide from search engines", asDiv: true, children: _jsx(Switch, { checked: meta.noindex === true, onChange: (v) => onPatch("noindex", v), label: "Hide from search engines" }) })] }));
|
|
15
15
|
}
|
|
@@ -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
|
}
|
|
@@ -11,10 +11,10 @@ import { optionsOf } from "../../fields.js";
|
|
|
11
11
|
import { isHidden } from "../../conditions.js";
|
|
12
12
|
import { cssProblem } from "../../css.js";
|
|
13
13
|
import { Button, Field, Switch } from "../ui/primitives.js";
|
|
14
|
+
import { fieldLabel } from "../field-label.js";
|
|
14
15
|
import { patchObject } from "./list.js";
|
|
15
16
|
import { tidySlug } from "./slug-field.js";
|
|
16
|
-
|
|
17
|
-
const label = (key) => key === "css" ? "Custom CSS" : key.replace(/([A-Z])/g, (c) => ` ${c.toLowerCase()}`).replace(/^./, (c) => c.toUpperCase());
|
|
17
|
+
const label = fieldLabel;
|
|
18
18
|
export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, suggestions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, hint, onBlur, }) {
|
|
19
19
|
const path = pathProp ?? fieldKey;
|
|
20
20
|
// A tags field has no per-chip slot: the first item error shows under the field.
|
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)
|
|
@@ -2,15 +2,15 @@ import type { ReactNode } from "react";
|
|
|
2
2
|
import type { ResolvedConfig } from "../../config.ts";
|
|
3
3
|
import type { AdminOpAction } from "../ops.ts";
|
|
4
4
|
import type { AuthConfig } from "../shell/session.ts";
|
|
5
|
-
import type {
|
|
6
|
-
/** `
|
|
7
|
-
* concurrency §5), async because its space is asked of the database; absent or failed, presence
|
|
5
|
+
import type { PresenceConfig } from "../../env.ts";
|
|
6
|
+
/** `presence` (optional) is the content project's public config for editing presence (spec 2026-09-26
|
|
7
|
+
* concurrency §5; presence only, never content reads, spec 2026-09-29 content API contract §8), async because its space is asked of the database; absent or failed, presence
|
|
8
8
|
* is off and everything else works. */
|
|
9
9
|
export declare function createSmoodlyAdmin(input: {
|
|
10
10
|
config: ResolvedConfig;
|
|
11
11
|
op: AdminOpAction;
|
|
12
12
|
auth: () => AuthConfig;
|
|
13
|
-
|
|
13
|
+
presence?: () => PresenceConfig | Promise<PresenceConfig>;
|
|
14
14
|
}): {
|
|
15
15
|
AdminPage: () => null;
|
|
16
16
|
AdminLayout: ({ children }: {
|
|
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { serializeAdminConfig } from "../serialize.js";
|
|
3
3
|
import { AdminApp } from "../shell/AdminApp.js";
|
|
4
4
|
import { AdminStyles } from "../ui/theme.js";
|
|
5
|
-
/** `
|
|
6
|
-
* concurrency §5), async because its space is asked of the database; absent or failed, presence
|
|
5
|
+
/** `presence` (optional) is the content project's public config for editing presence (spec 2026-09-26
|
|
6
|
+
* concurrency §5; presence only, never content reads, spec 2026-09-29 content API contract §8), async because its space is asked of the database; absent or failed, presence
|
|
7
7
|
* is off and everything else works. */
|
|
8
8
|
export function createSmoodlyAdmin(input) {
|
|
9
9
|
const registry = serializeAdminConfig(input.config);
|
|
@@ -28,14 +28,14 @@ export function createSmoodlyAdmin(input) {
|
|
|
28
28
|
// Presence is the one thing that needs it: a failed lookup (an unmigrated project, an
|
|
29
29
|
// unreachable database) is logged and turns presence off, so the shell still reaches
|
|
30
30
|
// sign-in, the schema notice or the admin-path ErrorPane.
|
|
31
|
-
let
|
|
31
|
+
let presenceConfig;
|
|
32
32
|
try {
|
|
33
|
-
|
|
33
|
+
presenceConfig = await input.presence?.();
|
|
34
34
|
}
|
|
35
35
|
catch (e) {
|
|
36
36
|
console.error("smoodly: editing presence is off —", e instanceof Error ? e.message : e);
|
|
37
37
|
}
|
|
38
|
-
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(),
|
|
38
|
+
return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth(), presenceConfig: presenceConfig }), children] }));
|
|
39
39
|
}
|
|
40
40
|
return { AdminPage, AdminLayout };
|
|
41
41
|
}
|
|
@@ -4,5 +4,5 @@ export type { AdminOpCall, AdminOpAction, AdminOps, OpResult, MembersListResult
|
|
|
4
4
|
export type { InviteInput, InviteResult, MemberRecord } from "../../member-store.ts";
|
|
5
5
|
export type { MemberRole } from "../../permissions.ts";
|
|
6
6
|
export type { AuthConfig } from "../shell/session.ts";
|
|
7
|
-
export type {
|
|
7
|
+
export type { PresenceConfig } from "../../env.ts";
|
|
8
8
|
export type { AdminAuth } from "../auth.ts";
|
|
@@ -34,6 +34,13 @@ export type AdminCollection = {
|
|
|
34
34
|
depth: number;
|
|
35
35
|
/** `order: "manual"` — the list drags into the `sort` order (spec 2026-09-09 §5). */
|
|
36
36
|
manual: boolean;
|
|
37
|
+
/** The collection has SEO & Social values (spec 2026-09-29 content API contract §7); each label
|
|
38
|
+
* names, in lower case, the field an empty value falls back on — the hints' copy. */
|
|
39
|
+
seo?: {
|
|
40
|
+
titleLabel: string;
|
|
41
|
+
descriptionLabel?: string;
|
|
42
|
+
imageLabel?: string;
|
|
43
|
+
};
|
|
37
44
|
};
|
|
38
45
|
export type AdminPageTemplate = {
|
|
39
46
|
name: string;
|
|
@@ -45,7 +52,7 @@ export type AdminPageTemplate = {
|
|
|
45
52
|
zones: Record<string, ZonePolicy>;
|
|
46
53
|
/** The template's page fields (spec 2026-09-10 page fields §5), edited on the Settings tab. */
|
|
47
54
|
fields: Record<string, AdminField>;
|
|
48
|
-
/** `
|
|
55
|
+
/** `seo: true` — the SEO & Social tab exists for this template. */
|
|
49
56
|
seo: boolean;
|
|
50
57
|
};
|
|
51
58
|
export type AdminGlobal = {
|
package/dist/admin/serialize.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { fixedSlug } from "../page.js";
|
|
2
2
|
import { collectionDepth, collectionOrder } from "../collections.js";
|
|
3
|
+
import { SEO_KEY } from "../seo.js";
|
|
4
|
+
import { fieldLabel } from "./field-label.js";
|
|
3
5
|
import { perLocaleSegments } from "../paths.js";
|
|
4
6
|
import { styleDescriptors } from "../styles.js";
|
|
5
7
|
import { CUSTOM_NODE_TYPE, customFields, defaultRow } from "../custom/index.js";
|
|
@@ -32,6 +34,15 @@ export function sectionSettings(config, name) {
|
|
|
32
34
|
function sectionCss(config, name) {
|
|
33
35
|
return config.css && (config.registry.sections ?? []).find((s) => s.schema.name === name)?.schema.css !== false;
|
|
34
36
|
}
|
|
37
|
+
const lower = (key) => fieldLabel(key).toLowerCase();
|
|
38
|
+
function seoLabels(c) {
|
|
39
|
+
const named = c.seo === true || c.seo === undefined ? {} : c.seo;
|
|
40
|
+
return {
|
|
41
|
+
titleLabel: c.titleField ? lower(c.titleField) : "entry title",
|
|
42
|
+
...(named.description ? { descriptionLabel: lower(named.description) } : {}),
|
|
43
|
+
...(named.image ? { imageLabel: lower(named.image) } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
35
46
|
export function serializeAdminConfig(config) {
|
|
36
47
|
const perLocale = (v) => perLocaleSegments(v, config.locales.supported);
|
|
37
48
|
return {
|
|
@@ -62,12 +73,17 @@ export function serializeAdminConfig(config) {
|
|
|
62
73
|
sample: e.sample,
|
|
63
74
|
};
|
|
64
75
|
}),
|
|
65
|
-
collections: (config.registry.collections ?? []).map((c) =>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
collections: (config.registry.collections ?? []).map((c) => {
|
|
77
|
+
// `$seo` is the SEO panel's, never a form field.
|
|
78
|
+
const { [SEO_KEY]: _seo, ...fields } = c.fields;
|
|
79
|
+
return {
|
|
80
|
+
name: c.name, title: c.title, titleField: c.titleField, fields: serializeMap(fields),
|
|
81
|
+
...(c.path !== undefined ? { path: perLocale(c.path) } : {}),
|
|
82
|
+
depth: collectionDepth(c),
|
|
83
|
+
manual: collectionOrder(c) === "manual",
|
|
84
|
+
...(c.seo !== undefined ? { seo: seoLabels(c) } : {}),
|
|
85
|
+
};
|
|
86
|
+
}),
|
|
71
87
|
pages: (config.registry.pages ?? [])
|
|
72
88
|
.filter(({ schema }) => schema.kind === "page")
|
|
73
89
|
.map(({ schema }) => {
|
|
@@ -80,7 +96,7 @@ export function serializeAdminConfig(config) {
|
|
|
80
96
|
...(typeof p.slug === "object" && p.slug !== null ? { slugs: { ...p.slug } } : {}),
|
|
81
97
|
zones: p.zones ?? {},
|
|
82
98
|
fields: serializeMap(p.fields),
|
|
83
|
-
seo: p.
|
|
99
|
+
seo: p.seo === true,
|
|
84
100
|
};
|
|
85
101
|
}),
|
|
86
102
|
globals: (config.registry.globals ?? []).map((s) => ({
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { AdminOpAction } from "../ops.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
3
|
import { type AuthConfig } from "./session.ts";
|
|
4
|
-
import type {
|
|
5
|
-
export declare function AdminApp({ registry, op, auth,
|
|
4
|
+
import type { PresenceConfig } from "../../env.ts";
|
|
5
|
+
export declare function AdminApp({ registry, op, auth, presenceConfig }: {
|
|
6
6
|
registry: AdminRegistry;
|
|
7
7
|
op: AdminOpAction;
|
|
8
8
|
auth: AuthConfig;
|
|
9
|
-
|
|
9
|
+
presenceConfig?: PresenceConfig;
|
|
10
10
|
}): import("react").JSX.Element;
|
|
@@ -39,7 +39,7 @@ import { schemaBehind } from "../../schema-version.js";
|
|
|
39
39
|
import { createPresence, followTokenRefresh } from "./presence.js";
|
|
40
40
|
import { connectRealtime } from "./presence-realtime.js";
|
|
41
41
|
import { PresenceProvider } from "./presence-context.js";
|
|
42
|
-
export function AdminApp({ registry, op, auth,
|
|
42
|
+
export function AdminApp({ registry, op, auth, presenceConfig }) {
|
|
43
43
|
const session = useAdminSession(auth);
|
|
44
44
|
const [ops] = useState(() => makeClientOps(op, session.token));
|
|
45
45
|
const route = useRoute(registry.adminPath);
|
|
@@ -89,7 +89,7 @@ export function AdminApp({ registry, op, auth, content }) {
|
|
|
89
89
|
const tokenRef = useRef(session.token);
|
|
90
90
|
tokenRef.current = session.token;
|
|
91
91
|
const authClient = session.client;
|
|
92
|
-
const contentUrl =
|
|
92
|
+
const contentUrl = presenceConfig?.url, contentKey = presenceConfig?.key, contentSpace = presenceConfig?.spaceId;
|
|
93
93
|
useEffect(() => {
|
|
94
94
|
if (!signedIn || !contentUrl || !contentKey || !contentSpace || !email) {
|
|
95
95
|
setPresence(null);
|
|
@@ -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";
|
|
@@ -49,6 +49,8 @@ import { usePresence, usePresenceState, useSavedElsewhere, useTrackPresence } fr
|
|
|
49
49
|
import { editorsOf } from "./presence.js";
|
|
50
50
|
import { PresenceLine } from "../ui/PresenceLine.js";
|
|
51
51
|
import { slugHint, tidySlug, withFollowingSlug } from "../forms/slug-field.js";
|
|
52
|
+
import { SeoSettings } from "../editor/SeoSettings.js";
|
|
53
|
+
import { SEO_KEY } from "../../seo.js";
|
|
52
54
|
/** A stable empty map for the hooks that run before the "no such collection" return. */
|
|
53
55
|
const NO_FIELDS = {};
|
|
54
56
|
/** True while the entry's slug box has focus: its field wrapper carries the path. */
|
|
@@ -139,6 +141,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
139
141
|
const r = await ops.entries.save(collection, id, locale, value, token(), { redirect: redirectRef.current });
|
|
140
142
|
if (r.ok) {
|
|
141
143
|
redirectRef.current = true;
|
|
144
|
+
recordRef.current = r.data;
|
|
142
145
|
setRecord(r.data);
|
|
143
146
|
setSaveErrors({});
|
|
144
147
|
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
@@ -155,7 +158,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
155
158
|
setDirty(false); },
|
|
156
159
|
});
|
|
157
160
|
// Reload and close ask only while a save is pending or in flight.
|
|
158
|
-
|
|
161
|
+
useUnloadGuard(dirty || saveState !== "saved");
|
|
159
162
|
const refTargets = useMemo(() => {
|
|
160
163
|
const out = {};
|
|
161
164
|
for (const [key, d] of Object.entries(meta?.fields ?? {})) {
|
|
@@ -477,5 +480,10 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
477
480
|
setDirty(true);
|
|
478
481
|
setTouched(true);
|
|
479
482
|
mark();
|
|
480
|
-
} })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), (redirects[locale] ?? []).length > 0 && _jsx(InfoRow, { label: "Redirects from", value: redirects[locale].join(", "), mono: true }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && (_jsxs("div", { className: "sm-card", children: [
|
|
483
|
+
} })] })) : !notice && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", children: [record && row && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Created", value: relativeTime(record.createdAt) }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), (redirects[locale] ?? []).length > 0 && _jsx(InfoRow, { label: "Redirects from", value: redirects[locale].join(", "), mono: true }), multilingual && _jsx(InfoRow, { label: "Languages", value: Object.keys(record.locales).join(", ") }), _jsx(InfoRow, { label: "ID", value: id, mono: true })] })), record && row && meta.seo && (_jsxs("div", { className: "sm-card", children: [_jsx(SectionLabel, { children: "SEO & Social" }), _jsx(SeoSettings, { meta: fields?.[SEO_KEY] ?? {}, titleHint: `Empty: the ${meta.seo.titleLabel}.`, descriptionHint: meta.seo.descriptionLabel ? `Empty: the ${meta.seo.descriptionLabel}.` : undefined, imageHint: meta.seo.imageLabel ? `Empty: the ${meta.seo.imageLabel}.` : undefined, onPatch: (key, value) => {
|
|
484
|
+
setFields((f) => ({ ...(f ?? {}), [SEO_KEY]: { ...(f?.[SEO_KEY] ?? {}), [key]: value } }));
|
|
485
|
+
setDirty(true);
|
|
486
|
+
setTouched(true);
|
|
487
|
+
mark();
|
|
488
|
+
} })] })), record && (_jsxs("div", { className: "sm-card", children: [multilingual && row && Object.keys(record.locales).length > 1 && (_jsxs(Button, { variant: "secondary", onClick: () => void doRemoveLocale(), children: ["Remove from ", locale] })), _jsx(Button, { variant: "danger", onClick: destroy, children: "Delete" })] }))] })] })] }));
|
|
481
489
|
}
|
|
@@ -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 ?? {})) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PresenceConfig } from "../../env.ts";
|
|
2
2
|
import type { PresenceChannel } from "./presence.ts";
|
|
3
|
-
export declare function connectRealtime(config:
|
|
3
|
+
export declare function connectRealtime(config: PresenceConfig, email: string, token: string): PresenceChannel;
|
|
@@ -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
|
+
}
|
package/dist/cli/space-io.js
CHANGED
|
@@ -47,7 +47,7 @@ export async function readSchemaVersion(db) {
|
|
|
47
47
|
}
|
|
48
48
|
export function schemaRefusal(missing, command) {
|
|
49
49
|
return missing === "key"
|
|
50
|
-
? `${command} needs SMOODLY_SECRET_KEY — the service-role key, or the cloud's write key. The publishable key
|
|
50
|
+
? `${command} needs SMOODLY_SECRET_KEY — the service-role key, or the cloud's write key. The publishable key reads no content.`
|
|
51
51
|
: "This database has no Smoodly schema. Run `npx smoodly migrate`, then `supabase db push` (hosted) or `supabase db reset` (local).";
|
|
52
52
|
}
|
|
53
53
|
/** Every row of one table in one space, paged in primary-key order. The space filter is explicit: a service-role key sees every space. */
|
package/dist/collections.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Descriptor } from "./fields.ts";
|
|
2
2
|
import type { BuilderMap, NamespaceValues } from "./schema.ts";
|
|
3
|
+
import { type CollectionSeo } from "./seo.ts";
|
|
3
4
|
/** How a collection is ordered — in the admin list and in public reads
|
|
4
5
|
* alike (spec 2026-09-05 §3). "manual" is the drag order (`sort`). */
|
|
5
6
|
export type EntryOrder = "manual" | {
|
|
@@ -29,6 +30,8 @@ export type CollectionSchema<F extends BuilderMap = BuilderMap, N extends string
|
|
|
29
30
|
admin?: {
|
|
30
31
|
listColumns?: string[];
|
|
31
32
|
};
|
|
33
|
+
/** Entries carry SEO & Social values (spec 2026-09-29 content API contract §7): stored under `$seo`, empty values falling back on `titleField` and the named fields. */
|
|
34
|
+
seo?: CollectionSeo;
|
|
32
35
|
/** phantom: the entry value shape, used by f.ref target inference */
|
|
33
36
|
readonly __entry?: NamespaceValues<F>;
|
|
34
37
|
};
|
|
@@ -47,4 +50,5 @@ export declare function collection<F extends BuilderMap, const N extends string
|
|
|
47
50
|
admin?: {
|
|
48
51
|
listColumns?: string[];
|
|
49
52
|
};
|
|
53
|
+
seo?: CollectionSeo;
|
|
50
54
|
}): CollectionSchema<F, N>;
|
package/dist/collections.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SEO_DESCRIPTOR, SEO_KEY, validateCollectionSeo } from "./seo.js";
|
|
1
2
|
export const DEFAULT_ORDER = { by: "createdAt", direction: "desc" };
|
|
2
3
|
export const BUILT_IN_ORDER_FIELDS = ["createdAt", "updatedAt"];
|
|
3
4
|
export const collectionOrder = (c) => c.order ?? DEFAULT_ORDER;
|
|
@@ -7,6 +8,9 @@ export function collection(input) {
|
|
|
7
8
|
throw new Error("smoodly: a collection requires a name.");
|
|
8
9
|
if (["global", "asset", "media"].includes(input.name))
|
|
9
10
|
throw new Error(`smoodly: "${input.name}" is a reserved name.`);
|
|
11
|
+
if (Object.prototype.hasOwnProperty.call(input.fields, SEO_KEY)) {
|
|
12
|
+
throw new Error(`smoodly: collection "${input.name}": "${SEO_KEY}" is a reserved key.`);
|
|
13
|
+
}
|
|
10
14
|
if (input.tree !== undefined && (!Number.isInteger(input.tree.depth) || input.tree.depth < 1)) {
|
|
11
15
|
throw new Error(`smoodly: collection "${input.name}" declares an invalid tree depth.`);
|
|
12
16
|
}
|
|
@@ -49,12 +53,16 @@ export function collection(input) {
|
|
|
49
53
|
throw new Error(`smoodly: collection "${input.name}" slug field "${key}" follows an unknown field "${from}".`);
|
|
50
54
|
}
|
|
51
55
|
}
|
|
56
|
+
const fields = Object.fromEntries(Object.entries(input.fields).map(([k, b]) => [k, b.descriptor]));
|
|
57
|
+
if (input.seo !== undefined)
|
|
58
|
+
validateCollectionSeo(input.name, input.seo, fields);
|
|
52
59
|
return {
|
|
53
60
|
kind: "collection",
|
|
54
61
|
name: input.name,
|
|
55
62
|
title: input.title,
|
|
56
63
|
titleField: input.titleField,
|
|
57
|
-
fields:
|
|
64
|
+
fields: input.seo !== undefined ? { ...fields, [SEO_KEY]: SEO_DESCRIPTOR } : fields,
|
|
65
|
+
...(input.seo !== undefined ? { seo: input.seo } : {}),
|
|
58
66
|
...(input.path !== undefined ? { path: input.path } : {}),
|
|
59
67
|
...(input.tree !== undefined ? { tree: input.tree } : {}),
|
|
60
68
|
...(input.order !== undefined ? { order: input.order } : {}),
|