smoodly 0.0.15 → 0.0.17

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 (154) hide show
  1. package/README.md +238 -36
  2. package/dist/admin/auth.d.ts +7 -4
  3. package/dist/admin/auth.js +1 -1
  4. package/dist/admin/client-ops.js +3 -3
  5. package/dist/admin/custom-section.d.ts +10 -0
  6. package/dist/admin/custom-section.js +10 -0
  7. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  8. package/dist/admin/editor/CanvasOverlay.js +6 -6
  9. package/dist/admin/editor/EditorView.d.ts +2 -0
  10. package/dist/admin/editor/EditorView.js +322 -62
  11. package/dist/admin/editor/GlobalInspector.d.ts +24 -0
  12. package/dist/admin/editor/GlobalInspector.js +13 -0
  13. package/dist/admin/editor/InsertPicker.d.ts +6 -5
  14. package/dist/admin/editor/InsertPicker.js +6 -4
  15. package/dist/admin/editor/InspectorFields.d.ts +21 -0
  16. package/dist/admin/editor/InspectorFields.js +22 -0
  17. package/dist/admin/editor/Outline.d.ts +0 -1
  18. package/dist/admin/editor/Outline.js +3 -3
  19. package/dist/admin/editor/PageSettings.js +3 -2
  20. package/dist/admin/editor/global-edit.d.ts +39 -0
  21. package/dist/admin/editor/global-edit.js +124 -0
  22. package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
  23. package/dist/admin/editor/useGlobalEdit.js +18 -0
  24. package/dist/admin/forms/FieldWidget.d.ts +14 -1
  25. package/dist/admin/forms/FieldWidget.js +38 -11
  26. package/dist/admin/forms/ListField.d.ts +9 -1
  27. package/dist/admin/forms/ListField.js +16 -8
  28. package/dist/admin/forms/RichtextElementCard.js +11 -5
  29. package/dist/admin/forms/RowsWidget.d.ts +13 -0
  30. package/dist/admin/forms/RowsWidget.js +26 -0
  31. package/dist/admin/forms/SpanBar.d.ts +5 -0
  32. package/dist/admin/forms/SpanBar.js +37 -0
  33. package/dist/admin/forms/richtext-insert.d.ts +2 -2
  34. package/dist/admin/forms/richtext-insert.js +3 -3
  35. package/dist/admin/forms/richtext-nodes.js +1 -1
  36. package/dist/admin/forms/tabs.d.ts +20 -8
  37. package/dist/admin/forms/tabs.js +19 -23
  38. package/dist/admin/forms/useRichtextEditor.js +8 -1
  39. package/dist/admin/global-items.d.ts +9 -0
  40. package/dist/admin/{shared-items.js → global-items.js} +29 -14
  41. package/dist/admin/index.d.ts +3 -3
  42. package/dist/admin/index.js +1 -1
  43. package/dist/admin/next/op-handler.js +4 -3
  44. package/dist/admin/ops-impl.d.ts +3 -0
  45. package/dist/admin/ops-impl.js +242 -56
  46. package/dist/admin/ops.d.ts +38 -6
  47. package/dist/admin/serialize.d.ts +18 -7
  48. package/dist/admin/serialize.js +27 -5
  49. package/dist/admin/shell/AdminApp.js +7 -7
  50. package/dist/admin/shell/EntriesList.js +19 -3
  51. package/dist/admin/shell/EntryForm.js +183 -77
  52. package/dist/admin/shell/GlobalFields.d.ts +24 -0
  53. package/dist/admin/shell/GlobalFields.js +19 -0
  54. package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
  55. package/dist/admin/shell/GlobalForm.js +258 -0
  56. package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
  57. package/dist/admin/shell/{SharedList.js → GlobalList.js} +12 -11
  58. package/dist/admin/shell/IconRail.js +14 -7
  59. package/dist/admin/shell/PagesList.js +4 -8
  60. package/dist/admin/shell/entries-list.d.ts +2 -2
  61. package/dist/admin/shell/entries-list.js +3 -3
  62. package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
  63. package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
  64. package/dist/admin/supabase-auth.js +1 -1
  65. package/dist/admin/tree-ops.d.ts +10 -8
  66. package/dist/admin/tree-ops.js +28 -16
  67. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  68. package/dist/admin/ui/ActionMenu.js +25 -0
  69. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  70. package/dist/admin/ui/HistoryModal.js +40 -0
  71. package/dist/admin/ui/Modal.d.ts +5 -1
  72. package/dist/admin/ui/Modal.js +3 -3
  73. package/dist/admin/ui/PublishControl.d.ts +31 -0
  74. package/dist/admin/ui/PublishControl.js +24 -0
  75. package/dist/admin/ui/format.d.ts +4 -0
  76. package/dist/admin/ui/format.js +9 -0
  77. package/dist/admin/ui/primitives.d.ts +16 -1
  78. package/dist/admin/ui/primitives.js +20 -4
  79. package/dist/admin/ui/status-label.d.ts +10 -0
  80. package/dist/admin/ui/status-label.js +14 -0
  81. package/dist/admin/ui/theme.js +93 -13
  82. package/dist/admin/validate.d.ts +27 -2
  83. package/dist/admin/validate.js +47 -10
  84. package/dist/collections.d.ts +3 -9
  85. package/dist/collections.js +1 -2
  86. package/dist/conditions.d.ts +33 -0
  87. package/dist/conditions.js +79 -0
  88. package/dist/config.d.ts +26 -19
  89. package/dist/config.js +26 -25
  90. package/dist/css.d.ts +7 -0
  91. package/dist/css.js +57 -0
  92. package/dist/custom/CustomSection.d.ts +26 -0
  93. package/dist/custom/CustomSection.js +20 -0
  94. package/dist/custom/css.d.ts +3 -0
  95. package/dist/custom/css.js +28 -0
  96. package/dist/custom/index.d.ts +26 -0
  97. package/dist/custom/index.js +16 -0
  98. package/dist/custom/rows.d.ts +28 -0
  99. package/dist/custom/rows.js +129 -0
  100. package/dist/custom/schema.d.ts +32 -0
  101. package/dist/custom/schema.js +39 -0
  102. package/dist/entry-store.d.ts +42 -17
  103. package/dist/entry-store.js +107 -40
  104. package/dist/fields.d.ts +5 -2
  105. package/dist/fields.js +4 -2
  106. package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
  107. package/dist/{shared-id.js → global-id.js} +4 -4
  108. package/dist/global.d.ts +57 -0
  109. package/dist/global.js +51 -0
  110. package/dist/index.d.ts +28 -18
  111. package/dist/index.js +10 -5
  112. package/dist/media.js +1 -1
  113. package/dist/next/index.d.ts +1 -1
  114. package/dist/next/page-renderer.d.ts +4 -2
  115. package/dist/next/page-renderer.js +17 -13
  116. package/dist/next/queries.d.ts +6 -6
  117. package/dist/next/queries.js +7 -7
  118. package/dist/page.js +2 -2
  119. package/dist/pairing.d.ts +13 -7
  120. package/dist/pairing.js +4 -4
  121. package/dist/refs.d.ts +2 -2
  122. package/dist/refs.js +4 -4
  123. package/dist/render.d.ts +13 -1
  124. package/dist/render.js +10 -5
  125. package/dist/resolve.d.ts +11 -6
  126. package/dist/resolve.js +22 -12
  127. package/dist/revalidate.d.ts +4 -4
  128. package/dist/revalidate.js +4 -4
  129. package/dist/richtext-render.js +2 -2
  130. package/dist/richtext-walk.d.ts +5 -5
  131. package/dist/richtext-walk.js +4 -4
  132. package/dist/schema.d.ts +15 -10
  133. package/dist/schema.js +19 -7
  134. package/dist/section-wrapper.d.ts +18 -6
  135. package/dist/section-wrapper.js +24 -17
  136. package/dist/site.d.ts +17 -5
  137. package/dist/site.js +36 -12
  138. package/dist/sql-space.js +1 -1
  139. package/dist/sql.js +19 -16
  140. package/dist/store.d.ts +45 -13
  141. package/dist/store.js +88 -12
  142. package/dist/styles.d.ts +27 -0
  143. package/dist/styles.js +60 -0
  144. package/dist/supabase-entry-store.d.ts +11 -6
  145. package/dist/supabase-entry-store.js +115 -39
  146. package/dist/supabase-store.d.ts +13 -6
  147. package/dist/supabase-store.js +90 -20
  148. package/dist/zones.d.ts +9 -7
  149. package/dist/zones.js +5 -5
  150. package/package.json +1 -1
  151. package/dist/admin/shared-items.d.ts +0 -9
  152. package/dist/admin/shell/SharedForm.js +0 -134
  153. package/dist/shared.d.ts +0 -46
  154. package/dist/shared.js +0 -36
