smoodly 0.0.35 → 0.0.37

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 (64) hide show
  1. package/README.md +24 -1611
  2. package/dist/admin/drift.js +3 -2
  3. package/dist/admin/editor/SeoSettings.d.ts +7 -1
  4. package/dist/admin/editor/SeoSettings.js +2 -2
  5. package/dist/admin/field-label.d.ts +2 -0
  6. package/dist/admin/field-label.js +2 -0
  7. package/dist/admin/forms/FieldWidget.js +2 -2
  8. package/dist/admin/next/create-admin.d.ts +4 -4
  9. package/dist/admin/next/create-admin.js +5 -5
  10. package/dist/admin/next/index.d.ts +1 -1
  11. package/dist/admin/serialize.d.ts +8 -1
  12. package/dist/admin/serialize.js +23 -7
  13. package/dist/admin/shell/AdminApp.d.ts +3 -3
  14. package/dist/admin/shell/AdminApp.js +2 -2
  15. package/dist/admin/shell/EntryForm.js +8 -1
  16. package/dist/admin/shell/presence-realtime.d.ts +2 -2
  17. package/dist/cli/space-io.js +1 -1
  18. package/dist/collections.d.ts +4 -0
  19. package/dist/collections.js +9 -1
  20. package/dist/entry-page.d.ts +12 -10
  21. package/dist/entry-store.d.ts +30 -1
  22. package/dist/entry-store.js +54 -1
  23. package/dist/env.d.ts +5 -5
  24. package/dist/env.js +2 -2
  25. package/dist/fields.d.ts +13 -7
  26. package/dist/fields.js +2 -2
  27. package/dist/global.d.ts +4 -7
  28. package/dist/index.d.ts +10 -39
  29. package/dist/index.js +1 -1
  30. package/dist/migrations.js +1 -1
  31. package/dist/next/index.d.ts +1 -1
  32. package/dist/next/index.js +1 -1
  33. package/dist/next/meta.d.ts +6 -4
  34. package/dist/next/meta.js +14 -17
  35. package/dist/next/page-renderer.js +6 -3
  36. package/dist/next/queries.d.ts +10 -11
  37. package/dist/next/queries.js +7 -2
  38. package/dist/page.d.ts +5 -2
  39. package/dist/page.js +3 -1
  40. package/dist/pairing.d.ts +10 -10
  41. package/dist/path-index.d.ts +3 -0
  42. package/dist/path-index.js +7 -0
  43. package/dist/read-shapes.d.ts +64 -0
  44. package/dist/read-shapes.js +52 -0
  45. package/dist/render.d.ts +3 -3
  46. package/dist/resolve.d.ts +12 -0
  47. package/dist/resolve.js +29 -7
  48. package/dist/schema-version.d.ts +1 -1
  49. package/dist/schema-version.js +1 -1
  50. package/dist/schema.d.ts +22 -1
  51. package/dist/seo.d.ts +18 -0
  52. package/dist/seo.js +53 -0
  53. package/dist/site.d.ts +15 -16
  54. package/dist/site.js +64 -58
  55. package/dist/sql-space.d.ts +6 -5
  56. package/dist/sql-space.js +11 -24
  57. package/dist/sql.d.ts +6 -1
  58. package/dist/sql.js +60 -23
  59. package/dist/supabase-entry-store.d.ts +3 -1
  60. package/dist/supabase-entry-store.js +39 -0
  61. package/dist/supabase-path-index.d.ts +1 -0
  62. package/dist/supabase-path-index.js +16 -0
  63. package/migrations/20260930040956_smoodly_v7.sql +84 -0
  64. package/package.json +1 -1
@@ -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;
@@ -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
  }
@@ -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.
@@ -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);
@@ -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. */
@@ -478,5 +480,10 @@ export function EntryForm({ ops, registry, collection, id }) {
478
480
  setDirty(true);
479
481
  setTouched(true);
480
482
  mark();
481
- } })] })) : !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" })] }))] })] })] }));
482
489
  }
@@ -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;
@@ -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 } : {}),
@@ -1,21 +1,23 @@
1
1
  import type { ComponentType, ReactElement } from "react";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
- import type { EntryRecord } from "./entry-store.ts";
4
- import type { LocaleAlternate } from "./paths.ts";
3
+ import type { EntryItem } from "./read-shapes.ts";
4
+ import type { Materialize } from "./schema.ts";
5
5
  export type EntryPageSchema = {
6
6
  kind: "entryPage";
7
7
  name: string;
8
8
  collection: string;
9
9
  };
