smoodly 0.0.7 → 0.0.9

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 +224 -4
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +64 -8
  4. package/dist/admin/forms/FieldWidget.d.ts +7 -3
  5. package/dist/admin/forms/FieldWidget.js +85 -7
  6. package/dist/admin/forms/list.d.ts +18 -0
  7. package/dist/admin/forms/list.js +81 -0
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +84 -1
  11. package/dist/admin/ops.d.ts +22 -1
  12. package/dist/admin/richtext.d.ts +13 -11
  13. package/dist/admin/richtext.js +94 -13
  14. package/dist/admin/serialize.d.ts +15 -0
  15. package/dist/admin/serialize.js +11 -0
  16. package/dist/admin/shared-items.d.ts +9 -0
  17. package/dist/admin/shared-items.js +61 -0
  18. package/dist/admin/shell/AdminApp.js +8 -2
  19. package/dist/admin/shell/EntryForm.js +1 -1
  20. package/dist/admin/shell/SharedForm.d.ts +7 -0
  21. package/dist/admin/shell/SharedForm.js +146 -0
  22. package/dist/admin/shell/SharedList.d.ts +6 -0
  23. package/dist/admin/shell/SharedList.js +62 -0
  24. package/dist/admin/shell/shared-list.d.ts +14 -0
  25. package/dist/admin/shell/shared-list.js +17 -0
  26. package/dist/admin/tree-ops.d.ts +12 -5
  27. package/dist/admin/tree-ops.js +39 -8
  28. package/dist/admin/ui/theme.d.ts +1 -1
  29. package/dist/admin/ui/theme.js +13 -0
  30. package/dist/admin/validate.d.ts +7 -2
  31. package/dist/admin/validate.js +43 -13
  32. package/dist/collections.d.ts +4 -1
  33. package/dist/config.d.ts +3 -0
  34. package/dist/config.js +35 -1
  35. package/dist/entry-store.d.ts +9 -1
  36. package/dist/entry-store.js +32 -4
  37. package/dist/fields.d.ts +16 -1
  38. package/dist/fields.js +49 -4
  39. package/dist/index.d.ts +8 -1
  40. package/dist/index.js +7 -1
  41. package/dist/next/page-renderer.d.ts +4 -0
  42. package/dist/next/page-renderer.js +14 -1
  43. package/dist/page.js +5 -1
  44. package/dist/refs.js +13 -0
  45. package/dist/resolve.d.ts +3 -0
  46. package/dist/resolve.js +41 -0
  47. package/dist/revalidate.d.ts +4 -0
  48. package/dist/revalidate.js +4 -0
  49. package/dist/richtext-render.d.ts +3 -0
  50. package/dist/richtext-render.js +62 -0
  51. package/dist/shared-id.d.ts +3 -0
  52. package/dist/shared-id.js +36 -0
  53. package/dist/shared.d.ts +46 -0
  54. package/dist/shared.js +36 -0
  55. package/dist/site.d.ts +10 -0
  56. package/dist/site.js +28 -1
  57. package/dist/sql-space.d.ts +5 -1
  58. package/dist/sql-space.js +9 -10
  59. package/dist/sql.js +1 -19
  60. package/dist/supabase-entry-store.d.ts +9 -1
  61. package/dist/supabase-entry-store.js +60 -7
  62. package/dist/zones.d.ts +6 -2
  63. package/dist/zones.js +8 -2
  64. package/package.json +1 -1
package/dist/config.js CHANGED
@@ -1,17 +1,50 @@
1
1
  import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