@@ -1,12 +1,17 @@
1
1
  import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
2
  import { ASSET_COLLECTION } from "../media.js";
3
3
  import { BASE } from "./shell/base.js";
4
- import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, sharedTag } from "../revalidate.js";
4
+ import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
5
5
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
6
- import { ensureSharedItems } from "./shared-items.js";
7
- import { validateFields } from "./validate.js";
6
+ import { ensureGlobalItems } from "./global-items.js";
7
+ import { validateCustomNode, validateFields, validateNode } from "./validate.js";
8
+ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
9
+ import { SETTINGS_KEY, CSS_KEY } from "../global.js";
10
+ import { withDefaults } from "../field-defaults.js";
8
11
  import { elementSchemas } from "../richtext-walk.js";
9
12
  const ok = (data) => ({ ok: true, data });
13
+ /** A history row without its content (spec 2026-09-13 history §6). */
14
+ const summary = ({ id, savedAt }) => ({ id, savedAt });
10
15
  const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
11
16
  /** Own-property, non-array plain object — same shape guard the tree checks
12
17
  * below and `runAdminOp`'s envelope check both lean on. */
@@ -62,6 +67,8 @@ const classify = (e) => {
62
67
  return err("blocked", message);
63
68
  if (message.includes("cannot publish"))
64
69
  return err("blocked", message);
70
+ if (message.includes("nothing published"))
71
+ return err("blocked", message);
65
72
  if (message.includes("already taken"))
66
73
  return err("conflict", message);
67
74
  if (message.includes("already has a template"))
@@ -88,13 +95,15 @@ const classify = (e) => {
88
95
  export function createAdminOps(deps) {
89
96
  const { config, pages, entries, effects } = deps;
90
97
  const href = deps.href ?? ((path) => path);
98
+ const by = deps.actor?.email ?? undefined;
91
99
  const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
92
100
  // Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
93
- const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, styles: e.schema.styles })));
94
- const sectionFields = (type) => config.registry.sections?.find((s) => s.schema.name === type)?.schema;
95
- const sharedItem = (name) => config.registry.shared?.find((s) => s.name === name) ?? null;
101
+ const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, settings: e.schema.settings })));
102
+ // The custom section (spec 2026-09-14 §6) validates like a section, over its own schema.
103
+ const sectionFields = (type) => (type === CUSTOM_NODE_TYPE ? customSchema(config.custom.toolset) : config.registry.sections?.find((s) => s.schema.name === type)?.schema);
104
+ const globalItem = (name) => config.registry.globals?.find((s) => s.name === name) ?? null;
96
105
  /** One row per registration; null when materialization could not create it. */