10
- /** What an entry page renders: the entry in the render locale, its path
11
- * there, and its address in every supported locale (spec 2026-09-15 §1). */
12
- export type EntryPageProps = {
13
- entry: EntryRecord;
14
- path: string;
15
- locale: string;
16
- locales: Record<string, LocaleAlternate | null>;
10
+ /** What an entry page renders: the entry as a read returns it — its path, locale and
11
+ * locale alternates ride on it (spec 2026-09-29 content API contract §3). */
12
+ export type EntryPageProps<F = Record<string, unknown>> = {
13
+ entry: EntryItem<F>;
17
14
  };
18
15
  export type PairedEntryPage = ((props: EntryPageProps) => ReactElement) & {
19
16
  schema: EntryPageSchema;
20
17
  };
21
- export declare function entryPage(collection: CollectionSchema<any>, View: ComponentType<EntryPageProps>): PairedEntryPage;
18
+ /** The collection's fields as a read returns them. */
19
+ type Materialized<C extends CollectionSchema<any>> = {
20
+ [K in keyof NonNullable<C["__entry"]>]: Materialize<NonNullable<C["__entry"]>[K]>;
21
+ };
22
+ export declare function entryPage<C extends CollectionSchema<any>>(collection: C, View: ComponentType<EntryPageProps<Materialized<C>>>): PairedEntryPage;
23
+ export {};
@@ -81,6 +81,22 @@ export type ListOptions = {
81
81
  */
82
82
  parent?: string | null;
83
83
  };
84
+ export type SnapshotQuery = {
85
+ locale: string;
86
+ /** true: the live row (the editor preview); false: the published snapshot. */
87
+ draft: boolean;
88
+ where?: Where;
89
+ order?: EntryOrder;
90
+ limit?: number;
91
+ parent?: string | null;
92
+ /** Only these entries (getEntry by id). */
93
+ ids?: string[];
94
+ };
95
+ /** `fields` is the merged snapshot (shared, localized, `slug`) of the version shown; `record` never leaves the site layer. */
96
+ export type SnapshotRow = {
97
+ record: EntryRecord;
98
+ fields: Record<string, unknown>;
99
+ };
84
100
  export type CreateEntryOptions = {
85
101
  locale: string;
86
102
  parentId?: string | null;
@@ -102,6 +118,11 @@ export interface EntryStore {
102
118
  create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
103
119
  get(collection: string, id: string): Promise<EntryRecord | null>;
104
120
  list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
121
+ /** Filters, orders and limits over the version SHOWN: the published snapshot (a row
122
+ * without one falls back to the live merge), or the live row when `draft` (spec
123
+ * 2026-09-29 content API contract §5). Only rows present in `locale`; the published
124
+ * path also needs the row published. */
125
+ query(collection: string, q: SnapshotQuery): Promise<SnapshotRow[]>;
105
126
  /** Every entry row, registered collection or not, with each published locale's live fields (spec 2026-09-27 editor safety §3). */
106
127
  scanEntries(): Promise<EntryScan[]>;
107
128
  /** Batched read for the resolver: the MERGED fields in `locale`, keyed
@@ -168,6 +189,8 @@ export interface EntryStore {
168
189
  }[]>;
169
190
  resolvePath(locale: string, path: string): Promise<PathHit | null>;
170
191
  pathsOf(collection: string, id: string): Promise<Record<string, string>>;
192
+ /** id → full live path in `locale` for many entries of one collection; ids without one are absent. */
193
+ pathsIn(collection: string, ids: string[], locale: string): Promise<Record<string, string>>;
171
194
  /** locale → this entry's old addresses. */
172
195
  redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
173
196
  }
@@ -235,10 +258,14 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
235
258
  * the creation tiebreak follows `direction` for non-manual orders.
236
259
  * Declared fields compare numerically when both values are numbers,
237
260
  * otherwise as strings via `localeCompare` — the same order Postgres
238
- * gives the jsonb VALUE (`fields->x`) for one type; see the README's
261
+ * gives the jsonb VALUE (`fields->x`) for one type; see MAINTAINERS.md's
239
262
  * follow-ups for the mixed-type and JSON-null edges.
240
263
  */
241
264
  export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
265
+ /** Comparator for `query`: like compareEntries, but a declared field reads the
266
+ * merged snapshot, `updatedAt` the locale row's, and an absent, null or empty-string value
267
+ * sorts last in both directions; ties break by creation order (ascending). */
268
+ export declare function compareSnapshots(order: EntryOrder, locale: string, seq: (id: string) => number): (a: SnapshotRow, b: SnapshotRow) => number;
242
269
  export declare class MemoryEntryStore implements EntryStore {
243
270
  private refs;
244
271
  private entries;
@@ -287,6 +314,7 @@ export declare class MemoryEntryStore implements EntryStore {
287
314
  create(collection: string, fields: Record<string, unknown>, options: CreateEntryOptions, by?: string): Promise<EntryRecord>;
288
315
  get(collection: string, id: string): Promise<EntryRecord | null>;
289
316
  list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
317
+ query(collection: string, q: SnapshotQuery): Promise<SnapshotRow[]>;
290
318
  getMany(collection: string, ids: string[], locale: string, options?: {
291
319
  status?: "published";
292
320
  }): Promise<Record<string, Record<string, unknown>>>;
@@ -315,6 +343,7 @@ export declare class MemoryEntryStore implements EntryStore {
315
343
  sourceId: string;
316
344
  }[]>;
317
345
  resolvePath(locale: string, path: string): Promise<PathHit | null>;
346
+ pathsIn(collection: string, ids: string[], locale: string): Promise<Record<string, string>>;
318
347
  pathsOf(collection: string, id: string): Promise<{}>;
319
348
  redirectsOf(collection: string, id: string): Promise<Record<string, string[]>>;
320
349
  }
@@ -150,7 +150,7 @@ export function entryTitleIn(record, locale, schema) {
150
150
  * the creation tiebreak follows `direction` for non-manual orders.
151
151
  * Declared fields compare numerically when both values are numbers,
152
152
  * otherwise as strings via `localeCompare` — the same order Postgres
153
- * gives the jsonb VALUE (`fields->x`) for one type; see the README's
153
+ * gives the jsonb VALUE (`fields->x`) for one type; see MAINTAINERS.md's
154
154
  * follow-ups for the mixed-type and JSON-null edges.
155
155
  */
156
156
  export function compareEntries(order, seq) {
@@ -172,6 +172,32 @@ export function compareEntries(order, seq) {
172
172
  return d !== 0 ? d * dir : (seq(a) - seq(b)) * dir;
173
173
  };
174
174
  }
175
+ /** Comparator for `query`: like compareEntries, but a declared field reads the
176
+ * merged snapshot, `updatedAt` the locale row's, and an absent, null or empty-string value
177
+ * sorts last in both directions; ties break by creation order (ascending). */
178
+ export function compareSnapshots(order, locale, seq) {
179
+ return (a, b) => {
180
+ if (order === "manual") {
181
+ const d = (a.record.sort ?? Infinity) - (b.record.sort ?? Infinity);
182
+ return d !== 0 ? d : seq(a.record.id) - seq(b.record.id);
183
+ }
184
+ const dir = order.direction === "asc" ? 1 : -1;
185
+ const value = (r) => order.by === "createdAt" ? r.record.createdAt : order.by === "updatedAt" ? r.record.locales[locale]?.updatedAt : r.fields[order.by];
186
+ const empty = (v) => (v === "" ? undefined : v); // a cleared text or date is empty too
187
+ const va = empty(value(a)), vb = empty(value(b));
188
+ const tie = seq(a.record.id) - seq(b.record.id);
189
+ if (order.by === "createdAt")
190
+ return tie * dir; // the clock can tie within a millisecond; creation order cannot
191
+ if (va == null && vb == null)
192
+ return tie;
193
+ if (va == null)
194
+ return 1;
195
+ if (vb == null)
196
+ return -1;
197
+ const d = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));
198
+ return d !== 0 ? d * dir : tie;
199
+ };
200
+ }
175
201
  export class MemoryEntryStore {
176
202
  constructor(collections,
177
203
  // pass the SAME index to MemoryPageStore so cross-kind questions work
@@ -389,6 +415,29 @@ export class MemoryEntryStore {
389
415
  rows = rows.slice(0, options.limit);
390
416
  return rows.map((e) => this.snapshot(e));
391
417
  }
418
+ async query(collection, q) {
419
+ const schema = this.schema(collection);
420
+ const wanted = q.ids ? new Set(q.ids) : null;
421
+ const rows = [];
422
+ for (const e of this.entries.values()) {
423
+ if (e.collection !== collection)
424
+ continue;
425
+ if (wanted && !wanted.has(e.id))
426
+ continue;
427
+ if (q.parent !== undefined && e.parentId !== q.parent)
428
+ continue;
429
+ const row = e.locales[q.locale];
430
+ if (!row || (!q.draft && row.status !== "published"))
431
+ continue;
432
+ const snapshot = !q.draft && row.publishedVersionId ? this.versions.get(row.publishedVersionId) : undefined;
433
+ const fields = snapshot ? structuredClone(snapshot.fields) : entryFieldsIn(e, q.locale);
434
+ if (q.where && !matchesWhere(fields, q.where))
435
+ continue;
436
+ rows.push({ record: this.snapshot(e), fields });
437
+ }
438
+ rows.sort(compareSnapshots(q.order ?? collectionOrder(schema), q.locale, (id) => this.order.get(id)));
439
+ return typeof q.limit === "number" ? rows.slice(0, q.limit) : rows;
440
+ }
392
441
  async getMany(collection, ids, locale, options) {
393
442
  this.schema(collection);
394
443
  const out = {};
@@ -644,6 +693,10 @@ export class MemoryEntryStore {
644
693
  resolvePath(locale, path) {
645
694
  return this.paths.resolve(locale, path);
646
695
  }
696
+ pathsIn(collection, ids, locale) {
697
+ this.schema(collection);
698
+ return this.paths.livePathsIn("entry", ids, locale);
699
+ }
647
700
  pathsOf(collection, id) {
648
701
  return this.row(collection, id) ? this.paths.pathsOf({ kind: "entry", id }) : Promise.resolve({});
649
702
  }
package/dist/env.d.ts CHANGED
@@ -4,7 +4,7 @@ export type SmoodlyEnv = {
4
4
  url: string;
5
5
  /** Server-only: the service-role key (self-host) or the environment's write key (cloud). */
6
6
  secretKey: string;
7
- /** What a browser may hold for the content project: anon/publishable key, or the read key. */
7
+ /** What a browser may hold for the content project: anon/publishable key, or the cloud's per-environment key. It reads no content (sign-in and presence only). */
8
8
  publishableKey: string;
9
9
  /** Where editors sign in. Defaults to `url`. */
10
10
  authUrl: string;
@@ -15,12 +15,12 @@ export declare function resolveSmoodlyEnv(env: Record<string, string | undefined
15
15
  /** `resolveSmoodlyEnv(process.env)` — call lazily, never at module load,
16
16
  * so `next build` without env still succeeds. */
17
17
  export declare const smoodlyEnv: () => SmoodlyEnv;
18
- /** What the browser needs to join the content project's presence channel (spec 2026-09-26 concurrency §5): public values only. */
19
- export type ContentConfig = {
18
+ /** What the browser needs to join the admin's presence channel: the content project's URL, publishable key and space, public values only — never for content reads (spec 2026-09-29 content API contract §8). */
19
+ export type PresenceConfig = {
20
20
  url: string;
21
21
  key: string;
22
22
  spaceId: string;
23
23
  };
24
- /** The public config, with the space the database says the secret key acts in —
24
+ /** The presence config, with the space the database says the secret key acts in —
25
25
  * asked, never read out of the key, so it cannot disagree with where rows land. */
26
- export declare function publicContentConfig(env: SmoodlyEnv, db?: Parameters<typeof readCurrentSpace>[0]): Promise<ContentConfig>;
26
+ export declare function presenceConfig(env: SmoodlyEnv, db?: Parameters<typeof readCurrentSpace>[0]): Promise<PresenceConfig>;
package/dist/env.js CHANGED
@@ -25,9 +25,9 @@ export const smoodlyEnv = () => resolveSmoodlyEnv(process.env);
25
25
  /** One lookup per process per content project and key: the space never
26
26
  * changes for a key. A failed lookup is not cached, so the next call retries. */
27
27
  const spaces = new Map();
28
- /** The public config, with the space the database says the secret key acts in —
28
+ /** The presence config, with the space the database says the secret key acts in —
29
29
  * asked, never read out of the key, so it cannot disagree with where rows land. */
30
- export function publicContentConfig(env, db) {
30
+ export function presenceConfig(env, db) {
31
31
  const cacheKey = `${env.url}\n${env.secretKey}`;
32
32
  let space = spaces.get(cacheKey);
33
33
  if (!space) {