smoodly 0.0.34 → 0.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +807 -1326
  2. package/dist/admin/drift.js +3 -2
  3. package/dist/admin/editor/EditorView.js +10 -3
  4. package/dist/admin/editor/SeoSettings.d.ts +7 -1
  5. package/dist/admin/editor/SeoSettings.js +2 -2
  6. package/dist/admin/editor/autosave.d.ts +6 -1
  7. package/dist/admin/editor/autosave.js +43 -3
  8. package/dist/admin/field-label.d.ts +2 -0
  9. package/dist/admin/field-label.js +2 -0
  10. package/dist/admin/forms/FieldWidget.js +2 -2
  11. package/dist/admin/forms/list.js +10 -1
  12. package/dist/admin/next/create-admin.d.ts +4 -4
  13. package/dist/admin/next/create-admin.js +5 -5
  14. package/dist/admin/next/index.d.ts +1 -1
  15. package/dist/admin/serialize.d.ts +8 -1
  16. package/dist/admin/serialize.js +23 -7
  17. package/dist/admin/shell/AdminApp.d.ts +3 -3
  18. package/dist/admin/shell/AdminApp.js +2 -2
  19. package/dist/admin/shell/EntryForm.js +11 -3
  20. package/dist/admin/shell/GlobalForm.js +3 -2
  21. package/dist/admin/shell/presence-realtime.d.ts +2 -2
  22. package/dist/admin/shell/router.d.ts +5 -0
  23. package/dist/admin/shell/router.js +13 -0
  24. package/dist/cli/space-io.js +1 -1
  25. package/dist/collections.d.ts +4 -0
  26. package/dist/collections.js +9 -1
  27. package/dist/entry-page.d.ts +12 -10
  28. package/dist/entry-store.d.ts +29 -0
  29. package/dist/entry-store.js +53 -0
  30. package/dist/env.d.ts +5 -5
  31. package/dist/env.js +2 -2
  32. package/dist/fields.d.ts +13 -7
  33. package/dist/fields.js +2 -2
  34. package/dist/global.d.ts +4 -7
  35. package/dist/index.d.ts +10 -39
  36. package/dist/index.js +1 -1
  37. package/dist/migrations.js +1 -1
  38. package/dist/next/canvas-slot.d.ts +7 -0
  39. package/dist/next/canvas-slot.js +9 -0
  40. package/dist/next/index.d.ts +3 -1
  41. package/dist/next/index.js +3 -1
  42. package/dist/next/meta.d.ts +6 -4
  43. package/dist/next/meta.js +14 -17
  44. package/dist/next/page-renderer.d.ts +2 -0
  45. package/dist/next/page-renderer.js +18 -11
  46. package/dist/next/preview-pill.d.ts +9 -0
  47. package/dist/next/preview-pill.js +56 -0
  48. package/dist/next/preview-route.d.ts +1 -0
  49. package/dist/next/preview-route.js +30 -0
  50. package/dist/next/queries.d.ts +10 -11
  51. package/dist/next/queries.js +7 -2
  52. package/dist/page.d.ts +5 -2
  53. package/dist/page.js +3 -1
  54. package/dist/pairing.d.ts +10 -10
  55. package/dist/path-index.d.ts +3 -0
  56. package/dist/path-index.js +7 -0
  57. package/dist/read-shapes.d.ts +64 -0
  58. package/dist/read-shapes.js +52 -0
  59. package/dist/render.d.ts +3 -3
  60. package/dist/resolve.d.ts +12 -0
  61. package/dist/resolve.js +29 -7
  62. package/dist/schema-version.d.ts +1 -1
  63. package/dist/schema-version.js +1 -1
  64. package/dist/schema.d.ts +22 -1
  65. package/dist/seo.d.ts +18 -0
  66. package/dist/seo.js +53 -0
  67. package/dist/site.d.ts +15 -16
  68. package/dist/site.js +64 -58
  69. package/dist/sql-space.d.ts +15 -0
  70. package/dist/sql-space.js +54 -39
  71. package/dist/sql.d.ts +11 -1
  72. package/dist/sql.js +78 -25
  73. package/dist/supabase-entry-store.d.ts +3 -1
  74. package/dist/supabase-entry-store.js +39 -0
  75. package/dist/supabase-path-index.d.ts +1 -0
  76. package/dist/supabase-path-index.js +16 -0
  77. package/migrations/20260929135523_smoodly_v6.sql +200 -0
  78. package/migrations/20260930040956_smoodly_v7.sql +84 -0
  79. package/package.json +1 -5