97
- const sharedRecord = async (name) => (await entries.list(name))[0] ?? null;
106
+ const globalRecord = async (name) => (await entries.list(name))[0] ?? null;
98
107
  const fanOut = async (entryId) => {
99
108
  if (!effects?.revalidate)
100
109
  return;
@@ -103,28 +112,28 @@ export function createAdminOps(deps) {
103
112
  effects.revalidate(entryTag(id));
104
113
  for (const id of pageIds)
105
114
  effects.revalidate(pageTag(id));
106
- // A shared item's row is an entry like any other: when the item
115
+ // A global item's row is an entry like any other: when the item
107
116
  // itself changes OR when something it REFERENCES does, the unit
108
- // `getSmoodlyShared` caches under `shared:<name>` is stale too — and
117
+ // `getSmoodlyGlobal` caches under `global:<name>` is stale too — and
109
118
  // that unit is a layout, on every page. `affectedTargets` returns row
110
119
  // ids, so resolve name → row id here. Once per fan-out, not memoized
111
120
  // per ops instance: a row can be materialized between two calls, and
112
121
  // an entry write is rare enough that one `entries.list` per
113
122
  // registration beats reasoning about a stale cache. Last, so the
114
123
  // existing entry-then-page order is untouched.
115
- const registered = config.registry.shared ?? [];
124
+ const registered = config.registry.globals ?? [];
116
125
  if (registered.length === 0)
117
126
  return;
118
127
  const touched = new Set(entryIds);
119
128
  for (const item of registered) {
120
129
  const row = (await entries.list(item.name))[0];
121
130
  if (row && touched.has(row.id))
122
- effects.revalidate(sharedTag(item.name));
131
+ effects.revalidate(globalTag(item.name));
123
132
  }
124
133
  };
125
134
  /** An asset's metadata (alt, title, caption, credit) is read wherever media
126
135
  * resolves (spec 2026-09-08 §9): the entry and page units that reference the
127
- * asset, directly or through an entry, the shared items among those rows,
136
+ * asset, directly or through an entry, the global items among those rows,
128
137
  * and the list units of every referencing entry's collection. The walk is
129
138
  * the entry fan-out's, seeded with the asset's id — which is no entry, so
130
139
  * it gets no tag of its own. */
@@ -138,10 +147,10 @@ export function createAdminOps(deps) {
138
147
  for (const id of pageIds)
139
148
  effects.revalidate(pageTag(id));
140
149
  const touched = new Set(users);
141
- for (const item of config.registry.shared ?? []) {
150
+ for (const item of config.registry.globals ?? []) {
142
151
  const row = (await entries.list(item.name))[0];
143
152
  if (row && touched.has(row.id))
144
- effects.revalidate(sharedTag(item.name));
153
+ effects.revalidate(globalTag(item.name));
145
154
  }
146
155
  for (const c of config.registry.collections ?? []) {
147
156
  for (const id of users) {
@@ -200,9 +209,9 @@ export function createAdminOps(deps) {
200
209
  return { title: sourceId, href: null };
201
210
  return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: `${BASE}/pages/${sourceId}` };
202
211
  }
203
- for (const item of config.registry.shared ?? []) {
212
+ for (const item of config.registry.globals ?? []) {
204
213
  if (await entries.get(item.name, sourceId))
205
- return { title: item.title, href: `${BASE}/shared/${encodeURIComponent(item.name)}` };
214
+ return { title: item.title, href: `${BASE}/globals/${encodeURIComponent(item.name)}` };
206
215
  }
207
216
  for (const c of config.registry.collections ?? []) {
208
217
  const record = await entries.get(c.name, sourceId);
@@ -281,7 +290,7 @@ export function createAdminOps(deps) {
281
290
  const created = await pages.createPage({
282
291
  locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
283
292
  ...(input.title !== undefined ? { title: input.title } : {}),
284
- });
293
+ }, by);
285
294
  expireTree();
286
295
  return ok(created);
287
296
  }),
@@ -293,7 +302,7 @@ export function createAdminOps(deps) {
293
302
  return err("validation", "smoodly: malformed page tree.");
294
303
  if (!(await pages.getPage(id)))
295
304
  return err("not_found", `smoodly: no page "${id}".`);
296
- return ok({ versionId: (await pages.saveDraft(id, locale, tree)).id });
305
+ return ok({ versionId: (await pages.saveDraft(id, locale, tree, by)).id });
297
306
  }),
298
307
  publish: (id, locale) => guard(async () => {
299
308
  const unsupported = supportedLocale(locale);
@@ -317,7 +326,11 @@ export function createAdminOps(deps) {
317
326
  const schema = sectionFields(node.type);
318
327
  if (!schema?.fields)
319
328
  continue;
320
- for (const fe of validateFields(node.fields ?? {}, schema.fields, elementDescs)) {
329
+ // Fields, settings (prefixed) and the css snippet (spec 2026-09-14 §3, §4); a custom section adds its row structure (§6).
330
+ const errors = node.type === CUSTOM_NODE_TYPE
331
+ ? validateCustomNode(node, schema.fields, elementDescs)
332
+ : validateNode(node, { fields: schema.fields, settings: schema.settings }, elementDescs);
333
+ for (const fe of errors) {
321
334
  fieldErrors.push({ field: `${node.id}.${fe.field}`, message: fe.message });
322
335
  }
323
336
  }
@@ -330,12 +343,75 @@ export function createAdminOps(deps) {
330
343
  if (fieldErrors.length > 0) {
331
344
  return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
332
345
  }
333
- const published = await pages.publish(id, locale);
346
+ const published = await pages.publish(id, locale, by);
334
347
  // The parent's cached unit lists its published children.
335
348
  expirePages([published.id, published.parentId]);
336
349
  expireTree();
337
350
  return ok(published);
338
351
  }),
352
+ unpublish: (id, locale) => guard(async () => {
353
+ const unsupported = supportedLocale(locale);
354
+ if (unsupported)
355
+ return unsupported;
356
+ const record = await pages.getPage(id);
357
+ if (!record)
358
+ return err("not_found", `smoodly: no page "${id}".`);
359
+ if (!record.locales[locale])
360
+ return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
361
+ if (isHomeNode(record))
362
+ return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be unpublished.`);
363
+ if (fixedNodeOf(record, config))
364
+ return owned(record, "it stays published");
365
+ const off = await pages.unpublish(id, locale, by);
366
+ // The parent's cached unit lists its published children; the tree listing shows status.
367
+ expirePages([off.id, off.parentId]);
368
+ expireTree();
369
+ return ok(off);
370
+ }),
371
+ discardDraft: (id, locale) => guard(async () => {
372
+ const unsupported = supportedLocale(locale);
373
+ if (unsupported)
374
+ return unsupported;
375
+ const record = await pages.getPage(id);
376
+ if (!record)
377
+ return err("not_found", `smoodly: no page "${id}".`);
378
+ const row = record.locales[locale];
379
+ if (!row)
380
+ return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
381
+ if (!row.publishedVersionId) {
382
+ return err("blocked", `smoodly: "${nameOf(record)}" has nothing published in ${locale} — there is no version to restore.`);
383
+ }
384
+ const restored = await pages.discardDraft(id, locale, by);
385
+ expirePages([restored.id]); // the draft-mode render of this page changed
386
+ return ok(restored);
387
+ }),
388
+ listVersions: (id, locale) => guard(async () => {
389
+ const unsupported = supportedLocale(locale);
390
+ if (unsupported)
391
+ return unsupported;
392
+ if (!(await pages.getPage(id)))
393
+ return err("not_found", `smoodly: no page "${id}".`);
394
+ return ok((await pages.listVersions(id, locale)).map(summary));
395
+ }),
396
+ restore: (id, locale, versionId) => guard(async () => {
397
+ const unsupported = supportedLocale(locale);
398
+ if (unsupported)
399
+ return unsupported;
400
+ const record = await pages.getPage(id);
401
+ if (!record)
402
+ return err("not_found", `smoodly: no page "${id}".`);
403
+ if (!record.locales[locale])
404
+ return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
405
+ if (record.template === null)
406
+ return err("blocked", `smoodly: "${nameOf(record)}" is a folder — it has no versions.`);
407
+ const version = typeof versionId === "string" ? await pages.getVersion(versionId) : null;
408
+ if (!version || version.pageId !== id || version.locale !== locale) {
409
+ return err("not_found", `smoodly: no version "${String(versionId)}" of this page in ${locale}.`);
410
+ }
411
+ const restored = await pages.restore(id, locale, versionId, by);
412
+ expirePages([restored.id]); // the draft-mode render of this page changed
413
+ return ok(restored);
414
+ }),
339
415
  delete: (id) => guard(async () => {
340
416
  const record = await pages.getPage(id);
341
417
  if (!record)
@@ -364,7 +440,7 @@ export function createAdminOps(deps) {
364
440
  const unsupported = supportedLocale(patch.locale);
365
441
  if (unsupported)
366
442
  return unsupported;
367
- const renamed = await pages.rename(id, patch);
443
+ const renamed = await pages.rename(id, patch, by);
368
444
  // Every descendant's path changed with this segment; the parent
369
445
  // lists this node's title and path.
370
446
  expirePages([...(await subtreeIds(id)), record.parentId]);
@@ -408,7 +484,7 @@ export function createAdminOps(deps) {
408
484
  if (registration.schema.slug !== undefined) {
409
485
  return err("validation", `smoodly: page template "${template}" is fixed — Smoodly creates that page for you.`);
410
486
  }
411
- const withTemplate = await pages.setTemplate(id, template);
487
+ const withTemplate = await pages.setTemplate(id, template, by);
412
488
  expireTree();
413
489
  return ok(withTemplate);
414
490
  }),
@@ -419,7 +495,7 @@ export function createAdminOps(deps) {
419
495
  if (!(await pages.getPage(id)))
420
496
  return err("not_found", `smoodly: no page "${id}".`);
421
497
  // A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
422
- const added = await pages.addLocale(id, locale, { from: options.from });
498
+ const added = await pages.addLocale(id, locale, { from: options.from }, by);
423
499
  expireTree();
424
500
  return ok(added);
425
501
  }),
@@ -455,7 +531,9 @@ export function createAdminOps(deps) {
455
531
  const parentLocales = record.parentId === null
456
532
  ? config.locales.supported
457
533
  : Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
458
- return ok({ record, locale, fields, parentLocales });
534
+ const paths = await entries.pathsOf(collection, id);
535
+ const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
536
+ return ok({ record, locale, fields, parentLocales, urls });
459
537
  }),
460
538
  create: (collection, input) => guard(async () => {
461
539
  const unsupported = supportedLocale(input.locale);
@@ -464,10 +542,7 @@ export function createAdminOps(deps) {
464
542
  const descriptors = collectionFields(collection);
465
543
  if (!descriptors)
466
544
  return err("not_found", `smoodly: no collection named "${collection}".`);
467
- const fieldErrors = validateFields(input.fields, descriptors, elementDescs);
468
- if (fieldErrors.length > 0)
469
- return err("validation", "Some fields need attention.", fieldErrors);
470
- const created = await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null });
545
+ const created = await entries.create(collection, withDefaults(input.fields, descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
471
546
  expireCollection(collection);
472
547
  return ok(created);
473
548
  }),
@@ -478,10 +553,7 @@ export function createAdminOps(deps) {
478
553
  const descriptors = collectionFields(collection);
479
554
  if (!descriptors)
480
555
  return err("not_found", `smoodly: no collection named "${collection}".`);
481
- const fieldErrors = validateFields(fields, descriptors, elementDescs);
482
- if (fieldErrors.length > 0)
483
- return err("validation", "Some fields need attention.", fieldErrors);
484
- const entry = await entries.update(collection, id, locale, fields);
556
+ const entry = await entries.update(collection, id, locale, fields, by);
485
557
  await fanOut(id);
486
558
  expireCollection(collection);
487
559
  return ok(entry);
@@ -490,11 +562,62 @@ export function createAdminOps(deps) {
490
562
  const unsupported = supportedLocale(locale);
491
563
  if (unsupported)
492
564
  return unsupported;
493
- const entry = await entries.setStatus(collection, id, locale, status);
565
+ if (status === "published") {
566
+ // A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
567
+ const descriptors = collectionFields(collection);
568
+ if (!descriptors)
569
+ return err("not_found", `smoodly: no collection named "${collection}".`);
570
+ const record = await entries.get(collection, id);
571
+ if (!record)
572
+ return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
573
+ if (!record.locales[locale])
574
+ return err("not_found", `smoodly: entry "${id}" does not exist in locale "${locale}".`);
575
+ const fieldErrors = validateFields(entryFieldsIn(record, locale), descriptors, elementDescs);
576
+ if (fieldErrors.length > 0)
577
+ return err("validation", "Some fields need attention.", fieldErrors);
578
+ }
579
+ const entry = await entries.setStatus(collection, id, locale, status, by);
494
580
  await fanOut(id);
495
581
  expireCollection(collection);
496
582
  return ok(entry);
497
583
  }),
584
+ discardDraft: (collection, id, locale) => guard(async () => {
585
+ const unsupported = supportedLocale(locale);
586
+ if (unsupported)
587
+ return unsupported;
588
+ const restored = await entries.discardDraft(collection, id, locale, by);
589
+ await fanOut(id);
590
+ expireCollection(collection);
591
+ return ok(restored);
592
+ }),
593
+ listVersions: (collection, id, locale) => guard(async () => {
594
+ const unsupported = supportedLocale(locale);
595
+ if (unsupported)
596
+ return unsupported;
597
+ if (!(await entries.get(collection, id)))
598
+ return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
599
+ return ok((await entries.listVersions(collection, id, locale)).map(summary));
600
+ }),
601
+ getVersion: (collection, id, versionId) => guard(async () => {
602
+ const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
603
+ if (!version || version.entryId !== id) {
604
+ return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}".`);
605
+ }
606
+ return ok({ ...summary(version), fields: version.fields });
607
+ }),
608
+ restore: (collection, id, locale, versionId) => guard(async () => {
609
+ const unsupported = supportedLocale(locale);
610
+ if (unsupported)
611
+ return unsupported;
612
+ const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
613
+ if (!version || version.entryId !== id || version.locale !== locale) {
614
+ return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}", locale ${locale}.`);
615
+ }
616
+ const restored = await entries.restore(collection, id, locale, versionId, by);
617
+ await fanOut(id);
618
+ expireCollection(collection);
619
+ return ok(restored);
620
+ }),
498
621
  // The tag expires only after the delete succeeded; a blocked delete expires nothing.
499
622
  delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
500
623
  move: (collection, id, to) => guard(async () => {
@@ -512,7 +635,7 @@ export function createAdminOps(deps) {
512
635
  const unsupported = supportedLocale(locale);
513
636
  if (unsupported)
514
637
  return unsupported;
515
- const added = await entries.addLocale(collection, id, locale, { from: options.from });
638
+ const added = await entries.addLocale(collection, id, locale, { from: options.from }, by);
516
639
  expireCollection(collection);
517
640
  return ok(added);
518
641
  }),
@@ -526,22 +649,22 @@ export function createAdminOps(deps) {
526
649
  return ok(entry);
527
650
  }),
528
651
  },
