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
@@ -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) {
package/dist/fields.d.ts CHANGED
@@ -24,6 +24,14 @@ export declare function normalizeOptions<V extends string>(options: readonly Opt
24
24
  export declare function optionsOf(options: unknown): Option[];
25
25
  /** The values a stored choice may take. */
26
26
  export declare const optionValues: (options: unknown) => string[];
27
+ /** A ref field's value at the type level: the target and how deep the ref site resolves it.
28
+ * Never a runtime value — `Values<M>` turns it into what a read returns. */
29
+ export type RefMode = "shallow" | "full" | (string & {});
30
+ export type RefTo<T, M extends RefMode = "shallow"> = {
31
+ readonly __refTo: T;
32
+ readonly __mode: M;
33
+ };
34
+ type Rebase<V, M extends RefMode> = V extends RefTo<infer T, any> ? RefTo<T, M> : V extends RefTo<infer T, any>[] ? RefTo<T, M>[] : V;
27
35
  export type FieldBuilder<Value = unknown, Flags extends BuilderFlags = {}> = {
28
36
  readonly descriptor: Descriptor;
29
37
  optional(): FieldBuilder<Value, Flags & {
@@ -43,7 +51,9 @@ export type FieldBuilder<Value = unknown, Flags extends BuilderFlags = {}> = {
43
51
  * the named refs, one level; object values nest deeper
44
52
  * - no call — hydrate the entry itself; its refs stay ids
45
53
  */
46
- resolve(...projection: (string | Record<string, unknown>)[]): FieldBuilder<Value, Flags>;
54
+ resolve(): FieldBuilder<Rebase<Value, "full">, Flags>;
55
+ resolve<const K extends string>(...keys: K[]): FieldBuilder<Rebase<Value, K>, Flags>;
56
+ resolve<const P extends Record<string, unknown>>(projection: P): FieldBuilder<Rebase<Value, keyof P & string>, Flags>;
47
57
  /** Hide the field while the condition holds (spec 2026-09-14 §12): it leaves the form and validation skips it; the stored value is kept. */
48
58
  hidden(condition: Condition): FieldBuilder<Value, Flags>;
49
59
  /** phantom, type-level only */
@@ -105,14 +115,10 @@ export declare const f: {
105
115
  * 300px sidebar cannot show one legibly (spec 2026-09-07, list field §1).
106
116
  * `.min`/`.max` count items. */
107
117
  list: <B extends FieldBuilder<any, any>>(item: B & NotLocalized<B>) => FieldBuilder<ValueOf<B>[], {}>;
108
- ref: <T>(target: () => T) => FieldBuilder<Resolved<T>, {}>;
109
- refList: <T>(target: () => T) => FieldBuilder<Resolved<T>[], {}>;
118
+ ref: <T>(target: () => T) => FieldBuilder<RefTo<T, "shallow">, {}>;
119
+ refList: <T>(target: () => T) => FieldBuilder<RefTo<T, "shallow">[], {}>;
110
120
  };
111
121
  export type ValueOf<B> = B extends FieldBuilder<infer V, infer Flags> ? Flags extends {
112
122
  optional: true;
113
123
  } ? V | undefined : V : never;
114
- /** Resolved entry for a ref target — refined when collections land. */
115
- export type Resolved<T> = T extends {
116
- __entry?: infer E;
117
- } ? E : Record<string, unknown>;
118
124
  export {};
package/dist/fields.js CHANGED
@@ -37,13 +37,13 @@ function builder(descriptor) {
37
37
  max: (n) => chain({ max: n }),
38
38
  min: (n) => chain({ min: n }),
39
39
  default: (v) => chain({ default: v }),
40
- resolve: (...projection) => {
40
+ resolve: ((...projection) => {
41
41
  if (projection.length === 0)
42
42
  return chain({ resolve: "full" });
43
43
  if (typeof projection[0] === "object")
44
44
  return chain({ resolve: projection[0] });
45
45
  return chain({ resolve: Object.fromEntries(projection.map((k) => [k, true])) });
46
- },
46
+ }),
47
47
  hidden: (condition) => chain({ hidden: condition }),
48
48
  };
49
49
  }
package/dist/global.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { CollectionSchema } from "./collections.ts";
2
2
  import type { Descriptor } from "./fields.ts";
3
3
  import type { PairedComponent } from "./pairing.tsx";
4
- import type { BuilderMap, NamespaceValues, SectionSchema } from "./schema.ts";
5
- import type { StyleProps } from "./styles.ts";
4
+ import type { BuilderMap, SectionSchema, Values } from "./schema.ts";
5
+ import type { GlobalItem, VisualGlobalItem } from "./read-shapes.ts";
6
6
  /** The reserved tree node type of a placement. */
7
7
  export declare const GLOBAL_NODE_TYPE = "global";
8
8
  /** The reserved keys a visual item's settings, styles steps and css live under in its row (spec 2026-09-14 §8). */
@@ -46,12 +46,9 @@ type GlobalInput<N extends string = string> = {
46
46
  export declare function globalItem<F extends BuilderMap, const N extends string = string>(input: GlobalInput<N> & {
47
47
  fields: F;
48
48
  section?: undefined;
49
- }): GlobalSchema<NamespaceValues<F>, N>;
49
+ }): GlobalSchema<GlobalItem<Values<F>>, N>;
50
50
  export declare function globalItem<F extends BuilderMap, St extends BuilderMap, const N extends string = string>(input: GlobalInput<N> & {
51
51
  section: PairedComponent<SectionSchema<F, St>, F, St>;
52
52
  fields?: undefined;
53
- }): GlobalSchema<NamespaceValues<F> & {
54
- settings: NamespaceValues<St>;
55
- styles: StyleProps;
56
- }, N>;
53
+ }): GlobalSchema<VisualGlobalItem<Values<F>, Values<St>>, N>;
57
54
  export {};
package/dist/index.d.ts CHANGED
@@ -51,7 +51,7 @@ export type { PathIndex } from "./path-index.ts";
51
51
  export { MemoryEntryStore } from "./entry-store.ts";
52
52
  export { SupabaseEntryStore } from "./supabase-entry-store.ts";
53
53
  export { SupabasePathIndex } from "./supabase-path-index.ts";
54
- export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryScan, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, Where, WhereValue, } from "./entry-store.ts";
54
+ export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryScan, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, Where, WhereValue, SnapshotQuery, SnapshotRow, } from "./entry-store.ts";
55
55
  export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.ts";
