smoodly 0.0.35 → 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 (64) hide show
  1. package/README.md +29 -38
  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 +29 -0
  22. package/dist/entry-store.js +53 -0
  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
package/README.md CHANGED
@@ -523,6 +523,22 @@ Implemented:
523
523
  (the rule table, both adapters), the store contracts, `admin-ops`
524
524
  ("redirects on rename and move", "redirects ops"),
525
525
  `next-page-renderer` ("redirects"), `examples/site/e2e/redirects.spec.ts`.
526
+ - Content API contract (spec 2026-09-29 content API contract): every
527
+ read returns the public shape — `EntryItem`, `PageItem`, `GlobalItem`
528
+ (`src/read-shapes.ts`), nothing of other locales, pointers or editors
529
+ leaves the site layer; a reference resolves as `{ id, path, fields }`
530
+ as deep as its field says, in lists and single reads alike, and the
531
+ types follow the depth; `smoodly_list_entries` (schema version 7)
532
+ filters, orders and limits over the snapshot shown, so `where` sees
533
+ localized fields and never the live row; a list is tagged by every
534
+ collection it resolved into; `seo: true` or `{ description, image }`
535
+ on a collection adds the SEO & Social panel, stored per locale under
536
+ `$seo` (`src/seo.ts`); every content table admits a write key only, the
537
+ publishable key serves sign-in and presence (`presenceConfig`). Tests:
538
+ `test/entry-store-contract.ts` ("query"), `test/site.test.tsx`,
539
+ `test/next-queries.test.tsx`, `test/types-refs.test.ts`,
540
+ `test/seo.test.ts`, `test/isolation.supabase.test.ts`,
541
+ `test/rls.supabase.test.ts`.
526
542
  - Editor safety (spec 2026-09-27 editor safety): schema drift — stored
527
543
  content the code no longer describes — is found by one pure walk
528
544
  (`admin/drift.ts`) over the serialized registry. Broken findings
@@ -692,31 +708,7 @@ goes straight into its bucket. Design-level questions live in
692
708
 
693
709
  ### Now
694
710
 
695
- - SEO for entry pages: entries have no `meta`; a `titleField`-based
696
- title and a description field are the next step. *(2026-09-10, page fields and SEO)*
697
- - `where` filters a versioned collection's LIVE row, not its published
698
- snapshot: an entry whose published version had `kind: "x"` and whose
699
- draft changed it to `"y"` drops out of `where: { kind: "x" }` before
700
- the change is published. The published gate is per locale row, the
701
- filter is on the node's shared fields. Fix by filtering the snapshot
702
- (a join on the version row) or by documenting that a versioned
703
- collection's shared fields are live for filtering. *(2026-09-11, queries)*
704
- - `getEntries`/`getEntriesTree` return refs as ids (cheap lists); `getEntry`
705
- by path resolves them, `getEntry` by id or slug does not — document or
706
- unify when a site needs resolved lists. Worse than it reads: the field
707
- types promise resolved objects everywhere (`f.ref` builds
708
- `Resolved<T>`), and `guides/collections/queries.mdx` says `getEntry`
709
- "by id or by its URL path" resolves references, so a developer's code
710
- typechecks and fails at runtime. Decide the rule and make the types,
711
- the runtime and the guide agree. *(2026-09-05; raised to Now 2026-09-29)*
712
- - The read API returns the site layer's shapes unprojected: `getEntries`
713
- gives `{ entry: EntryRecord, path }` (the locales map, sort, parentId,
714
- collection and timestamps ride along under `entry`), `getPage` gives
715
- the renderer's `SitePage` (`record`, `tree`). Project in `queries.ts`,
716
- at the public boundary, to `{ id, path, fields, updatedAt }` per entry
717
- and a page without renderer internals; the tree item is already lean.
718
- Do it before the Astro glue (OQ 29) wraps the same reads and before
719
- a second site depends on the shape — it is a breaking change to `q`. *(2026-09-11, queries)*
711
+ Nothing open.
720
712
 
721
713
  ### Next
722
714
 
@@ -1199,13 +1191,17 @@ goes straight into its bucket. Design-level questions live in
1199
1191
 
1200
1192
  #### Queries (`q`)
1201
1193
 