@@ -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
  }
@@ -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") +
@@ -0,0 +1,7 @@
1
+ import type { ReactNode } from "react";
2
+ export declare const CanvasBridgeSlot: import("react").ComponentType<{}>;
3
+ export declare const CanvasNetSlot: import("react").ComponentType<{
4
+ id: string;
5
+ title: string;
6
+ children?: ReactNode;
7
+ }>;
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ // The one Smoodly client module a live page carries (spec 2026-09-29
3
+ // pre-install fixes §1). Next ships every client module a route's server
4
+ // graph imports, rendered or not; this stub is that module, and the bridge
5
+ // and the canvas net load from it lazily — only when the renderer is in
6
+ // editor mode, so a live page downloads neither.
7
+ import dynamic from "next/dynamic";
8
+ export const CanvasBridgeSlot = dynamic(() => import("../admin/next/bridge.js").then((m) => m.EditorBridge));
9
+ export const CanvasNetSlot = dynamic(() => import("./canvas-boundary.js").then((m) => m.CanvasBoundary));
@@ -1,5 +1,7 @@
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
+ export { previewRoute } from "./preview-route.ts";
7
+ export { previewPill } from "./preview-pill.tsx";
@@ -1,3 +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
+ export { previewRoute } from "./preview-route.js";
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
+ }
@@ -30,6 +30,8 @@ export type PageRendererOptions = {
30
30
  /** Locale-relative CMS path → site URL. Default: the path itself. With
31
31
  * next-intl: `(path, locale) => getPathname({ locale, href: path })`. */
32
32
  href?: (path: string, locale: string) => string;
33
+ /** Where the preview pill's Exit goes (spec 2026-09-29 pre-install fixes §2): the route that exports `previewRoute`. Default `/api/preview`. */
34
+ previewRoute?: string;
33
35
  };
34
36
  export type PathRenderOptions = {
35
37
  locale?: string;
@@ -1,29 +1,31 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The site-route seam (spec 2026-09-05 §5): the two route files call
3
3
  // renderSmoodlyPath; everything else — request gating, draft mode, the
4
- // cache keyed by (locale, path) and tagged by id, 404s, the editor
5
- // bridge — lives here, over the framework-free site layer. Bound once by
6
- // the app, exactly like createAdminOpHandler.
4
+ // cache keyed by (locale, path, target) and tagged by id, 404s, the editor
5
+ // bridge (through the lazy canvas stub) — lives here, over the
6
+ // framework-free site layer. Bound once by the app, exactly like
7
+ // createAdminOpHandler.
7
8
  import { Suspense } from "react";
8
9
  import { unstable_cache } from "next/cache";
9
10
  import { draftMode } from "next/headers";
10
11
  import { notFound, permanentRedirect, unstable_rethrow } from "next/navigation";
11
12
  import { connection } from "next/server";
12
- import { EditorBridge } from "../admin/next/bridge.js";
13
- import { CanvasBoundary } from "./canvas-boundary.js";
13
+ import { CanvasBridgeSlot, CanvasNetSlot } from "./canvas-slot.js";
14
14
  import { DRAFT_META_NAME } from "../admin/editor/canvas-diagnosis.js";
15
15
  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";
22
+ import { previewPill } from "./preview-pill.js";
21
23
  /** Logged once per renderer when Next's cacheComponents is on (spec 2026-09-27 cache components §2). */
22
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).";
23
25
  /** The canvas net (spec 2026-09-27 section guard §5): Server Components hand a section that failed to the
24
26
  * browser, where CanvasBoundary catches it. Defined here, not in the client file: a function exported
25
27
  * from a "use client" module is a reference on the server and cannot be called. */