529
- shared: {
652
+ globals: {
530
653
  list: () => guard(async () => {
531
- const records = await ensureSharedItems(entries, config);
532
- return ok((config.registry.shared ?? []).flatMap((s) => (records[s.name] ? [{ name: s.name, record: records[s.name] }] : [])));
654
+ const records = await ensureGlobalItems(entries, config);
655
+ return ok((config.registry.globals ?? []).flatMap((s) => (records[s.name] ? [{ name: s.name, record: records[s.name] }] : [])));
533
656
  }),
534
657
  get: (name, locale) => guard(async () => {
535
658
  const unsupported = supportedLocale(locale);
536
659
  if (unsupported)
537
660
  return unsupported;
538
- const item = sharedItem(name);
661
+ const item = globalItem(name);
539
662
  if (!item)
540
- return err("not_found", `smoodly: no shared item named "${name}".`);
541
- await ensureSharedItems(entries, config);
542
- const record = await sharedRecord(name);
663
+ return err("not_found", `smoodly: no global item named "${name}".`);
664
+ await ensureGlobalItems(entries, config);
665
+ const record = await globalRecord(name);
543
666
  if (!record?.locales[locale])
544
- return err("not_found", `smoodly: shared item "${name}" does not exist in locale "${locale}".`);
667
+ return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
545
668
  const usage = await entries.referencesTo(name, record.id);
546
669
  return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
547
670
  }),
@@ -549,17 +672,14 @@ export function createAdminOps(deps) {
549
672
  const unsupported = supportedLocale(locale);
550
673
  if (unsupported)
551
674
  return unsupported;
552
- const item = sharedItem(name);
675
+ const item = globalItem(name);
553
676
  if (!item)
554
- return err("not_found", `smoodly: no shared item named "${name}".`);
555
- const fieldErrors = validateFields(fields, item.fields, elementDescs);
556
- if (fieldErrors.length > 0)
557
- return err("validation", "Some fields need attention.", fieldErrors);
558
- const record = await sharedRecord(name);
677
+ return err("not_found", `smoodly: no global item named "${name}".`);
678
+ const record = await globalRecord(name);
559
679
  if (!record)
560
- return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
561
- const saved = await entries.update(name, record.id, locale, fields);
562
- // fanOut expires `shared:<name>` itself — this row IS the item's.
680
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
681
+ const saved = await entries.update(name, record.id, locale, fields, by);
682
+ // fanOut expires `global:<name>` itself — this row IS the item's.
563
683
  await fanOut(record.id);
564
684
  return ok(saved);
565
685
  }),
@@ -567,16 +687,82 @@ export function createAdminOps(deps) {
567
687
  const unsupported = supportedLocale(locale);
568
688
  if (unsupported)
569
689
  return unsupported;
570
- if (!sharedItem(name))
571
- return err("not_found", `smoodly: no shared item named "${name}".`);
572
- const record = await sharedRecord(name);
690
+ const item = globalItem(name);
691
+ if (!item)
692
+ return err("not_found", `smoodly: no global item named "${name}".`);
693
+ const record = await globalRecord(name);
573
694
  if (!record)
574
- return err("not_found", `smoodly: shared item "${name}" is not materialized — open Shared content first.`);
575
- const changed = await entries.setStatus(name, record.id, locale, status);
695
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
696
+ if (status === "published") {
697
+ // A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
698
+ if (!record.locales[locale])
699
+ return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
700
+ // A visual item validates like a tree node: its fields, its section's settings under `$settings`
701
+ // (prefixed `settings.`), its `$css` (spec 2026-09-14 §3, §4).
702
+ const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...fields } = entryFieldsIn(record, locale);
703
+ const settingsDescs = item.section !== undefined ? sectionFields(item.section)?.settings : undefined;
704
+ const fieldErrors = validateNode({ fields, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: settingsDescs }, elementDescs);
705
+ if (fieldErrors.length > 0)
706
+ return err("validation", "Some fields need attention.", fieldErrors);
707
+ }
708
+ const changed = await entries.setStatus(name, record.id, locale, status, by);
576
709
  // see save: the fan-out carries the item's own tag.
577
710
  await fanOut(record.id);
578
711
  return ok(changed);
579
712
  }),