56
56
  export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collectionTag } from "./revalidate.ts";
57
57
  export type { AffectedTargets } from "./revalidate.ts";
@@ -66,7 +66,10 @@ export type { PageStore, PageRecord, PageLocale, PageVersion, PageScan, PageStor
66
66
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
67
67
  export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
68
68
  export type { FieldBuilder, Descriptor, ValueOf, Option, OptionInput, OptionValue } from "./fields.ts";
69
- export type { SectionSchema, ElementSchema, BuilderMap } from "./schema.ts";
69
+ export type { SectionSchema, ElementSchema, BuilderMap, Values } from "./schema.ts";
70
+ export type { CollectionSeo } from "./seo.ts";
71
+ export type { EntryRef, EntryItem, PageItem, GlobalItem, VisualGlobalItem, Seo } from "./read-shapes.ts";
72
+ export type { RefTo } from "./fields.ts";
70
73
  export type { Props, PairedComponent } from "./pairing.tsx";
71
74
  export type { CollectionSchema, EntryOrder } from "./collections.ts";
72
75
  export { DEFAULT_TOOLSET, type Toolset, type ToolsetMark, type ToolsetNode } from "./toolset.ts";
@@ -94,23 +97,7 @@ export declare const smoodly: {
94
97
  omit: string[];
95
98
  };
96
99
  css?: false;
97
- sample?: Partial<{ [K_1 in Exclude<keyof F, { [K in keyof F]: F[K] extends {
98
- __flags?: infer Fl;
99
- } ? Fl extends {
100
- optional: true;
101
- } ? K : never : never; }[keyof F]>]: import("./fields.ts").ValueOf<F[K_1]>; } & { [K_2 in { [K in keyof F]: F[K] extends {
102
- __flags?: infer Fl;
103
- } ? Fl extends {
104
- optional: true;
105
- } ? K : never : never; }[keyof F]]?: import("./fields.ts").ValueOf<F[K_2]> | undefined; } & { [K_4 in Exclude<keyof S, { [K_3 in keyof S]: S[K_3] extends {
106
- __flags?: infer Fl;
107
- } ? Fl extends {
108
- optional: true;
109
- } ? K_3 : never : never; }[keyof S]>]: import("./fields.ts").ValueOf<S[K_4]>; } & { [K_5 in { [K_3 in keyof S]: S[K_3] extends {
110
- __flags?: infer Fl;
111
- } ? Fl extends {
112
- optional: true;
113
- } ? K_3 : never : never; }[keyof S]]?: import("./fields.ts").ValueOf<S[K_5]> | undefined; }> | undefined;
100
+ sample?: Partial<{ [K in keyof import("./schema.ts").NamespaceValues<F>]: import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<F>[K]; } & { [K_1 in keyof import("./schema.ts").NamespaceValues<S>]: import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<S>[K_1]; }> | undefined;
114
101
  }): import("./schema.ts").SectionSchema<F, S>;