26
- const canvasWrap = (child, section) => (_jsx(Suspense, { fallback: null, children: _jsx(CanvasBoundary, { id: section.id, title: section.title, children: child }) }));
28
+ const canvasWrap = (child, section) => (_jsx(Suspense, { fallback: null, children: _jsx(CanvasNetSlot, { id: section.id, title: section.title, children: child }) }));
27
29
  export function createPageRenderer(options) {
28
30
  const { site } = options;
29
31
  const href = options.href ?? ((path) => path);
@@ -34,7 +36,10 @@ export function createPageRenderer(options) {
34
36
  async function load(target, locale, path, draft, version) {
35
37
  if (draft)
36
38
  return site.load(target, locale, path, { draft: true, ...(version !== undefined ? { version } : {}) });
37
- return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path], {
39
+ // Keyed by what the unit holds, not only where: an address that changes
40
+ // owner — a deploy handing it to a code record, which fires no tag —
41
+ // can never serve the previous occupant (spec 2026-09-29 pre-install fixes §5).
42
+ return unstable_cache(() => site.load(target, locale, path), ["smoodly-site", locale, path, target.kind, target.id], {
38
43
  tags: [target.kind === "page" ? pageTag(target.id) : entryTag(target.id)],
39
44
  })();
40
45
  }
@@ -69,7 +74,7 @@ export function createPageRenderer(options) {
69
74
  console.warn(`smoodly: no entry page for collection "${hit.collection}" — the entry at ${hit.path} is a 404.`);
70
75
  notFound();
71
76
  }
72
- 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) });
73
78
  }
74
79
  /** hreflang from the SAME map a language switch reads (spec 2026-09-15 §3): the locales with a URL, through href. */
75
80
  function alternates(hit) {
@@ -115,7 +120,7 @@ export function createPageRenderer(options) {
115
120
  // The draft marker tells the page editor's canvas diagnosis that Smoodly
116
121
  // rendered this response (spec 2026-09-27 shadowed-page detector §3);
117
122
  // React hoists the meta into the head. Never outside draft mode.
118
- return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx(EditorBridge, {}) : null, render(hit, editor, locale)] }));
123
+ return (_jsxs(_Fragment, { children: [draft ? _jsx("meta", { name: DRAFT_META_NAME }) : null, editor ? _jsx(CanvasBridgeSlot, {}) : null, draft && !editor && !version ? previewPill({ route: options.previewRoute }) : null, render(hit, editor, locale)] }));
119
124
  }
120
125
  async function metadata(pathFor, opts, accept) {
121
126
  // generateMetadata runs alongside the page render, so it gates too.
@@ -129,7 +134,9 @@ export function createPageRenderer(options) {
129
134
  const hit = await load(target, locale, path, draft);
130
135
  if (!hit || !accept(hit))
131
136
  return {};
132
- 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) };
133
140
  }
134
141
  const any = () => true;
135
142
  /** The escape hatch may only render ITS OWN registration's record. */
@@ -0,0 +1,9 @@
1
+ import { type ReactElement } from "react";
2
+ /** The pill's whole client side. `closed` lives in the closure, so a hidden
3
+ * pill stays hidden across client navigations and returns on a reload. */
4
+ export declare const PREVIEW_PILL_SCRIPT: string;
5
+ /** The pill for a route that does not go through the renderer: render it
6
+ * when `(await draftMode()).isEnabled`, never in the editor's canvas. */
7
+ export declare function previewPill(opts?: {
8
+ route?: string;
9
+ }): ReactElement;