1202
- - The read client over the read key (DESIGN.md OQ 31) can reuse `publicContent()`. *(2026-09-26, concurrency)*
1203
- - `where` on localized fields (a join on the locale row), ranges and
1204
- negation; any-of over an array-valued field (any-of compares the text
1205
- form of the field, so it covers selects, refs and numbers; on a
1206
- checkboxes, refList or list field `site.getEntries` refuses it rather
1207
- than match nothing — both adapters agree on the text form, so a
1208
- one-element array never matches its element). *(2026-09-11, queries)*
1194
+ - `presence()` serves the presence channel only; a future read gateway (DESIGN.md OQ 31) is not built on it — no key but a write key reads content. *(2026-09-26, concurrency; 2026-09-30 §8)*
1195
+ - `where` ranges and negation. *(2026-09-11, queries)*
1196
+ - `EntryItem.updatedAt`, and `order: updatedAt`, read the LIVE locale row, so a draft autosave bumps the public timestamp and can reorder a public list; read the published version's `saved_at` for published reads. *(2026-09-30, content API contract final review)*
1197
+ - The bare-slug `getEntry` fallback re-reads the whole collection and resolves every entry's references and media before matching one slug; match on `record` first, then resolve only the hit (or query by slug). *(2026-09-30, content API contract final review)*
1198
+ - `SmoodlySite.resolve` (and `load`) still return the full `EntryRecord` (every locale, `updatedBy`) on the object the developer holds; the types are unexported and the spec calls it internal — mark it `@internal` or move both off the public interface. *(2026-09-30, content API contract final review)*
1199
+ - A nested `.resolve({ a: { b: true } })` types only the top level (`Rebase<V, keyof P>`) while the runtime resolves deeper; type the nesting. *(2026-09-30, content API contract final review)*
1200
+ - Any-of over an array-valued field: any-of compares the text form of
1201
+ the field, so it covers selects, refs and numbers; on a checkboxes,
1202
+ refList or list field `site.getEntries` refuses it rather than match
1203
+ nothing — both adapters agree on the text form, so a one-element array
1204
+ never matches its element. *(2026-09-11, queries)*
1209
1205
  - `getEntriesTree` has no `q` counterpart yet; add one when a site
1210
1206
  needs a nested collection nav. *(2026-09-11, queries)*
1211
1207
  - `getShared(["header", "footer", "contact"], { locale })` returning a
@@ -1278,12 +1274,10 @@ goes straight into its bucket. Design-level questions live in
1278
1274
  locale's cached unit of that page (spec 2026-09-06 §4). A
1279
1275
  `page:<id>:<locale>` tag would halve the revalidations on a bilingual
1280
1276
  site. *(2026-09-06)*
1277
+ - Examples and guides write `url(entry.path!)`: `path` is `string | null` on every entry, though a collection that declares `path` always has one — type it `string` for those and drop the assertions. *(2026-09-30, content API contract final review)*
1281
1278
  - `smoodlyMetadata` does not apply the registration check `renderSmoodlyPath`
1282
1279
  applies: a record whose `template` has no registration is a 404 body with
1283
1280
  its meta emitted beside it. *(2026-09-05)*
1284
- - The entry-page view receives the whole `EntryRecord`, every locale's
1285
- row in `locales` included (fields, slug, status and version pointers); a
1286
- client entry page would ship all of it to the browser. *(2026-09-05)*
1287
1281
 
1288
1282
  #### Media
1289
1283
 
@@ -1439,9 +1433,6 @@ goes straight into its bucket. Design-level questions live in
1439
1433
  actor-free parts. *(2026-09-13, history)*
1440
1434
  - A GIN index on `entries.fields` when a customer's collection is large
1441
1435
  enough to need it; additive, no migration. *(2026-09-11, queries)*
1442
- - `getEntry` by id goes through `site.getEntry`, which lists the whole
1443
- collection to find one row; a primary-key read first would be cheaper
1444
- on the uncached path (draft mode, first miss). *(2026-09-11, queries)*
1445
1436
  - Every revalidating op (publish, delete, a save of published content)
1446
1437
  answers with a fresh RSC render of the admin route — the layout's
1447
1438
  styles included, ~30 KB — because `updateTag` runs inside the server
@@ -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
  }
@@ -239,6 +262,10 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
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
  }