713
+ discardDraft: (name, locale) => guard(async () => {
714
+ const unsupported = supportedLocale(locale);
715
+ if (unsupported)
716
+ return unsupported;
717
+ if (!globalItem(name))
718
+ return err("not_found", `smoodly: no global item named "${name}".`);
719
+ const record = await globalRecord(name);
720
+ if (!record)
721
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
722
+ const restored = await entries.discardDraft(name, record.id, locale, by);
723
+ await fanOut(record.id);
724
+ return ok(restored);
725
+ }),
726
+ listVersions: (name, locale) => guard(async () => {
727
+ const unsupported = supportedLocale(locale);
728
+ if (unsupported)
729
+ return unsupported;
730
+ if (!globalItem(name))
731
+ return err("not_found", `smoodly: no global item named "${name}".`);
732
+ const record = await globalRecord(name);
733
+ if (!record)
734
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
735
+ return ok((await entries.listVersions(name, record.id, locale)).map(summary));
736
+ }),
737
+ getVersion: (name, versionId) => guard(async () => {
738
+ if (!globalItem(name))
739
+ return err("not_found", `smoodly: no global item named "${name}".`);
740
+ const record = await globalRecord(name);
741
+ if (!record)
742
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
743
+ const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
744
+ if (!version || version.entryId !== record.id) {
745
+ return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}".`);
746
+ }
747
+ return ok({ ...summary(version), fields: version.fields });
748
+ }),
749
+ restore: (name, locale, versionId) => guard(async () => {
750
+ const unsupported = supportedLocale(locale);
751
+ if (unsupported)
752
+ return unsupported;
753
+ if (!globalItem(name))
754
+ return err("not_found", `smoodly: no global item named "${name}".`);
755
+ const record = await globalRecord(name);
756
+ if (!record)
757
+ return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
758
+ const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
759
+ if (!version || version.entryId !== record.id || version.locale !== locale) {
760
+ return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}" in ${locale}.`);
761
+ }
762
+ const restored = await entries.restore(name, record.id, locale, versionId, by);
763
+ await fanOut(record.id);
764
+ return ok(restored);
765
+ }),
580
766
  },