115
102
  element<F extends import("./schema.ts").BuilderMap = {}, S extends import("./schema.ts").BuilderMap = {}>(input: {
116
103
  name: string;
@@ -124,23 +111,7 @@ export declare const smoodly: {
124
111
  omit: string[];
125
112
  };
126
113
  css?: false;
127
- sample?: Partial<{ [K_1 in Exclude<keyof F, { [K in keyof F]: F[K] extends {
128
- __flags?: infer Fl;
129
- } ? Fl extends {
130
- optional: true;
131
- } ? K : never : never; }[keyof F]>]: import("./fields.ts").ValueOf<F[K_1]>; } & { [K_2 in { [K in keyof F]: F[K] extends {
132
- __flags?: infer Fl;
133
- } ? Fl extends {
134
- optional: true;
135
- } ? K : never : never; }[keyof F]]?: import("./fields.ts").ValueOf<F[K_2]> | undefined; } & { [K_4 in Exclude<keyof S, { [K_3 in keyof S]: S[K_3] extends {
136
- __flags?: infer Fl;
137
- } ? Fl extends {
138
- optional: true;
139
- } ? K_3 : never : never; }[keyof S]>]: import("./fields.ts").ValueOf<S[K_4]>; } & { [K_5 in { [K_3 in keyof S]: S[K_3] extends {
140
- __flags?: infer Fl;
141
- } ? Fl extends {
142
- optional: true;
143
- } ? K_3 : never : never; }[keyof S]]?: import("./fields.ts").ValueOf<S[K_5]> | undefined; }> | undefined;
114
+ sample?: Partial<{ [K in keyof import("./schema.ts").NamespaceValues<F>]: import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<F>[K] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<F>[K]; } & { [K_1 in keyof import("./schema.ts").NamespaceValues<S>]: import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any> ? string : import("./schema.ts").NamespaceValues<S>[K_1] extends import("./fields.ts").RefTo<any, any>[] ? string[] : import("./schema.ts").NamespaceValues<S>[K_1]; }> | undefined;
144
115
  } & {
145
116
  contexts: ("richtext" | "column")[];
146
117
  }): import("./schema.ts").ElementSchema<F, S>;
@@ -157,8 +128,8 @@ export declare const smoodly: {
157
128
  };
158
129
  export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.ts";
159
130
  export type { MarkNode, MarkPath, Marks, Scope } from "./marks.ts";
160
- export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig } from "./env.ts";
161
- export type { SmoodlyEnv, ContentConfig } from "./env.ts";
131
+ export { resolveSmoodlyEnv, smoodlyEnv, presenceConfig } from "./env.ts";
132
+ export type { SmoodlyEnv, PresenceConfig } from "./env.ts";
162
133
  export { clientOptions, createSmoodlyClient } from "./client.ts";
163
134
  export { createSmoodlySite, pageContextOf } from "./site.ts";
164
- export type { SiteOptions, SitePage, SitePageSchema, SiteEntry, SiteHit, SmoodlySite, LoadOptions, PageTreeItem, SiteEntryItem, EntryTreeItem, EntryQuery, } from "./site.ts";
135
+ export type { SiteOptions, SitePageSchema, SmoodlySite, LoadOptions, PageTreeItem, EntryTreeItem, EntryQuery, } from "./site.ts";
package/dist/index.js CHANGED
@@ -66,6 +66,6 @@ export const smoodly = {
66
66
  marks,
67
67
  };
68
68
  export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
69
- export { resolveSmoodlyEnv, smoodlyEnv, publicContentConfig } from "./env.js";
69
+ export { resolveSmoodlyEnv, smoodlyEnv, presenceConfig } from "./env.js";
70
70
  export { clientOptions, createSmoodlyClient } from "./client.js";