2
+ const RESERVED_NAME = "shared"; // the placement node type and the admin route
2
3
  function assertUniqueNames(config) {
3
4
  const seen = new Set();
5
+ const sectionNameList = (config.registry.sections ?? []).map((s) => s.schema.name);
6
+ const sectionNames = new Set(sectionNameList);
4
7
  const all = [
5
- ...(config.registry.sections ?? []).map((s) => s.schema.name),
8
+ ...sectionNameList,
6
9
  ...(config.registry.elements ?? []).map((e) => e.schema.name),
7
10
  ...(config.registry.collections ?? []).map((c) => c.name),
8
11
  ...(config.registry.pages ?? []).filter((p) => p.schema.kind !== "entryPage").map((p) => p.schema.name),
9
12
  ];
10
13
  for (const name of all) {
14
+ if (name === RESERVED_NAME)
15
+ throw new Error(`smoodly: "${RESERVED_NAME}" is a reserved name.`);
11
16
  if (seen.has(name))
12
17
  throw new Error(`smoodly: duplicate registered name "${name}".`);
13
18
  seen.add(name);
14
19
  }
20
+ // Shared items get their own pass, after every other kind is settled: a
21
+ // shared item's name must be unique among shared items, and must not
22
+ // collide with any collection/element/page/section name above — EXCEPT a
23
+ // visual item may reuse the name of the registered section it renders
24
+ // (spec 2026-09-07 §2's example pairs a "footer" section with a "footer"
25
+ // item). That exemption is narrow: it only excuses a match against the
26
+ // item's own, actually-registered section — never against a collection,
27
+ // element, or page that happens to share the string, and never against a
28
+ // second shared item of the same name.
29
+ const sharedSeen = new Set();
30
+ for (const item of config.registry.shared ?? []) {
31
+ const ownSection = item.name === item.section && sectionNames.has(item.name);
32
+ if (item.name === RESERVED_NAME)
33
+ throw new Error(`smoodly: "${RESERVED_NAME}" is a reserved name.`);
34
+ if (sharedSeen.has(item.name) || (seen.has(item.name) && !ownSection)) {
35
+ throw new Error(`smoodly: duplicate registered name "${item.name}".`);
36
+ }
37
+ sharedSeen.add(item.name);
38
+ }
39
+ }
40
+ /** A visual item renders through a registered section. */
41
+ function assertSharedSections(config) {
42
+ const sections = new Set((config.registry.sections ?? []).map((s) => s.schema.name));
43
+ for (const item of config.registry.shared ?? []) {
44
+ if (item.section !== undefined && !sections.has(item.section)) {
45
+ throw new Error(`smoodly: shared item "${item.name}" renders section "${item.section}", which is not registered.`);
46
+ }
47
+ }
15
48
  }
16
49
  /** Every root-level segment a registration claims, per locale: fixed page
17
50
  * slugs and collection paths. Two claims on one (locale, segment) are a
@@ -91,6 +124,7 @@ function assertLocales(config) {
91
124
  export function defineConfig(config) {
92
125
  assertLocales(config);
93
126
  assertUniqueNames(config);
127
+ assertSharedSections(config);
94
128
  assertRootSegments(config);
95
129
  const depth = config.pages?.tree?.depth ?? 1;
96
130
  if (!Number.isInteger(depth) || depth < 1)
@@ -1,4 +1,5 @@
1
1
  import type { CollectionSchema, EntryOrder } from "./collections.ts";
2
+ import type { SharedSchema } from "./shared.ts";
2
3
  import { type RefEdge } from "./refs.ts";
3
4
  import { MemoryRefIndex } from "./ref-index.ts";
4
5
  import { type PathIndex } from "./path-index.ts";
@@ -61,6 +62,7 @@ export type AddEntryLocaleOptions = {
61
62
  export type EntryStoreOptions = {
62
63
  paths?: PathIndex;
63
64
  locales?: LocaleSet;
65
+ shared?: SharedSchema[];
64
66
  };
65
67
  export interface EntryStore {
66
68
  /** Creates the node and ONE locale row from the merged `fields`: shared
@@ -118,6 +120,8 @@ export interface EntryStore {
118
120
  pathsOf(collection: string, id: string): Promise<Record<string, string>>;
119
121
  }
120
122
  export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
123
+ /** A shared item is a collection with exactly one entry (spec 2026-09-07). */
124
+ export declare const isSharedSchema: (schema: CollectionSchema<any>) => boolean;
121
125
  export declare const DEFAULT_ENTRY_LOCALES: LocaleSet;
