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.
Files changed (79) hide show
  1. package/README.md +807 -1326
  2. package/dist/admin/drift.js +3 -2
  3. package/dist/admin/editor/EditorView.js +10 -3
  4. package/dist/admin/editor/SeoSettings.d.ts +7 -1
  5. package/dist/admin/editor/SeoSettings.js +2 -2
  6. package/dist/admin/editor/autosave.d.ts +6 -1
  7. package/dist/admin/editor/autosave.js +43 -3
  8. package/dist/admin/field-label.d.ts +2 -0
  9. package/dist/admin/field-label.js +2 -0
  10. package/dist/admin/forms/FieldWidget.js +2 -2
  11. package/dist/admin/forms/list.js +10 -1
  12. package/dist/admin/next/create-admin.d.ts +4 -4
  13. package/dist/admin/next/create-admin.js +5 -5
  14. package/dist/admin/next/index.d.ts +1 -1
  15. package/dist/admin/serialize.d.ts +8 -1
  16. package/dist/admin/serialize.js +23 -7
  17. package/dist/admin/shell/AdminApp.d.ts +3 -3
  18. package/dist/admin/shell/AdminApp.js +2 -2
  19. package/dist/admin/shell/EntryForm.js +11 -3
  20. package/dist/admin/shell/GlobalForm.js +3 -2
  21. package/dist/admin/shell/presence-realtime.d.ts +2 -2
  22. package/dist/admin/shell/router.d.ts +5 -0
  23. package/dist/admin/shell/router.js +13 -0
  24. package/dist/cli/space-io.js +1 -1
  25. package/dist/collections.d.ts +4 -0
  26. package/dist/collections.js +9 -1
  27. package/dist/entry-page.d.ts +12 -10
  28. package/dist/entry-store.d.ts +29 -0
  29. package/dist/entry-store.js +53 -0
  30. package/dist/env.d.ts +5 -5
  31. package/dist/env.js +2 -2
  32. package/dist/fields.d.ts +13 -7
  33. package/dist/fields.js +2 -2
  34. package/dist/global.d.ts +4 -7
  35. package/dist/index.d.ts +10 -39
  36. package/dist/index.js +1 -1
  37. package/dist/migrations.js +1 -1
  38. package/dist/next/canvas-slot.d.ts +7 -0
  39. package/dist/next/canvas-slot.js +9 -0
  40. package/dist/next/index.d.ts +3 -1
  41. package/dist/next/index.js +3 -1
  42. package/dist/next/meta.d.ts +6 -4
  43. package/dist/next/meta.js +14 -17
  44. package/dist/next/page-renderer.d.ts +2 -0
  45. package/dist/next/page-renderer.js +18 -11
  46. package/dist/next/preview-pill.d.ts +9 -0
  47. package/dist/next/preview-pill.js +56 -0
  48. package/dist/next/preview-route.d.ts +1 -0
  49. package/dist/next/preview-route.js +30 -0
  50. package/dist/next/queries.d.ts +10 -11
  51. package/dist/next/queries.js +7 -2
  52. package/dist/page.d.ts +5 -2
  53. package/dist/page.js +3 -1
  54. package/dist/pairing.d.ts +10 -10
  55. package/dist/path-index.d.ts +3 -0
  56. package/dist/path-index.js +7 -0
  57. package/dist/read-shapes.d.ts +64 -0
  58. package/dist/read-shapes.js +52 -0
  59. package/dist/render.d.ts +3 -3
  60. package/dist/resolve.d.ts +12 -0
  61. package/dist/resolve.js +29 -7
  62. package/dist/schema-version.d.ts +1 -1
  63. package/dist/schema-version.js +1 -1
  64. package/dist/schema.d.ts +22 -1
  65. package/dist/seo.d.ts +18 -0
  66. package/dist/seo.js +53 -0
  67. package/dist/site.d.ts +15 -16
  68. package/dist/site.js +64 -58
  69. package/dist/sql-space.d.ts +15 -0
  70. package/dist/sql-space.js +54 -39
  71. package/dist/sql.d.ts +11 -1
  72. package/dist/sql.js +78 -25
  73. package/dist/supabase-entry-store.d.ts +3 -1
  74. package/dist/supabase-entry-store.js +39 -0
  75. package/dist/supabase-path-index.d.ts +1 -0
  76. package/dist/supabase-path-index.js +16 -0
  77. package/migrations/20260929135523_smoodly_v6.sql +200 -0
  78. package/migrations/20260930040956_smoodly_v7.sql +84 -0
  79. package/package.json +1 -5
@@ -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
- 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);
@@ -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: "Used in search results and link previews. 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 }), 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: "1200\u00D7630 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" }) })] }));
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), 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
  }
@@ -0,0 +1,2 @@
1
+ /** A key as a caption, sentence case: `spacingTop` → "Spacing top", `css` → "Custom CSS". */
2
+ export declare const fieldLabel: (key: string) => string;
@@ -0,0 +1,2 @@
1
+ /** A key as a caption, sentence case: `spacingTop` → "Spacing top", `css` → "Custom CSS". */
2
+ export const fieldLabel = (key) => key === "css" ? "Custom CSS" : key.replace(/([A-Z])/g, (c) => ` ${c.toLowerCase()}`).replace(/^./, (c) => c.toUpperCase());
@@ -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
- /** A key as a caption, sentence case: `spacingTop` → "Spacing top", `css` → "Custom CSS". */
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.
@@ -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)
@@ -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 { ContentConfig } from "../../env.ts";
6
- /** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
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
- content?: () => ContentConfig | Promise<ContentConfig>;
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
- /** `content` (optional) is the content project's public config for editing presence (spec 2026-09-26
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 content;
31
+ let presenceConfig;
32
32
  try {
33
- content = await input.content?.();
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(), content: content }), children] }));
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 { ContentConfig } from "../../env.ts";
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
- /** `meta: { seo: true }` — the SEO & Social tab exists for this template. */
55
+ /** `seo: true` — the SEO & Social tab exists for this template. */
49
56
  seo: boolean;
50
57
  };
51
58
  export type AdminGlobal = {
@@ -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
- name: c.name, title: c.title, titleField: c.titleField, fields: serializeMap(c.fields),
67
- ...(c.path !== undefined ? { path: perLocale(c.path) } : {}),
68
- depth: collectionDepth(c),
69
- manual: collectionOrder(c) === "manual",
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.meta?.seo === true,
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 { ContentConfig } from "../../env.ts";
5
- export declare function AdminApp({ registry, op, auth, content }: {
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
- content?: ContentConfig;
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, content }) {
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 = content?.url, contentKey = content?.key, contentSpace = content?.spaceId;
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, 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";
@@ -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
- useLeaveGuard(dirty || saveState !== "saved");
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: [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" })] }))] })] })] }));
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 { 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 ?? {})) {
@@ -1,3 +1,3 @@
1
- import type { ContentConfig } from "../../env.ts";
1
+ import type { PresenceConfig } from "../../env.ts";
2
2
  import type { PresenceChannel } from "./presence.ts";
3
- export declare function connectRealtime(config: ContentConfig, email: string, token: string): PresenceChannel;
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
+ }
@@ -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 sees published content only.`
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. */
@@ -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>;
@@ -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: Object.fromEntries(Object.entries(input.fields).map(([k, b]) => [k, b.descriptor])),
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 } : {}),