581
767
  assets: {
582
768
  list: (query) => guard(async () => (assets ? ok(await assets.list(query)) : noMedia())),
@@ -55,23 +55,34 @@ export type EntryGetResult = {
55
55
  locale: string;
56
56
  /** That locale's merged draft fields; null when the entry has no row in `locale`. */
57
57
  fields: Record<string, unknown> | null;
58
+ /** locale → site URL (`href` applied server-side) for the locales the entry has a path in; empty for a collection without a path. */
59
+ urls: Record<string, string>;
58
60
  /** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
59
61
  parentLocales: string[];
60
62
  };
61
- export type SharedListItem = {
63
+ export type GlobalListItem = {
62
64
  name: string;
63
65
  record: EntryRecord;
64
66
  };
65
- export type SharedGetResult = {
67
+ export type GlobalGetResult = {
66
68
  name: string;
67
69
  /** The node with ALL its locale rows (every supported one, by construction). */
68
70
  record: EntryRecord;
69
71
  locale: string;
70
- /** That locale's merged draft fields — a visual item's styles under `$styles`. */
72
+ /** That locale's merged draft fields — a visual item's settings under `$settings`. */
71
73
  fields: Record<string, unknown>;
72
74
  /** Who links it: "used on N pages". */
73
75
  usage: EntryUsage[];
74
76
  };
77
+ /** A history row: the version id and when its content last changed (spec 2026-09-13 history §5). */
78
+ export type VersionSummary = {
79
+ id: string;
80
+ savedAt: number;
81
+ };
82
+ /** A stored entry snapshot for the disabled form. */
83
+ export type EntryVersionResult = VersionSummary & {
84
+ fields: Record<string, unknown>;
85
+ };
75
86
  /** One place an asset is used: the source row, the field, a label and an admin link. */
76
87
  export type AssetUsageItem = {
77
88
  sourceKind: string;
@@ -91,6 +102,10 @@ export type AdminOps = {
91
102
  versionId: string;
92
103
  }>>;
93
104
  publish(id: string, locale: string): Promise<OpResult<PageRecord>>;
105
+ /** Takes the locale off the site; status draft, pointers kept. Never the site root or a fixed node (spec 2026-09-13 publish flow §5). */
106
+ unpublish(id: string, locale: string): Promise<OpResult<PageRecord>>;
107
+ /** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
108
+ discardDraft(id: string, locale: string): Promise<OpResult<PageRecord>>;
94
109
  delete(id: string): Promise<OpResult<null>>;
95
110
  rename(id: string, patch: {
96
111
  locale: string;
@@ -109,6 +124,10 @@ export type AdminOps = {
109
124
  }): Promise<OpResult<PageRecord>>;
110
125
  /** Deletes the locale's row, versions and paths. Never on fixed nodes or the site root. */
111
126
  removeLocale(id: string, locale: string): Promise<OpResult<PageRecord>>;
127
+ /** The locale's versions newest first, without their trees. */
128
+ listVersions(id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
129
+ /** The version's tree becomes the draft (spec 2026-09-13 history §6); `not_found` for a version of another page or locale, `blocked` on a folder. */
130
+ restore(id: string, locale: string, versionId: string): Promise<OpResult<PageRecord>>;
112
131
  };
113
132
  entries: {
114
133
  list(collection: string): Promise<OpResult<EntryRecord[]>>;
@@ -117,6 +136,8 @@ export type AdminOps = {
117
136
  create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
118
137
  save(collection: string, id: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
119
138
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
139
+ /** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
140
+ discardDraft(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
120
141
  delete(collection: string, id: string): Promise<OpResult<null>>;
121
142
  move(collection: string, id: string, to: {
122
143
  parentId: string | null;
@@ -128,13 +149,24 @@ export type AdminOps = {
128
149
  }): Promise<OpResult<EntryRecord>>;
129
150
  /** Deletes the locale's row, versions and paths; fans out like a save. */
130
151
  removeLocale(collection: string, id: string, locale: string): Promise<OpResult<EntryRecord>>;
152
+ /** Newest first; empty for a collection without `versions`. */
153
+ listVersions(collection: string, id: string, locale: string): Promise<OpResult<VersionSummary[]>>;
154
+ /** One snapshot's fields; `not_found` unless it belongs to `id`. */
155
+ getVersion(collection: string, id: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
156
+ /** The snapshot becomes the draft (§6); `not_found` for a foreign version. Fans out like a save. */
157
+ restore(collection: string, id: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
131
158
  };
132
- shared: {
159
+ globals: {
133
160
  /** Materializes every registered item in every supported locale, then lists them in registry order. */
134
- list(): Promise<OpResult<SharedListItem[]>>;
135
- get(name: string, locale: string): Promise<OpResult<SharedGetResult>>;
161
+ list(): Promise<OpResult<GlobalListItem[]>>;
162
+ get(name: string, locale: string): Promise<OpResult<GlobalGetResult>>;
136
163
  save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
137
164
  setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
165
+ /** The item's live row back to its published snapshot (§4); `blocked` before the first publish. */
166
+ discardDraft(name: string, locale: string): Promise<OpResult<EntryRecord>>;
167
+ listVersions(name: string, locale: string): Promise<OpResult<VersionSummary[]>>;
168
+ getVersion(name: string, versionId: string): Promise<OpResult<EntryVersionResult>>;
169
+ restore(name: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
138
170
  };
139
171
  assets: {
140
172
  /** Ready assets, newest first, paged by cursor; `kind` and `search` filter. */