71
71
  export { createSmoodlySite, pageContextOf } from "./site.js";
@@ -43,7 +43,7 @@ export function viewsFile(collections, existing, at) {
43
43
  const changed = collections.filter((c) => existing.get(c.name) !== collectionHash(c.sql));
44
44
  if (changed.length === 0)
45
45
  return null;
46
- const text = "-- Smoodly: read views and order indexes for this app's collections, written by `npx smoodly migrate`\n" +
46
+ const text = "-- Smoodly: read views for this app's collections, written by `npx smoodly migrate`\n" +
47
47
  `-- (${at.toISOString()}). One block per collection; the marker's hash is the block's SQL, compared on the next run.\n` +
48
48
  "-- Never edit a written file: change the collection and run migrate again.\n\n" +
49
49
  changed.map((c) => `${collectionMarker(c)}\n${c.sql.trim()}`).join("\n\n") +
@@ -1,6 +1,6 @@
1
1
  export { createPageRenderer } from "./page-renderer.tsx";
2
2
  export type { PageRendererOptions, PathRenderOptions, PageRegistration } from "./page-renderer.tsx";
3
- export { metadataFrom } from "./meta.ts";
3
+ export { metadataFrom, metadataFromSeo } from "./meta.ts";
4
4
  export { createSmoodlyQueries } from "./queries.ts";
5
5
  export type { SmoodlyQueries, QueryOptions, EntriesQuery, TypedEntryItem, GlobalValue } from "./queries.ts";
6
6
  export { previewRoute } from "./preview-route.ts";
@@ -1,5 +1,5 @@
1
1
  export { createPageRenderer } from "./page-renderer.js";
2
- export { metadataFrom } from "./meta.js";
2
+ export { metadataFrom, metadataFromSeo } from "./meta.js";
3
3
  export { createSmoodlyQueries } from "./queries.js";
4
4
  export { previewRoute } from "./preview-route.js";
5
5
  export { previewPill } from "./preview-pill.js";
@@ -1,8 +1,10 @@
1
1
  import type { Metadata } from "next";
2
- /** Maps the page tree's meta onto Next metadata (spec 2026-09-10 page
3
- * fields §4). The SEO title falls back to the page's own title so every
4
- * page carries one; absent meta and no title returns {} so the app's
5
- * layout.tsx defaults apply untouched. */
2
+ import type { Seo } from "../read-shapes.ts";
3
+ /** Maps the effective SEO values onto Next metadata (spec 2026-09-10 page
4
+ * fields §4, 2026-09-29 content API contract §7). Absent values leave the
5
+ * app's layout defaults untouched: no seo at all returns {}. */
6
+ export declare function metadataFromSeo(seo: Seo | undefined): Metadata;
7
+ /** A page tree's stored meta as Next metadata; the SEO title falls back to the page's own title. */
6
8
  export declare function metadataFrom(meta: unknown, page?: {
7
9
  title?: string;
8
10
  }): Metadata;
package/dist/next/meta.js CHANGED
@@ -1,15 +1,10 @@
1
1
  import { imageUrl } from "../image/loaders.js";
2
- const isResolvedImage = (v) => typeof v === "object" && v !== null && typeof v.url === "string";
3
- /** Maps the page tree's meta onto Next metadata (spec 2026-09-10 page
4
- * fields §4). The SEO title falls back to the page's own title so every
5
- * page carries one; absent meta and no title returns {} so the app's
6
- * layout.tsx defaults apply untouched. */
7
- export function metadataFrom(meta, page) {
8
- const m = typeof meta === "object" && meta !== null && !Array.isArray(meta) ? meta : {};
9
- const str = (key) => (typeof m[key] === "string" && m[key] ? m[key] : undefined);
10
- const title = str("title") ?? (page?.title ? page.title : undefined);
11
- const description = str("description");
12
- const ogImage = m.ogImage;
2
+ import { pageSeo } from "../seo.js";
3
+ /** Maps the effective SEO values onto Next metadata (spec 2026-09-10 page
4
+ * fields §4, 2026-09-29 content API contract §7). Absent values leave the
5
+ * app's layout defaults untouched: no seo at all returns {}. */
6
+ export function metadataFromSeo(seo) {
7
+ const { title, description, image, noindex } = seo ?? {};
13
8
  const out = {};
14
9
  if (title)
15
10
  out.title = title;
@@ -20,16 +15,18 @@ export function metadataFrom(meta, page) {
20
15
  og.title = title;
21
16
  if (description)
22
17
  og.description = description;
23
- if (isResolvedImage(ogImage)) {
18
+ if (image) {
24
19
  // The delivery seam decides the URL; the cloud's transform loader (OQ 28) can serve a real 1200-wide render.
25
- og.images = [{ url: imageUrl(ogImage, { width: 1200 }), width: ogImage.width, height: ogImage.height, ...(ogImage.alt ? { alt: ogImage.alt } : {}) }];
26
- }
27
- else if (typeof ogImage === "string" && ogImage) {
28
- og.images = [ogImage];
20
+ og.images = [{ url: imageUrl(image, { width: 1200 }), width: image.width, height: image.height, ...(image.alt ? { alt: image.alt } : {}) }];
29
21
  }
30
22
  if (Object.keys(og).length > 0)
31
23
  out.openGraph = og;
32
- if (m.noindex === true)
24
+ if (noindex === true)
33
25
  out.robots = { index: false, follow: false };
34
26
  return out;
35
27
  }
28
+ /** A page tree's stored meta as Next metadata; the SEO title falls back to the page's own title. */
29
+ export function metadataFrom(meta, page) {
30
+ const m = typeof meta === "object" && meta !== null && !Array.isArray(meta) ? meta : {};
31
+ return metadataFromSeo(pageSeo(m, page?.title ?? ""));
32
+ }
@@ -16,8 +16,9 @@ import { fixedPathFor } from "../localize.js";
16
16
  import { decodeSegment, joinPath } from "../paths.js";
17
17
  import { renderPage } from "../render.js";
18
18
  import { entryTag, pageTag, globalTag } from "../revalidate.js";
19
+ import { toEntryItem } from "../read-shapes.js";
19
20
  import { pageContextOf } from "../site.js";
20
- import { metadataFrom } from "./meta.js";
21
+ import { metadataFromSeo } from "./meta.js";
21
22
  import { previewPill } from "./preview-pill.js";
22
23
  /** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
23
24
  export const CACHE_COMPONENTS_WARNING = "smoodly: Next's cacheComponents is on, and Smoodly does not support it — a missing page answers 200 with noindex instead of 404, and a redirect answers 200 instead of 308. Turn cacheComponents off in next.config.ts; the Next.js guide's \"Cache Components\" section says why (https://smoodly.io/docs/frameworks/nextjs).";
@@ -73,7 +74,7 @@ export function createPageRenderer(options) {
73
74
  console.warn(`smoodly: no entry page for collection "${hit.collection}" — the entry at ${hit.path} is a 404.`);
74
75
  notFound();
75
76
  }
76
- return _jsx(View, { entry: hit.entry, path: hit.path, locale: hit.locale, locales: hit.locales });
77
+ return _jsx(View, { entry: toEntryItem(hit.entry, hit.entry.fields, hit) });
77
78
  }
78
79
  /** hreflang from the SAME map a language switch reads (spec 2026-09-15 §3): the locales with a URL, through href. */
79
80
  function alternates(hit) {
@@ -133,7 +134,9 @@ export function createPageRenderer(options) {
133
134
  const hit = await load(target, locale, path, draft);
134
135
  if (!hit || !accept(hit))
135
136
  return {};
136
- return { ...(hit.kind === "page" ? metadataFrom(hit.tree.meta, { title: hit.title }) : {}), ...alternates(hit) };
137
+ // A page type without seo: true still gets its page title as <title>; seo: true adds the panel's values on top.
138
+ const seo = hit.seo ?? (hit.kind === "page" ? { title: hit.title } : undefined);
139
+ return { ...metadataFromSeo(seo), ...alternates(hit) };
137
140
  }
138
141
  const any = () => true;
139
142
  /** The escape hatch may only render ITS OWN registration's record. */
@@ -1,28 +1,27 @@
1
1
  import type { CollectionSchema } from "../collections.ts";
2
- import type { EntryRecord } from "../entry-store.ts";
3
2
  import type { GlobalSchema } from "../global.ts";
4
- import type { EntryQuery, PageTreeItem, SiteEntryItem, SitePage, SmoodlySite } from "../site.ts";
3
+ import { type EntryItem, type PageItem } from "../read-shapes.ts";
4
+ import type { Materialize } from "../schema.ts";
5
+ import type { EntryQuery, PageTreeItem, SmoodlySite } from "../site.ts";
5
6
  export type QueryOptions = {
6
7
  locale?: string;
7
8
  };
8
- export type EntriesQuery = Omit<EntryQuery, "locale" | "draft"> & QueryOptions;
9
+ export type EntriesQuery = Omit<EntryQuery, "locale" | "draft" | "ids"> & QueryOptions;
9
10
  type ByName<T, N> = Extract<T, {
10
11
  name: N;
11
12
  }>;
12
- /** Fields typed from the schema; a widened `CollectionSchema[]` array (names are `string`) falls back to the untyped record instead of `never`. */
13
- type EntryFields<C extends readonly CollectionSchema<any, string>[], N> = string extends C[number]["name"] ? Record<string, unknown> : NonNullable<ByName<C[number], N>["__entry"]>;
14
- /** A list item whose `entry.fields` are the collection's typed values. */
15
- export type TypedEntryItem<C extends readonly CollectionSchema<any, string>[], N> = Omit<SiteEntryItem, "entry"> & {
16
- entry: Omit<EntryRecord, "fields"> & {
17
- fields: EntryFields<C, N>;
18
- };
13
+ /** Fields typed from the schema, references materialized as `EntryRef`s; a widened `CollectionSchema[]` array (names are `string`) falls back to the untyped record instead of `never`. */
14
+ type EntryFields<C extends readonly CollectionSchema<any, string>[], N> = string extends C[number]["name"] ? Record<string, unknown> : {
15
+ [K in keyof NonNullable<ByName<C[number], N>["__entry"]>]: Materialize<NonNullable<ByName<C[number], N>["__entry"]>[K]>;
19
16
  };
17
+ /** A read item whose `fields` are the collection's typed values. */
18
+ export type TypedEntryItem<C extends readonly CollectionSchema<any, string>[], N> = EntryItem<EntryFields<C, N>>;
20
19
  export type GlobalValue<S extends readonly GlobalSchema<any, string>[], N> = string extends S[number]["name"] ? Record<string, unknown> : NonNullable<ByName<S[number], N>["__value"]>;
21
20
  export type SmoodlyQueries<C extends readonly CollectionSchema<any, string>[], S extends readonly GlobalSchema<any, string>[]> = {
22
21
  /** The published page tree in a locale, every item with its page fields. */
23
22
  getPageTree(options?: QueryOptions): Promise<PageTreeItem[]>;
24
23
  /** A page by locale-relative path ("/company/about-us") or id. */
25
- getPage(pathOrId: string, options?: QueryOptions): Promise<SitePage | null>;
24
+ getPage(pathOrId: string, options?: QueryOptions): Promise<PageItem | null>;
26
25
  /** Published entries of a collection: `where` (shared fields), `order`, `limit`, `parent` run in the store. */
27
26
  getEntries<N extends C[number]["name"]>(collection: N, options?: EntriesQuery): Promise<TypedEntryItem<C, N>[]>;
28
27
  getEntry<N extends C[number]["name"]>(collection: N, idOrPath: string, options?: QueryOptions): Promise<TypedEntryItem<C, N> | null>;
@@ -7,6 +7,7 @@ import { unstable_cache } from "next/cache";
7
7
  import { draftMode } from "next/headers";
8
8
  import { connection } from "next/server";
9
9
  import { collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
10
+ import { referencedCollections } from "../read-shapes.js";
10
11
  /** JSON with object keys sorted at every level and `undefined` dropped, so two spellings of one query share a unit. */
11
12
  function stable(value) {
12
13
  if (Array.isArray(value))
@@ -26,6 +27,7 @@ function entriesKey(o) {
26
27
  }
27
28
  export function createSmoodlyQueries(options) {
28
29
  const { site } = options;
30
+ const collectionByName = new Map(options.collections.map((c) => [c.name, c]));
29
31
  const globalByName = new Map((options.globals ?? []).map((s) => [s.name, s]));
30
32
  const localeOf = (o) => o?.locale ?? site.locales.default;
31
33
  /** Every query gates on the request (never a build prerender), then reads live in draft mode or cached under `tags`. */
@@ -59,7 +61,10 @@ export function createSmoodlyQueries(options) {
59
61
  getEntries: (collection, o = {}) => {
60
62
  const locale = localeOf(o);
61
63
  const { locale: _locale, ...query } = o;
62
- return run(["entries", collection, locale, entriesKey(o)], [collectionTag(collection)], (draft) => site.getEntries(collection, { ...query, locale, draft }));
64
+ // The unit also depends on every collection its references resolve into (spec 2026-09-29 content API contract §6).
65
+ const schema = collectionByName.get(collection);
66
+ const tags = [collection, ...(schema ? referencedCollections(schema, [...options.collections]) : [])];
67
+ return run(["entries", collection, locale, entriesKey(o)], [...new Set(tags)].map(collectionTag), (draft) => site.getEntries(collection, { ...query, locale, draft }));
63
68
  },
64
69
  async getEntry(collection, idOrPath, o) {
65
70
  const locale = localeOf(o);
@@ -70,7 +75,7 @@ export function createSmoodlyQueries(options) {
70
75
  const hit = await site.getEntry(collection, idOrPath, { locale, draft });
71
76
  // The site layer also answers a bare root slug, but the unit would sit under
72
77
  // a tag no op ever fires — so a slug is refused here rather than cached stale.
73
- if (hit && !idOrPath.startsWith("/") && hit.entry.id !== idOrPath) {
78
+ if (hit && !idOrPath.startsWith("/") && hit.id !== idOrPath) {
74
79
  throw new Error(`smoodly: getEntry("${collection}", "${idOrPath}") takes an id or a path — for a slug, pass the entry's path.`);
75
80
  }
76
81
  return hit;
package/dist/page.d.ts CHANGED
@@ -13,7 +13,8 @@ export type PageSchema<Zones extends Record<string, unknown> = Record<string, un
13
13
  * segment; the default locale's entry is required (see `fixedSlug`). */
14
14
  slug?: string | Record<string, string>;
15
15
  zones: Record<keyof Zones & string, ZonePolicy>;
16
- meta: Record<string, unknown>;
16
+ /** The SEO & Social tab exists for this template; reads return `seo` and the route's metadata uses it (spec 2026-09-29 content API contract §7). */
17
+ seo?: true;
17
18
  /** The template's own page-level fields (spec 2026-09-10 page fields §1):
18
19
  * stored in the tree per locale, typed on the view's `page.fields`. */
19
20
  fields: Record<string, Descriptor>;
@@ -27,7 +28,9 @@ export declare function pageSchema<Zones extends Record<string, object>, F exten
27
28
  title: string;
28
29
  slug?: string | Record<string, string>;
29
30
  zones: Zones;
30
- meta?: Record<string, unknown>;
31
+ seo?: true;
32
+ /** Gone: `meta: { seo: true }` is `seo: true`. */
33
+ meta?: never;
31
34
  fields?: F;
32
35
  }): PageSchema<Zones, F>;
33
36
  /** The default-locale segment of a fixed slug — what the admin lists and
package/dist/page.js CHANGED
@@ -44,6 +44,8 @@ function normalizePolicy(p) {
44
44
  export function pageSchema(input) {
45
45
  if (!input?.name)
46
46
  throw new Error("smoodly: a page requires a name.");
47
+ if (input.meta !== undefined)
48
+ throw new Error(`smoodly: page "${input.name}": meta: { seo: true } is now seo: true.`);
47
49
  // A fixed slug is decided by `slug !== undefined` at every other hop, but
48
50
  // the renderer's path lookup is truthiness-based: an empty string would read
49
51
  // as fixed to the admin and blow up in the route. Reject it at construction.
@@ -62,7 +64,7 @@ export function pageSchema(input) {
62
64
  title: input.title,
63
65
  ...(input.slug !== undefined ? { slug: input.slug } : {}),
64
66
  zones: Object.fromEntries(Object.entries(input.zones).map(([k, p]) => [k, normalizePolicy(p)])),
65
- meta: input.meta ?? {},
67
+ ...(input.seo === true ? { seo: true } : {}),
66
68
  fields,
67
69
  };
68
70
  }
package/dist/pairing.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ComponentType, ReactElement } from "react";
2
- import type { BuilderMap, ElementSchema, NamespaceValues, SectionSchema } from "./schema.ts";
2
+ import type { BuilderMap, ElementSchema, SectionSchema, Values } from "./schema.ts";
3
3
  import type { PageContext, ZoneContent } from "./render.tsx";
4
4
  import type { StyleProps } from "./styles.ts";
5
5
  /** What the view receives. A page view gets its zones and where it sits
@@ -14,28 +14,28 @@ export type Props<S> = S extends {
14
14
  __fields?: infer F extends BuilderMap;
15
15
  } ? {
16
16
  zones: Record<keyof Z & string, ZoneContent>;
17
- page: PageContext<NamespaceValues<F>>;
17
+ page: PageContext<Values<F>>;
18
18
  } : S extends {
19
19
  __f?: infer F extends BuilderMap;
20
20
  __s?: infer St extends BuilderMap;
21
- } ? NamespaceValues<F> & {
22
- settings: NamespaceValues<St>;
21
+ } ? Values<F> & {
22
+ settings: Values<St>;
23
23
  styles: StyleProps;
24
24
  } : never;
25
25
  /** What hard-coded JSX may pass: fields flat, settings optional and partial, styles optional. */
26
- type ExternalProps<F extends BuilderMap, St extends BuilderMap> = NamespaceValues<F> & {
27
- settings?: Partial<NamespaceValues<St>>;
26
+ type ExternalProps<F extends BuilderMap, St extends BuilderMap> = Values<F> & {
27
+ settings?: Partial<Values<St>>;
28
28
  styles?: StyleProps;
29
29
  };
30
30
  export type PairedComponent<Schema, F extends BuilderMap, St extends BuilderMap> = ((props: ExternalProps<F, St>) => ReactElement) & {
31
31
  schema: Schema;
32
32
  };
33
- export declare function section<F extends BuilderMap, St extends BuilderMap>(schema: SectionSchema<F, St>, View: ComponentType<NamespaceValues<F> & {
34
- settings: NamespaceValues<St>;
33
+ export declare function section<F extends BuilderMap, St extends BuilderMap>(schema: SectionSchema<F, St>, View: ComponentType<Values<F> & {
34
+ settings: Values<St>;
35
35
  styles: StyleProps;
36
36
  }>): PairedComponent<SectionSchema<F, St>, F, St>;
37
- export declare function element<F extends BuilderMap, St extends BuilderMap>(schema: ElementSchema<F, St>, View: ComponentType<NamespaceValues<F> & {
38
- settings: NamespaceValues<St>;
37
+ export declare function element<F extends BuilderMap, St extends BuilderMap>(schema: ElementSchema<F, St>, View: ComponentType<Values<F> & {
38
+ settings: Values<St>;
39
39
  styles: StyleProps;
40
40
  }>): PairedComponent<ElementSchema<F, St>, F, St>;
41
41
  export {};
@@ -16,6 +16,8 @@ export interface PathIndex {
16
16
  resolve(locale: string, path: string): Promise<PathHit | null>;
17
17
  /** locale → the target's LIVE path. */
18
18
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
19
+ /** id → the LIVE full path in one locale, for many targets at once (the resolver's EntryRef paths). */
20
+ livePathsIn(kind: "page" | "entry", ids: string[], locale: string): Promise<Record<string, string>>;
19
21
  /** The target's redirect rows. */
20
22
  redirectsOf(target: PathTarget): Promise<PathRow[]>;
21
23
  listLive(locale: string): Promise<PathRow[]>;
@@ -40,6 +42,7 @@ export declare class MemoryPathIndex implements PathIndex {
40
42
  private hit;
41
43
  resolve(locale: string, path: string): Promise<PathHit | null>;
42
44
  pathsOf(target: PathTarget): Promise<Record<string, string>>;
45
+ livePathsIn(kind: "page" | "entry", ids: string[], locale: string): Promise<Record<string, string>>;
43
46
  redirectsOf(target: PathTarget): Promise<PathRow[]>;
44
47
  /** Every live URL in the locale; an editor's row under a code address is left out, since visitors never reach it (spec §4). */
45
48
  listLive(locale: string): Promise<PathRow[]>;
@@ -61,6 +61,13 @@ export class MemoryPathIndex {
61
61
  out[r.locale] = r.path;
62
62
  return out;
63
63
  }
64
+ async livePathsIn(kind, ids, locale) {
65
+ const wanted = new Set(ids);
66
+ const out = {};
67
+ for (const r of this.shownAll(this.rows.filter((r) => !r.redirect && r.kind === kind && r.locale === locale && wanted.has(r.id))))
68
+ out[r.id] = r.path;
69
+ return out;
70
+ }
64
71
  async redirectsOf(target) {
65
72
  return this.shownAll(this.rows.filter((r) => r.redirect && sameTarget(r, target)));
66
73
  }