122
126
  export declare const entryErrors: {
123
127
  notFound: (collection: string, id: string) => Error;
@@ -135,6 +139,10 @@ export declare const entryErrors: {
135
139
  lastLocale: (id: string) => Error;
136
140
  localeInUse: (locale: string, titles: string[]) => Error;
137
141
  moveLocales: (locale: string) => Error;
142
+ singleton: (name: string) => Error;
143
+ owned: (name: string, verb: string) => Error;
144
+ noSlug: (name: string) => Error;
145
+ everyLocale: (name: string) => Error;
138
146
  };
139
147
  /** The keys a locale row owns: every field with .localized(), except
140
148
  * `slug`, which is the row's own column (a segment is an address, per
@@ -174,7 +182,6 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
174
182
  */
175
183
  export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
176
184
  export declare class MemoryEntryStore implements EntryStore {
177
- private collections;
178
185
  private refs;
179
186
  private entries;
180
187
  private versions;
@@ -184,6 +191,7 @@ export declare class MemoryEntryStore implements EntryStore {
184
191
  private order;
185
192
  private paths;
186
193
  private locales;
194
+ private schemas;
187
195
  constructor(collections: CollectionSchema<any>[], refs?: MemoryRefIndex, options?: EntryStoreOptions);
188
196
  private schema;
189
197
  private row;
@@ -13,6 +13,8 @@ import { collectionDepth, collectionOrder } from "./collections.js";
13
13
  import { collectEntryRefs } from "./refs.js";
14
14
  import { MemoryRefIndex } from "./ref-index.js";
15
15
  import { MemoryPathIndex } from "./path-index.js";
16
+ import { sharedEntryId } from "./shared-id.js";
17
+ import { DEFAULT_SPACE_ID } from "./sql-space.js";
16
18
  import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
17
19
  export function schemaOf(collections, name) {
18
20
  const schema = collections.find((c) => c.name === name);
@@ -20,6 +22,8 @@ export function schemaOf(collections, name) {
20
22
  throw new Error(`smoodly: no collection named "${name}".`);
21
23
  return schema;
22
24
  }
25
+ /** A shared item is a collection with exactly one entry (spec 2026-09-07). */
26
+ export const isSharedSchema = (schema) => schema.kind === "shared";
23
27
  let counter = 0;
24
28
  const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
25
29
  export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
@@ -39,6 +43,11 @@ export const entryErrors = {
39
43
  lastLocale: (id) => new Error(`smoodly: entry "${id}" must exist in at least one locale — delete the entry instead.`),
40
44
  localeInUse: (locale, titles) => new Error(`smoodly: child entries still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
41
45
  moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the entry cannot move under it.`),
46
+ // Shared items (spec 2026-09-07 §3): one row, no URL, every locale, code-owned.
47
+ singleton: (name) => new Error(`smoodly: shared item "${name}" already exists — it is a singleton.`),
48
+ owned: (name, verb) => new Error(`smoodly: shared item "${name}" is owned by code — ${verb}.`),
49
+ noSlug: (name) => new Error(`smoodly: shared item "${name}" has no URL — it cannot take a slug.`),
50
+ everyLocale: (name) => new Error(`smoodly: shared item "${name}" exists in every locale — it cannot be removed from one.`),
42
51
  };
43
52
  /** The keys a locale row owns: every field with .localized(), except
44
53
  * `slug`, which is the row's own column (a segment is an address, per
@@ -128,7 +137,6 @@ export class MemoryEntryStore {
128
137
  constructor(collections,
129
138
  // pass the SAME index to MemoryPageStore so cross-kind questions work
130
139
  refs = new MemoryRefIndex(), options = {}) {
131
- this.collections = collections;
132
140
  this.refs = refs;
133
141
  this.entries = new Map();
134
142
  this.versions = new Map();
@@ -139,9 +147,10 @@ export class MemoryEntryStore {
139
147
  this.byId = (id) => this.entries.get(id);
140
148
  this.paths = options.paths ?? new MemoryPathIndex();
141
149
  this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
150
+ this.schemas = [...collections, ...(options.shared ?? [])];
142
151
  }
143
152
  schema(collection) {
144
- return schemaOf(this.collections, collection);
153
+ return schemaOf(this.schemas, collection);
145
154
  }
146
155
  row(collection, id) {
147
156
  const entry = this.entries.get(id);
@@ -253,13 +262,23 @@ export class MemoryEntryStore {
253
262
  const schema = this.schema(collection);
254
263
  assertSupportedLocale(options.locale, this.locales);
255
264
  const parentId = options.parentId ?? null;
265
+ if (isSharedSchema(schema)) {
266
+ if ([...this.entries.values()].some((e) => e.collection === collection))
267
+ throw entryErrors.singleton(collection);
268
+ if (slugOf(fields) !== null)
269
+ throw entryErrors.noSlug(collection);
270
+ }
256
271
  const parent = this.assertParent(collection, parentId, 1);
257
272
  this.assertParentHasLocale(parent, options.locale);
258
273
  const { shared, localized, slug } = splitFields(fields, schema);
259
274
  this.assertSiblingSlug(collection, parentId, options.locale, slug);
260
275
  const now = Date.now();
261
276
  const entry = {
262
- id: uid("entry"), collection, parentId, fields: shared,
277
+ // A shared item's id is derived, not minted (spec 2026-09-07 §10):
278
+ // the same uuid v5 the Supabase adapter computes, so self-host and
279
+ // cloud agree on the row a materialization would create.
280
+ id: isSharedSchema(schema) ? await sharedEntryId(DEFAULT_SPACE_ID, collection) : uid("entry"),
281
+ collection, parentId, fields: shared,
263
282
  locales: {
264
283
  [options.locale]: { slug, status: "draft", fields: localized, draftVersionId: null, publishedVersionId: null, updatedAt: now },
265
284
  },
@@ -322,6 +341,8 @@ export class MemoryEntryStore {
322
341
  const entry = this.must(collection, id);
323
342
  const row = entryLocaleRow(entry, locale);
324
343
  const { shared, localized, slug } = splitFields(fields, schema);
344
+ if (isSharedSchema(schema) && slug !== null)
345
+ throw entryErrors.noSlug(collection);
325
346
  this.assertSiblingSlug(collection, entry.parentId, locale, slug, id);
326
347
  const before = { fields: entry.fields, row: { ...row } };
327
348
  entry.fields = shared;
@@ -387,6 +408,8 @@ export class MemoryEntryStore {
387
408
  }
388
409
  async removeLocale(collection, id, locale) {
389
410
  const schema = this.schema(collection);
411
+ if (isSharedSchema(schema))
412
+ throw entryErrors.everyLocale(collection);
390
413
  const entry = this.must(collection, id);
391
414
  const row = entryLocaleRow(entry, locale);
392
415
  if (Object.keys(entry.locales).length === 1)
@@ -417,6 +440,8 @@ export class MemoryEntryStore {
417
440
  .map((v) => structuredClone(v));
418
441
  }
419
442
  async move(collection, id, to) {
443
+ if (isSharedSchema(this.schema(collection)))
444
+ throw entryErrors.owned(collection, "it has no tree position");
420
445
  const entry = this.must(collection, id);
421
446
  const subtree = this.subtree(entry);
422
447
  if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
@@ -454,6 +479,8 @@ export class MemoryEntryStore {
454
479
  const entry = this.row(collection, id);
455
480
  if (!entry)
456
481
  return;
482
+ if (isSharedSchema(this.schema(collection)))
483
+ throw entryErrors.owned(collection, "remove the registration to remove it");
457
484
  if ([...this.entries.values()].some((e) => e.parentId === id))
458
485
  throw entryErrors.hasChildren(collection, id);
459
486
  const usage = await this.referencesTo(collection, id);
@@ -467,7 +494,8 @@ export class MemoryEntryStore {
467
494
  this.refs.remove({ sourceKind: "entry", sourceId: id });
468
495
  }
469
496
  async reorder(collection, orderedIds) {
470
- this.schema(collection);
497
+ if (isSharedSchema(this.schema(collection)))
498
+ throw entryErrors.owned(collection, "it has no order");
471
499
  orderedIds.forEach((id, i) => {
472
500
  const entry = this.row(collection, id);
473
501
  if (entry)
package/dist/fields.d.ts CHANGED
@@ -31,6 +31,15 @@ export type BuilderFlags = {
31
31
  optional?: true;
32
32
  localized?: true;
33
33
  };
34
+ /** A builder whose flags carry `localized: true` becomes `never` here, so
35
+ * `f.list(f.text().localized())` and `f.object({ a: f.text().localized() })`
36
+ * are type errors: localization is whole-value on lists and objects
37
+ * (spec 2026-09-07, list field §2). */
38
+ type NotLocalized<B> = B extends {
39
+ __flags?: infer Fl;
40
+ } ? Fl extends {
41
+ localized: true;
42
+ } ? never : B : B;
34
43
  export declare const f: {
35
44
  text: () => FieldBuilder<string, {}>;
36
45
  link: () => FieldBuilder<string, {}>;
@@ -54,7 +63,12 @@ export declare const f: {
54
63
  }) => FieldBuilder<unknown, {}>;
55
64
  dataAttributes: () => FieldBuilder<Record<string, string>, {}>;
56
65
  select: <const O extends readonly string[]>(options: O) => FieldBuilder<O[number], {}>;
57
- object: <S extends Record<string, FieldBuilder<any, any>>>(shape: S) => FieldBuilder<{ [K in keyof S]: ValueOf<S[K]>; }, {}>;
66
+ object: <S extends Record<string, FieldBuilder<any, any>>>(shape: S & { [K in keyof S]: NotLocalized<S[K]>; }) => FieldBuilder<{ [K in keyof S]: ValueOf<S[K]>; }, {}>;
67
+ /** A repeating item: any builder, but no list at any depth inside the item
68
+ * — a list in an object in a list is still a nested list on screen, and a
69
+ * 300px sidebar cannot show one legibly (spec 2026-09-07, list field §1).
70
+ * `.min`/`.max` count items. */
71
+ list: <B extends FieldBuilder<any, any>>(item: B & NotLocalized<B>) => FieldBuilder<ValueOf<B>[], {}>;
58
72
  ref: <T>(target: () => T) => FieldBuilder<Resolved<T>, {}>;
59
73
  refList: <T>(target: () => T) => FieldBuilder<Resolved<T>[], {}>;
60
74
  };
@@ -65,3 +79,4 @@ export type ValueOf<B> = B extends FieldBuilder<infer V, infer Flags> ? Flags ex
65
79
  export type Resolved<T> = T extends {
66
80
  __entry?: infer E;
67
81
  } ? E : Record<string, unknown>;
82
+ export {};
package/dist/fields.js CHANGED
@@ -20,6 +20,35 @@ function builder(descriptor) {
20
20
  tab: (tab) => chain({ tab }),
21
21
  };
22
22
  }
23
+ const describePath = (path) => (path === "" ? "the item" : `"${path}"`);
24
+ /** First path at which `matches` is true of the descriptor, walking into
25
+ * object subfields and a list's item, or null. */
26
+ function findMatch(d, matches, path = "") {
27
+ if (matches(d))
28
+ return path;
29
+ if (d.type === "object") {
30
+ for (const [k, sub] of Object.entries(d.fields)) {
31
+ const hit = findMatch(sub, matches, path ? `${path}.${k}` : k);
32
+ if (hit !== null)
33
+ return hit;
34
+ }
35
+ }
36
+ if (d.type === "list")
37
+ return findMatch(d.item, matches, path);
38
+ return null;
39
+ }
40
+ /** Refs are addressed by top-level key everywhere (resolver, refs index,
41
+ * admin); `.localized()` decides storage per top-level field. Neither may
42
+ * hide inside a container. */
43
+ function assertContainerItem(kind, d, path = "") {
44
+ const ref = findMatch(d, (sub) => sub.type === "ref" || sub.type === "refList", path);
45
+ if (ref !== null)
46
+ throw new Error(`smoodly: ${kind} cannot hold a ref (at ${describePath(ref)}) — refs live at the top level of fields.`);
47
+ const loc = findMatch(d, (sub) => sub.localized === true, path);
48
+ if (loc !== null) {
49
+ throw new Error(`smoodly: .localized() inside ${kind} (at ${describePath(loc)}) — localize the ${kind === "f.list" ? "list" : "object"} itself.`);
50
+ }
51
+ }
23
52
  export const f = {
24
53
  text: () => builder({ type: "text" }),
25
54
  link: () => builder({ type: "link" }),
@@ -32,10 +61,26 @@ export const f = {
32
61
  richtext: (opts) => builder({ type: "richtext", ...(opts?.toolset !== undefined ? { toolset: opts.toolset } : {}) }),
33
62
  dataAttributes: () => builder({ type: "dataAttributes" }),
34
63
  select: (options) => builder({ type: "select", options: [...options] }),
35
- object: (shape) => builder({
36
- type: "object",
37
- fields: Object.fromEntries(Object.entries(shape).map(([k, v]) => [k, v.descriptor])),
38
- }),
64
+ object: (shape) => {
65
+ for (const [k, v] of Object.entries(shape))
66
+ assertContainerItem("f.object", v.descriptor, k);
67
+ return builder({
68
+ type: "object",
69
+ fields: Object.fromEntries(Object.entries(shape).map(([k, v]) => [k, v.descriptor])),
70
+ });
71
+ },
72
+ /** A repeating item: any builder, but no list at any depth inside the item
73
+ * — a list in an object in a list is still a nested list on screen, and a
74
+ * 300px sidebar cannot show one legibly (spec 2026-09-07, list field §1).
75
+ * `.min`/`.max` count items. */
76
+ list: (item) => {
77
+ const d = item.descriptor;
78
+ const nested = findMatch(d, (sub) => sub.type === "list");
79
+ if (nested !== null)
80
+ throw new Error(`smoodly: f.list cannot hold another list (at ${describePath(nested)}) — nested lists are not supported.`);
81
+ assertContainerItem("f.list", d);
82
+ return builder({ type: "list", item: d });
83
+ },
39
84
  ref: (target) => builder({ type: "ref", target }),
40
85
  refList: (target) => builder({ type: "refList", target }),
41
86
  };
package/dist/index.d.ts CHANGED
@@ -3,8 +3,10 @@ import { collection, toolset } from "./collections.ts";
3
3
  import { Section } from "./section-wrapper.tsx";
4
4
  import { page } from "./render.tsx";
5
5
  import { entryPage } from "./entry-page.tsx";
6
+ import { shared } from "./shared.ts";
6
7
  export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.tsx";
7
8
  export type { PageTree, TreeNode, ZoneContent, PairedPage, Registry, PageContext, PageContextLink, PageLink, PageViewProps } from "./render.tsx";
9
+ export { RichText } from "./richtext-render.tsx";
8
10
  export type { PageSchema } from "./page.ts";
9
11
  export { fixedSlug } from "./page.ts";
10
12
  export type { EntryPageSchema, PairedEntryPage, EntryPageProps } from "./entry-page.tsx";
@@ -24,7 +26,7 @@ export { SupabaseEntryStore } from "./supabase-entry-store.ts";
24
26
  export { SupabasePathIndex } from "./supabase-path-index.ts";
25
27
  export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, } from "./entry-store.ts";
26
28
  export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.ts";
27
- export { affectedTargets, pageTag, entryTag } from "./revalidate.ts";
29
+ export { affectedTargets, pageTag, entryTag, sharedTag } from "./revalidate.ts";
28
30
  export type { AffectedTargets } from "./revalidate.ts";
29
31
  export { resolveTree, resolveFields } from "./resolve.ts";
30
32
  export type { EntryFetcher, ResolveFieldsOptions } from "./resolve.ts";
@@ -40,6 +42,10 @@ export type { Props, PairedComponent } from "./pairing.tsx";
40
42
  export type { CollectionSchema, Toolset, EntryOrder } from "./collections.ts";
41
43
  export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.ts";
42
44
  export type { ZonePolicy } from "./zones.ts";
45
+ export { SHARED_NODE_TYPE, STYLES_KEY, sharedLink } from "./shared.ts";
46
+ export { sharedEntryId } from "./shared-id.ts";
47
+ export { DEFAULT_SPACE_ID } from "./sql-space.ts";
48
+ export type { SharedSchema, SharedLink } from "./shared.ts";
43
49
  export declare const smoodly: {
44
50
  schema: {
45
51
  page: typeof import("./page.ts").pageSchema;
@@ -110,6 +116,7 @@ export declare const smoodly: {
110
116
  entryPage: typeof entryPage;
111
117
  collection: typeof collection;
112
118
  toolset: typeof toolset;
119
+ shared: typeof shared;
113
120
  Section: typeof Section;
114
121
  };
115
122
  export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
package/dist/index.js CHANGED
@@ -4,7 +4,9 @@ import { collection, toolset } from "./collections.js";
4
4
  import { Section } from "./section-wrapper.js";
5
5
  import { page } from "./render.js";
6
6
  import { entryPage } from "./entry-page.js";
7
+ import { shared } from "./shared.js";
7
8
  export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.js";
9
+ export { RichText } from "./richtext-render.js";
8
10
  export { fixedSlug } from "./page.js";
9
11
  export { f } from "./fields.js";
10
12
  export { z } from "./zones.js";
@@ -19,12 +21,15 @@ export { MemoryEntryStore } from "./entry-store.js";
19
21
  export { SupabaseEntryStore } from "./supabase-entry-store.js";
20
22
  export { SupabasePathIndex } from "./supabase-path-index.js";
21
23
  export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.js";
22
- export { affectedTargets, pageTag, entryTag } from "./revalidate.js";
24
+ export { affectedTargets, pageTag, entryTag, sharedTag } from "./revalidate.js";
23
25
  export { resolveTree, resolveFields } from "./resolve.js";
24
26
  export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
25
27
  export { fixedPathFor } from "./localize.js";
26
28
  export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
27
29
  export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
30
+ export { SHARED_NODE_TYPE, STYLES_KEY, sharedLink } from "./shared.js";
31
+ export { sharedEntryId } from "./shared-id.js";
32
+ export { DEFAULT_SPACE_ID } from "./sql-space.js";
28
33
  export const smoodly = {
29
34
  schema,
30
35
  section,
@@ -33,6 +38,7 @@ export const smoodly = {
33
38
  entryPage,
34
39
  collection,
35
40
  toolset,
41
+ shared,
36
42
  Section,
37
43
  };
38
44
  export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
@@ -2,6 +2,7 @@ import type { ComponentType, ReactElement } from "react";
2
2
  import type { Metadata } from "next";
3
3
  import type { Descriptor } from "../fields.ts";
4
4
  import { type PairedPage } from "../render.tsx";
5
+ import type { SharedSchema } from "../shared.ts";
5
6
  import { type SmoodlySite } from "../site.ts";
6
7
  /** A registered section, as renderPage's registry needs it. */
7
8
  type SectionEntry = ComponentType<any> & {
@@ -31,6 +32,9 @@ export type PathRenderOptions = {
31
32
  searchParams?: Promise<Record<string, string | string[] | undefined>>;
32
33
  };
33
34
  export declare function createPageRenderer(options: PageRendererOptions): {
35
+ getSmoodlyShared: <V>(item: SharedSchema<V>, opts?: {
36
+ locale?: string;
37
+ }) => Promise<V | null>;
34
38
  renderSmoodlyPath: (segments: string[], opts?: PathRenderOptions) => Promise<ReactElement<unknown, string | import("react").JSXElementConstructor<any>>>;
35
39
  smoodlyMetadata: (segments: string[], opts?: {
36
40
  locale?: string;
@@ -7,7 +7,7 @@ import { EditorBridge } from "../admin/next/bridge.js";
7
7
  import { fixedPathFor } from "../localize.js";
8
8
  import { joinPath } from "../paths.js";
9
9
  import { renderPage } from "../render.js";
10
- import { entryTag, pageTag } from "../revalidate.js";
10
+ import { entryTag, pageTag, sharedTag } from "../revalidate.js";
11
11
  import { pageContextOf } from "../site.js";
12
12
  import { metadataFrom } from "./meta.js";
13
13
  export function createPageRenderer(options) {
@@ -82,7 +82,20 @@ export function createPageRenderer(options) {
82
82
  * error surfaces as a rejected promise like every other failure. */
83
83
  const fixedPath = (Page) => (locale) => fixedPathFor(Page.schema, locale, site.locales, site.homePageSlug);
84
84
  const literal = (segments) => () => joinPath(segments);
85
+ /** A shared item for a layout: cached per (item, locale) under the
86
+ * shared tag outside draft mode, the live draft inside it. */
87
+ async function getSmoodlyShared(item, opts) {
88
+ await connection();
89
+ const locale = opts?.locale ?? site.locales.default;
90
+ const { isEnabled: draft } = await draftMode();
91
+ if (draft)
92
+ return site.getShared(item, { locale, draft: true });
93
+ return unstable_cache(() => site.getShared(item, { locale }), ["smoodly-shared", item.name, locale], {
94
+ tags: [sharedTag(item.name)],
95
+ })();
96
+ }
85
97
  return {
98
+ getSmoodlyShared,
86
99
  renderSmoodlyPath: (segments, opts) => page(literal(segments), opts, any),
87
100
  smoodlyMetadata: (segments, opts) => metadata(literal(segments), opts, any),
88
101
  /** A fixed registration rendered from the developer's own route file. */
package/dist/page.js CHANGED
@@ -10,7 +10,11 @@ function normalizePolicy(p) {
10
10
  case "freeform":
11
11
  return { kind: "freeform", allow: p.allow, rows: p.rows };
12
12
  case "locked":
13
- return { kind: "locked", component: p.component };
13
+ return {
14
+ kind: "locked",
15
+ component: p.component,
16
+ ...(typeof p.shared === "string" ? { shared: p.shared } : {}),
17
+ };
14
18
  default:
15
19
  throw new Error(`smoodly: unknown zone policy kind "${String(p.kind)}".`);
16
20
  }
package/dist/refs.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // and never the source of truth. Stores rewrite a source's edges on
5
5
  // every save; `refs` answers only the REVERSE question (who depends on
6
6
  // this?) — forward resolution stays resolveTree's job.
7
+ import { sharedLink } from "./shared.js";
7
8
  function targetOf(d) {
8
9
  const t = d.target?.();
9
10
  if (!t?.name)
@@ -24,6 +25,11 @@ function collectFrom(values, descriptors, out) {
24
25
  out.push({ field, targetCollection: targetOf(d), targetId: id });
25
26
  }
26
27
  }
28
+ else if (d.type === "object" || d.type === "list") {
29
+ // Deliberate: refs are top-level only — f.list/f.object refuse them at
30
+ // build time (spec 2026-09-07, list field §1), so there is nothing to walk.
31
+ continue;
32
+ }
27
33
  }
28
34
  }
29
35
  export function collectEntryRefs(fields, schema) {
@@ -39,6 +45,13 @@ export function collectTreeRefs(tree, sections) {
39
45
  const out = [];
40
46
  for (const nodes of Object.values(tree.zones ?? {})) {
41
47
  for (const node of nodes) {
48
+ // A placement (spec 2026-09-07 §5): one edge, keyed by the node id
49
+ // so two placements of one item on one page stay distinct rows.
50
+ const link = sharedLink(node);
51
+ if (link) {
52
+ out.push({ field: node.id, targetCollection: link.item, targetId: link.ref });
53
+ continue;
54
+ }
42
55
  const schema = byName.get(node.type);
43
56
  if (!schema)
44
57
  continue; // fail-soft, same as renderPage
package/dist/resolve.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Descriptor } from "./fields.ts";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
3
  import type { PageTree } from "./render.tsx";
4
+ import { type SharedSchema } from "./shared.ts";
4
5
  export type EntryFetcher = (collection: string, ids: string[]) => Promise<Record<string, Record<string, unknown>>>;
5
6
  type SchemaLike = {
6
7
  name: string;
@@ -15,6 +16,8 @@ type ResolveOptions = {
15
16
  })[];
16
17
  collections: CollectionSchema<any>[];
17
18
  fetch: EntryFetcher;
19
+ /** The registered shared items a placement node may name (spec 2026-09-07 §4). */
20
+ shared?: SharedSchema[];
18
21
  /** Optional safety valve on chain length; unlimited by default. */
19
22
  maxDepth?: number;
20
23
  };
package/dist/resolve.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // Cycles are cut per path — a ref back to an entry already on its own
7
7
  // chain gets a shallow copy (refs as ids), so traversal terminates by
8
8
  // construction and the output stays JSON-serializable.
9
+ import { sharedLink, SHARED_NODE_TYPE, STYLES_KEY } from "./shared.js";
9
10
  function targetOf(d) {
10
11
  const t = d.target?.();
11
12
  if (!t?.name)
@@ -27,6 +28,7 @@ export async function resolveTree(tree, options) {
27
28
  return [schema.name, schema];
28
29
  }));
29
30
  const resolved = structuredClone(tree);
31
+ await rewritePlacements(resolved, options.shared ?? [], options.fetch);
30
32
  const tasks = [];
31
33
  for (const nodes of Object.values(resolved.zones ?? {})) {
32
34
  for (const node of nodes) {
@@ -39,6 +41,42 @@ export async function resolveTree(tree, options) {
39
41
  await runResolution(tasks, { collections: options.collections, fetch: options.fetch, maxDepth });
40
42
  return resolved;
41
43
  }
44
+ /** Placements first (spec 2026-09-07 §4): fetch every linked item in one
45
+ * call per item, rewrite each node into the item's section node — the
46
+ * placement's id stays, so the canvas selects it — and drop what the
47
+ * site cannot render: an unpublished or dangling row, an object item
48
+ * (no view), an unknown item. The normal pass then hydrates the
49
+ * rewritten nodes' own refs. */
50
+ async function rewritePlacements(tree, shared, fetch) {
51
+ const byName = new Map(shared.map((s) => [s.name, s]));
52
+ const wanted = new Map();
53
+ for (const nodes of Object.values(tree.zones ?? {})) {
54
+ for (const node of nodes) {
55
+ const link = sharedLink(node);
56
+ if (!link || !byName.get(link.item)?.section)
57
+ continue;
58
+ const ids = wanted.get(link.item) ?? new Set();
59
+ ids.add(link.ref);
60
+ wanted.set(link.item, ids);
61
+ }
62
+ }
63
+ const fetched = new Map();
64
+ await Promise.all([...wanted].map(async ([item, ids]) => fetched.set(item, await fetch(item, [...ids]))));
65
+ for (const [zone, nodes] of Object.entries(tree.zones ?? {})) {
66
+ tree.zones[zone] = nodes.flatMap((node) => {
67
+ const link = sharedLink(node);
68
+ if (!link)
69
+ return node.type === SHARED_NODE_TYPE ? [] : [node];
70
+ const schema = byName.get(link.item);
71
+ const row = fetched.get(link.item)?.[link.ref];
72
+ if (!schema?.section || !row)
73
+ return [];
74
+ const { [STYLES_KEY]: styles, ...fields } = structuredClone(row);
75
+ const isObject = styles !== null && typeof styles === "object";
76
+ return [{ id: node.id, type: schema.section, fields, ...(isObject ? { styles: styles } : {}) }];
77
+ });
78
+ }
79
+ }
42
80
  /** One entry's fields, refs hydrated — the entry page's counterpart of
43
81
  * resolveTree. Same rules, same batching, same cycle cut. */
44
82
  export async function resolveFields(fields, descriptors, options) {
@@ -136,5 +174,8 @@ function collectTasks(values, descriptors, out) {
136
174
  else if (d.type === "refList" && Array.isArray(value)) {
137
175
  out.push({ holder: values, key, collection: targetOf(d), id: value, isList: true, mode: modeOf(d), ancestors: [] });
138
176
  }
177
+ else if (d.type === "object" || d.type === "list") {
178
+ continue; // refs are top-level only; containers hold none (spec 2026-09-07)
179
+ }
139
180
  }
140
181
  }
@@ -8,6 +8,10 @@ export type AffectedTargets = {
8
8
  };
9
9
  export declare const pageTag: (id: string) => string;
10
10
  export declare const entryTag: (id: string) => string;
11
+ /** A shared item's cache unit, by NAME (a singleton has one row per
12
+ * locale, and code owns the name): the Next glue's getSmoodlyShared
13
+ * caches under it; ops.shared expires it. */
14
+ export declare const sharedTag: (name: string) => string;
11
15
  export declare function affectedTargets(stores: {
12
16
  pages: PageStore;
13
17
  entries: EntryStore;
@@ -7,6 +7,10 @@
7
7
  // change (spec 2026-09-05 §5).
8
8
  export const pageTag = (id) => `page:${id}`;
9
9
  export const entryTag = (id) => `entry:${id}`;
10
+ /** A shared item's cache unit, by NAME (a singleton has one row per
11
+ * locale, and code owns the name): the Next glue's getSmoodlyShared
12
+ * caches under it; ops.shared expires it. */
13
+ export const sharedTag = (name) => `shared:${name}`;
10
14
  export async function affectedTargets(stores, entryId, options = {}) {
11
15
  const maxDepth = options.maxDepth ?? Infinity;
12
16
  const seen = new Set([entryId]);
@@ -0,0 +1,3 @@
1
+ export declare function RichText({ doc }: {
2
+ doc: unknown;
3
+ }): import("react").JSX.Element | null;