smoodly 0.0.51 → 0.0.53

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 (292) hide show
  1. package/README.md +14 -13
  2. package/dist/admin/auth.d.ts +3 -11
  3. package/dist/admin/auth.js +1 -30
  4. package/dist/admin/client-ops.d.ts +5 -4
  5. package/dist/admin/client-ops.js +15 -12
  6. package/dist/admin/delivery-mode.d.ts +1 -1
  7. package/dist/admin/delivery-mode.js +2 -2
  8. package/dist/admin/drift.d.ts +0 -2
  9. package/dist/admin/drift.js +1 -1
  10. package/dist/admin/editor/EditorView.d.ts +0 -3
  11. package/dist/admin/editor/EditorView.js +11 -16
  12. package/dist/admin/editor/FloatingToolbar.d.ts +0 -1
  13. package/dist/admin/editor/FloatingToolbar.js +1 -1
  14. package/dist/admin/editor/SeoSettings.d.ts +0 -2
  15. package/dist/admin/editor/SeoSettings.js +2 -2
  16. package/dist/admin/editor/canvas-address.d.ts +8 -0
  17. package/dist/admin/editor/canvas-address.js +18 -2
  18. package/dist/admin/editor/canvas-diagnosis.d.ts +2 -0
  19. package/dist/admin/editor/canvas-diagnosis.js +3 -0
  20. package/dist/admin/editor/inline-dom.d.ts +0 -4
  21. package/dist/admin/editor/inline-dom.js +1 -1
  22. package/dist/admin/editor/preview-grant.d.ts +6 -0
  23. package/dist/admin/editor/preview-grant.js +25 -0
  24. package/dist/admin/editor/useCanvasDiagnosis.js +2 -2
  25. package/dist/admin/files.generated.js +1 -1
  26. package/dist/admin/fixed-nodes.d.ts +0 -6
  27. package/dist/admin/fixed-nodes.js +1 -81
  28. package/dist/admin/forms/CropEditor.d.ts +4 -2
  29. package/dist/admin/forms/CropEditor.js +58 -5
  30. package/dist/admin/forms/ImagePreview.d.ts +3 -1
  31. package/dist/admin/forms/ImagePreview.js +9 -3
  32. package/dist/admin/forms/MediaGrid.js +2 -1
  33. package/dist/admin/forms/MediaPicker.js +9 -6
  34. package/dist/admin/forms/MediaWidget.js +11 -9
  35. package/dist/admin/forms/assets-context.d.ts +7 -3
  36. package/dist/admin/forms/assets-context.js +5 -3
  37. package/dist/admin/forms/crop-editor.d.ts +24 -2
  38. package/dist/admin/forms/crop-editor.js +36 -2
  39. package/dist/admin/forms/date-picker.d.ts +0 -1
  40. package/dist/admin/forms/date-picker.js +1 -1
  41. package/dist/admin/forms/tabs.d.ts +0 -1
  42. package/dist/admin/forms/tabs.js +1 -1
  43. package/dist/admin/forms/upload.d.ts +18 -11
  44. package/dist/admin/forms/upload.js +62 -24
  45. package/dist/admin/global-items.d.ts +0 -3
  46. package/dist/admin/global-items.js +0 -54
  47. package/dist/admin/handler.d.ts +22 -28
  48. package/dist/admin/handler.js +146 -47
  49. package/dist/admin/health.d.ts +0 -4
  50. package/dist/admin/index.d.ts +6 -9
  51. package/dist/admin/index.js +3 -6
  52. package/dist/admin/ops.d.ts +40 -32
  53. package/dist/admin/page-data.d.ts +14 -6
  54. package/dist/admin/page-data.js +1 -1
  55. package/dist/admin/session-flow.d.ts +65 -0
  56. package/dist/admin/session-flow.js +121 -0
  57. package/dist/admin/shell/AdminApp.d.ts +8 -3
  58. package/dist/admin/shell/AdminApp.js +37 -58
  59. package/dist/admin/shell/ContentHealthPage.js +1 -3
  60. package/dist/admin/shell/GlobalFields.d.ts +0 -3
  61. package/dist/admin/shell/GlobalFields.js +1 -1
  62. package/dist/admin/shell/IconRail.js +10 -8
  63. package/dist/admin/shell/MediaScreen.js +9 -6
  64. package/dist/admin/shell/MembersPage.d.ts +1 -2
  65. package/dist/admin/shell/MembersPage.js +19 -90
  66. package/dist/admin/shell/SettingsView.d.ts +1 -2
  67. package/dist/admin/shell/SettingsView.js +2 -2
  68. package/dist/admin/shell/SignedOutView.d.ts +5 -0
  69. package/dist/admin/shell/SignedOutView.js +9 -0
  70. package/dist/admin/shell/console-links.d.ts +5 -0
  71. package/dist/admin/shell/console-links.js +11 -0
  72. package/dist/admin/shell/members-list.d.ts +1 -14
  73. package/dist/admin/shell/members-list.js +2 -23
  74. package/dist/admin/shell/pages-tree.d.ts +0 -2
  75. package/dist/admin/shell/pages-tree.js +1 -1
  76. package/dist/admin/shell/presence-socket.d.ts +10 -0
  77. package/dist/admin/shell/presence-socket.js +191 -0
  78. package/dist/admin/shell/presence.d.ts +6 -18
  79. package/dist/admin/shell/presence.js +4 -11
  80. package/dist/admin/shell/session.d.ts +19 -12
  81. package/dist/admin/shell/session.js +114 -47
  82. package/dist/admin/ui/LocaleSwitcher.d.ts +0 -4
  83. package/dist/admin/ui/LocaleSwitcher.js +2 -2
  84. package/dist/admin/ui/primitives.d.ts +4 -14
  85. package/dist/admin/ui/primitives.js +7 -9
  86. package/dist/admin/ui/theme.js +13 -2
  87. package/dist/admin/validate.js +10 -1
  88. package/dist/api-client.d.ts +26 -0
  89. package/dist/api-client.js +107 -0
  90. package/dist/assemble.d.ts +4 -41
  91. package/dist/assemble.js +5 -303
  92. package/dist/asset-store.d.ts +28 -30
  93. package/dist/asset-store.js +26 -121
  94. package/dist/astro/cache.d.ts +1 -1
  95. package/dist/astro/components/Head.astro +4 -1
  96. package/dist/astro/components/Image.astro +12 -17
  97. package/dist/astro/components/Video.astro +5 -2
  98. package/dist/astro/head.d.ts +3 -0
  99. package/dist/astro/head.js +24 -0
  100. package/dist/astro/index.js +61 -27
  101. package/dist/astro/locale-paths.d.ts +5 -2
  102. package/dist/astro/locale-paths.js +10 -2
  103. package/dist/astro/routes/admin.js +9 -7
  104. package/dist/astro/runtime.d.ts +3 -2
  105. package/dist/astro/runtime.js +5 -4
  106. package/dist/astro/server.d.ts +7 -11
  107. package/dist/astro/server.js +22 -28
  108. package/dist/astro/shell.d.ts +3 -3
  109. package/dist/astro/shell.js +1 -1
  110. package/dist/astro/static-media-node.d.ts +4 -0
  111. package/dist/astro/static-media-node.js +25 -0
  112. package/dist/astro/static-media.d.ts +17 -0
  113. package/dist/astro/static-media.js +55 -0
  114. package/dist/astro/static.d.ts +5 -10
  115. package/dist/astro/static.js +13 -29
  116. package/dist/canvas-marker.d.ts +14 -0
  117. package/dist/canvas-marker.js +36 -0
  118. package/dist/cli/api.d.ts +14 -0
  119. package/dist/cli/api.js +48 -0
  120. package/dist/cli/app-env.d.ts +2 -0
  121. package/dist/cli/app-env.js +20 -0
  122. package/dist/cli/export.d.ts +16 -2
  123. package/dist/cli/export.js +52 -35
  124. package/dist/cli/import.d.ts +7 -11
  125. package/dist/cli/import.js +50 -54
  126. package/dist/cli/load-config.d.ts +0 -1
  127. package/dist/cli/load-config.js +1 -1
  128. package/dist/cli/main.d.ts +4 -12
  129. package/dist/cli/main.js +26 -72
  130. package/dist/cli/push.d.ts +6 -0
  131. package/dist/cli/push.js +31 -0
  132. package/dist/collections.d.ts +0 -1
  133. package/dist/collections.js +1 -1
  134. package/dist/config.d.ts +20 -5
  135. package/dist/config.js +18 -1
  136. package/dist/content/content-api.d.ts +23 -23
  137. package/dist/content/content-api.js +48 -56
  138. package/dist/content/mime.d.ts +1 -0
  139. package/dist/content/mime.js +6 -0
  140. package/dist/css.js +1 -1
  141. package/dist/deploy-store.d.ts +19 -0
  142. package/dist/entry-store.d.ts +8 -94
  143. package/dist/entry-store.js +9 -557
  144. package/dist/env.d.ts +27 -20
  145. package/dist/env.js +38 -50
  146. package/dist/fields.d.ts +11 -3
  147. package/dist/fields.js +33 -2
  148. package/dist/global-id.d.ts +2 -0
  149. package/dist/global-id.js +4 -2
  150. package/dist/image/SmoodlyImage.d.ts +9 -5
  151. package/dist/image/SmoodlyImage.js +29 -15
  152. package/dist/image/SmoodlyVideo.d.ts +1 -1
  153. package/dist/image/SmoodlyVideo.js +3 -2
  154. package/dist/image/index.d.ts +2 -3
  155. package/dist/image/index.js +0 -2
  156. package/dist/image/markup.d.ts +53 -0
  157. package/dist/image/markup.js +111 -0
  158. package/dist/index.d.ts +13 -23
  159. package/dist/index.js +7 -20
  160. package/dist/media-cut.d.ts +44 -0
  161. package/dist/media-cut.js +70 -0
  162. package/dist/media.d.ts +58 -4
  163. package/dist/media.js +126 -16
  164. package/dist/member-store.d.ts +2 -50
  165. package/dist/member-store.js +0 -56
  166. package/dist/next/admin-route.d.ts +1 -1
  167. package/dist/next/admin-route.js +5 -3
  168. package/dist/next/meta.js +2 -3
  169. package/dist/next/page-renderer.d.ts +2 -2
  170. package/dist/next/page-renderer.js +22 -11
  171. package/dist/next/queries.d.ts +2 -2
  172. package/dist/path-index.d.ts +0 -24
  173. package/dist/path-index.js +0 -98
  174. package/dist/paths.d.ts +1 -1
  175. package/dist/paths.js +1 -1
  176. package/dist/push.d.ts +30 -0
  177. package/dist/push.js +52 -0
  178. package/dist/read-counter.d.ts +2 -0
  179. package/dist/read-counter.js +6 -0
  180. package/dist/reads.d.ts +7 -7
  181. package/dist/reads.js +3 -0
  182. package/dist/registry-json.d.ts +11 -0
  183. package/dist/registry-json.js +81 -0
  184. package/dist/render.d.ts +0 -1
  185. package/dist/render.js +1 -1
  186. package/dist/resolve.d.ts +12 -6
  187. package/dist/resolve.js +11 -8
  188. package/dist/revalidate.d.ts +8 -1
  189. package/dist/revalidate.js +8 -1
  190. package/dist/richtext/index.d.ts +1 -1
  191. package/dist/section-wrapper.d.ts +1 -1
  192. package/dist/section-wrapper.js +0 -9
  193. package/dist/signed-message.d.ts +26 -0
  194. package/dist/signed-message.js +103 -0
  195. package/dist/site.d.ts +59 -18
  196. package/dist/site.js +21 -252
  197. package/dist/space-transfer.d.ts +6 -3
  198. package/dist/space-transfer.js +18 -6
  199. package/dist/store.d.ts +2 -80
  200. package/dist/store.js +3 -489
  201. package/dist/toolset.d.ts +2 -1
  202. package/dist/toolset.js +1 -1
  203. package/dist/version.d.ts +1 -1
  204. package/dist/version.js +1 -1
  205. package/package.json +4 -15
  206. package/dist/admin/auth-config.d.ts +0 -6
  207. package/dist/admin/browser/fetch-op.d.ts +0 -10
  208. package/dist/admin/browser/fetch-op.js +0 -44
  209. package/dist/admin/browser/main.d.ts +0 -1
  210. package/dist/admin/browser/main.js +0 -23
  211. package/dist/admin/ops-impl.d.ts +0 -51
  212. package/dist/admin/ops-impl.js +0 -1403
  213. package/dist/admin/shell/InviteModal.d.ts +0 -6
  214. package/dist/admin/shell/InviteModal.js +0 -28
  215. package/dist/admin/shell/LoginView.d.ts +0 -5
  216. package/dist/admin/shell/LoginView.js +0 -80
  217. package/dist/admin/shell/SchemaNotice.d.ts +0 -4
  218. package/dist/admin/shell/SchemaNotice.js +0 -4
  219. package/dist/admin/shell/SetPasswordView.d.ts +0 -7
  220. package/dist/admin/shell/SetPasswordView.js +0 -41
  221. package/dist/admin/shell/presence-realtime.d.ts +0 -3
  222. package/dist/admin/shell/presence-realtime.js +0 -32
  223. package/dist/admin/supabase-auth.d.ts +0 -7
  224. package/dist/admin/supabase-auth.js +0 -30
  225. package/dist/astro/deploy-deps.d.ts +0 -10
  226. package/dist/astro/deploy-deps.js +0 -8
  227. package/dist/astro/image.d.ts +0 -27
  228. package/dist/astro/image.js +0 -29
  229. package/dist/bind.d.ts +0 -19
  230. package/dist/bind.js +0 -34
  231. package/dist/cli/add-admin.d.ts +0 -11
  232. package/dist/cli/add-admin.js +0 -25
  233. package/dist/cli/load-collections.d.ts +0 -2
  234. package/dist/cli/load-collections.js +0 -27
  235. package/dist/cli/migrate.d.ts +0 -43
  236. package/dist/cli/migrate.js +0 -138
  237. package/dist/cli/rebuild.d.ts +0 -23
  238. package/dist/cli/rebuild.js +0 -22
  239. package/dist/cli/space-io.d.ts +0 -41
  240. package/dist/cli/space-io.js +0 -84
  241. package/dist/client.d.ts +0 -11
  242. package/dist/client.js +0 -54
  243. package/dist/current-space.d.ts +0 -4
  244. package/dist/current-space.js +0 -9
  245. package/dist/document-stamp.d.ts +0 -7
  246. package/dist/document-stamp.js +0 -48
  247. package/dist/image/loaders.d.ts +0 -15
  248. package/dist/image/loaders.js +0 -5
  249. package/dist/image/next-config.d.ts +0 -18
  250. package/dist/image/next-config.js +0 -32
  251. package/dist/image/remote-pattern.d.ts +0 -1
  252. package/dist/image/remote-pattern.js +0 -5
  253. package/dist/migrations.d.ts +0 -37
  254. package/dist/migrations.js +0 -73
  255. package/dist/project.d.ts +0 -88
  256. package/dist/project.js +0 -231
  257. package/dist/published.d.ts +0 -142
  258. package/dist/published.js +0 -164
  259. package/dist/ref-index.d.ts +0 -19
  260. package/dist/ref-index.js +0 -0
  261. package/dist/schema-read.d.ts +0 -23
  262. package/dist/schema-read.js +0 -50
  263. package/dist/schema-version.d.ts +0 -19
  264. package/dist/schema-version.js +0 -34
  265. package/dist/sql-space.d.ts +0 -44
  266. package/dist/sql-space.js +0 -289
  267. package/dist/sql.d.ts +0 -15
  268. package/dist/sql.js +0 -556
  269. package/dist/supabase-asset-store.d.ts +0 -21
  270. package/dist/supabase-asset-store.js +0 -170
  271. package/dist/supabase-entry-store.d.ts +0 -92
  272. package/dist/supabase-entry-store.js +0 -824
  273. package/dist/supabase-member-store.d.ts +0 -21
  274. package/dist/supabase-member-store.js +0 -96
  275. package/dist/supabase-path-index.d.ts +0 -30
  276. package/dist/supabase-path-index.js +0 -138
  277. package/dist/supabase-published-store.d.ts +0 -56
  278. package/dist/supabase-published-store.js +0 -314
  279. package/dist/supabase-store.d.ts +0 -103
  280. package/dist/supabase-store.js +0 -678
  281. package/migrations/20260926120000_smoodly_v1.sql +0 -535
  282. package/migrations/20260926130000_smoodly_v2.sql +0 -26
  283. package/migrations/20260926140000_smoodly_v3.sql +0 -38
  284. package/migrations/20260926150000_smoodly_v4.sql +0 -41
  285. package/migrations/20260929120000_smoodly_v5.sql +0 -56
  286. package/migrations/20260929135523_smoodly_v6.sql +0 -200
  287. package/migrations/20260930040956_smoodly_v7.sql +0 -84
  288. package/migrations/20261007120000_smoodly_v8.sql +0 -154
  289. package/migrations/20261007200000_smoodly_v9.sql +0 -30
  290. package/migrations/20261008120000_smoodly_v10.sql +0 -47
  291. package/migrations/20261009120000_smoodly_v11.sql +0 -49
  292. /package/dist/{admin/auth-config.js → deploy-store.js} +0 -0
@@ -1,1403 +0,0 @@
1
- import { entryErrors, entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
- import { chainOf, isSegment, pagePathIn, slugify } from "../paths.js";
3
- import { servedAnchor } from "../anchors.js";
4
- import { canonicalTags, distinctTags } from "../tags.js";
5
- import { can, isMemberRole, normalizeRole } from "../permissions.js";
6
- import { ASSET_COLLECTION } from "../media.js";
7
- import { adminHref, adminSlugTaken, isAdminSlug } from "../admin-path.js";
8
- import { addressesTag, affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
9
- import { ensureFixedNodes, fixedNodeOf, fixedNodeSpecs } from "./fixed-nodes.js";
10
- import { ensureGlobalItems } from "./global-items.js";
11
- import { SLUG_GRAMMAR_MESSAGE, validateCustomNode, validateFields, validateNode } from "./validate.js";
12
- import { serializeAdminConfig } from "./serialize.js";
13
- import { entryDrift, pageDrift } from "./drift.js";
14
- import { publishErrors } from "./drift-copy.js";
15
- import { buildHealthReport } from "./health.js";
16
- import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
17
- import { SETTINGS_KEY, CSS_KEY } from "../global.js";
18
- import { withDefaults } from "../field-defaults.js";
19
- import { elementSchemas } from "../richtext-walk.js";
20
- import { deployState, parseMarker, MARKER_PATH } from "../deploy-state.js";
21
- import { REDIRECT_PATH_MESSAGE, normalizeRedirectPath, redirectPathProblem } from "./redirect-path.js";
22
- import { SCHEMA_VERSION } from "../schema-version.js";
23
- import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
24
- const ok = (data) => ({ ok: true, data });
25
- /** A history row without its content (spec 2026-09-13 history §6). */
26
- const summary = ({ id, savedAt }) => ({ id, savedAt });
27
- const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
28
- /** The client's token is untrusted data: undefined skips, null is "no draft", the shape is the token, anything else is refused. */
29
- const BAD_TOKEN = Symbol("bad token");
30
- const tokenArg = (v) => v === undefined ? undefined : v === null ? null : isDraftToken(v) ? v : BAD_TOKEN;
31
- const badToken = () => err("validation", "smoodly: malformed draft token.");
32
- /** Own-property, non-array plain object — same shape guard the tree checks
33
- * below and `runAdminOp`'s envelope check both lean on. */
34
- const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
35
- /** A client-submitted tree is untyped data until proven otherwise — a
36
- * malformed shape here would corrupt the stored draft and crash every
37
- * later reader (canvas, publish-integrity walk). Structural only: does
38
- * not validate per-section field schemas (that's publish's job). */
39
- function isPageTree(tree) {
40
- if (!isPlainObject(tree) || !isPlainObject(tree.zones))
41
- return false;
42
- if (tree.meta !== undefined && !isPlainObject(tree.meta))
43
- return false;
44
- for (const nodes of Object.values(tree.zones)) {
45
- if (!Array.isArray(nodes))
46
- return false;
47
- for (const node of nodes) {
48
- if (!isPlainObject(node))
49
- return false;
50
- if (typeof node.id !== "string" || typeof node.type !== "string")
51
- return false;
52
- }
53
- }
54
- return true;
55
- }
56
- /** Store throws are stringly-typed; classify the known guards, default to internal. */
57
- const classify = (e) => {
58
- // The guard's refusal (spec 2026-09-26 concurrency §2): typed, so it never depends on message matching.
59
- if (e instanceof StaleDraftError)
60
- return { ok: false, code: "conflict", stale: true, by: e.by, at: e.at, message: e.message };
61
- const message = e instanceof Error ? e.message : String(e);
62
- if (message.includes("already exists in locale"))
63
- return err("conflict", message);
64
- if (message.includes("not a supported locale"))
65
- return err("validation", message);
66
- if (message.includes("add it there first") ||
67
- message.includes("remove it from") ||
68
- message.includes("at least one locale") ||
69
- message.includes("cannot move under it")) {
70
- return err("blocked", message);
71
- }
72
- if (message.includes("does not exist in locale"))
73
- return err("not_found", message);
74
- if (message.includes("cannot take a slug"))
75
- return err("validation", message);
76
- if (message.includes("is a live address"))
77
- return err("validation", message);
78
- // Ahead of the generic "already exists" check below: the singleton
79
- // refusal's message ("...already exists — it is a singleton.") contains
80
- // "already exists" as a substring, so it must classify here first or it
81
- // would be misread as a plain conflict.
82
- if (message.includes("is owned by code") || message.includes("it is a singleton") || message.includes("exists in every locale")) {
83
- return err("blocked", message);
84
- }
85
- if (message.includes("already exists"))
86
- return err("conflict", message);
87
- if (message.includes("is referenced by"))
88
- return err("blocked", message);
89
- if (message.includes("cannot publish"))
90
- return err("blocked", message);
91
- if (message.includes("nothing published"))
92
- return err("blocked", message);
93
- if (message.includes("already taken"))
94
- return err("conflict", message);
95
- if (message.includes("already has a page type"))
96
- return err("conflict", message);
97
- if (message.includes("exceed the tree depth") || message.includes("has child"))
98
- return err("blocked", message);
99
- // The tree guards: editing a folder as a page, moving a node into its
100
- // own subtree, and the home page's no-children invariant.
101
- if (message.includes("is a path") ||
102
- message.includes("cannot be moved under itself") ||
103
- message.includes("cannot have child pages")) {
104
- return err("blocked", message);
105
- }
106
- if (message.includes("must be in the same collection"))
107
- return err("validation", message);
108
- if (message.includes("not a valid slug"))
109
- return err("validation", message);
110
- if (message.includes("no page") || message.includes("no entry") || message.includes("no collection") || message.includes("no asset") || message.includes("no member"))
111
- return err("not_found", message);
112
- if (message.includes("not pending"))
113
- return err("conflict", message);
114
- return err("internal", message);
115
- };
116
- /** A new entry without a slug gets one from the field `f.slug({ from })` follows, as the admin's form
117
- * fills it (spec 2026-09-28 ASCII slugs §5), so a script needs no slug of its own. A given slug stays. */
118
- function followSlug(fields, descriptors) {
119
- const d = descriptors.slug;
120
- if (d?.type !== "slug" || d.from === undefined)
121
- return fields;
122
- if (typeof fields.slug === "string" && fields.slug !== "")
123
- return fields;
124
- const source = fields[d.from];
125
- const slug = typeof source === "string" ? slugify(source) : "";
126
- return slug === "" ? fields : { ...fields, slug };
127
- }
128
- export function createAdminOps(deps) {
129
- const { config, pages, entries, effects, projector } = deps;
130
- const href = deps.href ?? ((path) => path);
131
- const by = deps.actor?.email ?? undefined;
132
- const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
133
- /** The distinct tags a field holds across the collection: a localized field reads `locale`'s rows, a shared one every entry. */
134
- const tagsIn = (records, field, localized, locale, except) => distinctTags(localized
135
- ? records.flatMap((r) => (r.id !== except && r.locales[locale] ? [r.locales[locale].fields[field]] : []))
136
- : records.flatMap((r) => (r.id !== except ? [r.fields[field]] : [])));
137
- /** Every f.tags() value takes the collection's existing spelling (spec 2026-09-30 tester findings §5).
138
- * `except` is the entry being saved: its own old spelling is not a precedent. */
139
- async function foldTags(collection, locale, fields, descriptors, except) {
140
- const tagKeys = Object.entries(descriptors).filter(([key, d]) => d.type === "tags" && Array.isArray(fields[key]));
141
- if (tagKeys.length === 0)
142
- return fields;
143
- const records = await entries.list(collection);
144
- const out = { ...fields };
145
- for (const [key, d] of tagKeys) {
146
- out[key] = canonicalTags(out[key], tagsIn(records, key, d.localized === true, locale, except));
147
- }
148
- return out;
149
- }
150
- // Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
151
- // The serialized registry drift walks (spec 2026-09-27 editor safety §2) — the admin client's shape.
152
- let adminRegistryMemo = null;
153
- const adminRegistry = () => (adminRegistryMemo ?? (adminRegistryMemo = serializeAdminConfig(config)));
154
- const elementDescs = elementSchemas(config.registry.elements.map((e) => ({ name: e.name, fields: e.fields ?? {}, settings: e.settings })));
155
- // The custom section (spec 2026-09-14 §6) validates like a section, over its own schema.
156
- const sectionFields = (type) => (type === CUSTOM_NODE_TYPE ? customSchema(config.custom.toolset) : config.registry.sections.find((s) => s.name === type));
157
- const globalItem = (name) => config.registry.globals?.find((s) => s.name === name) ?? null;
158
- /** One row per registration; null when materialization could not create it. */
159
- const globalRecord = async (name) => (await entries.list(name))[0] ?? null;
160
- /** The published snapshot's drift for the editors' live-only line (spec 2026-09-27 editor safety §5). */
161
- const liveEntryFindings = async (name, record, locale) => {
162
- const row = record.locales[locale];
163
- if (row?.status !== "published" || !row.publishedVersionId)
164
- return [];
165
- const snap = await entries.getVersion(name, row.publishedVersionId);
166
- return snap ? entryDrift(adminRegistry(), name, { fields: snap.fields }) : [];
167
- };
168
- /** A projection is derived work done after the truth is written (spec 2026-10-07 §2 Failure): when it throws, the
169
- * op still succeeds — the error is logged, the targets are marked stale (best effort) to rebuild on their next
170
- * read, and the caller expires the tags all the same. */
171
- const project = async (op, kind, ids) => {
172
- if (ids.length === 0)
173
- return;
174
- try {
175
- await (kind === "page" ? projector.pages(ids) : projector.entries(ids));
176
- }
177
- catch (e) {
178
- console.error(`smoodly: ${op}: projecting ${kind}s ${ids.join(", ")} failed; their rows are marked stale and rebuild on read.`, e);
179
- try {
180
- await projector.markStale(ids.map((id) => ({ kind, id })));
181
- }
182
- catch (m) {
183
- console.error(`smoodly: ${op}: marking ${kind}s ${ids.join(", ")} stale failed too.`, m);
184
- }
185
- }
186
- };
187
- /** These entries' documents, the entries and pages that embed any of them (transitively), then their tags. Past
188
- * the projector's cap the rest are marked stale; their tags expire all the same (§2). `project: false` expires
189
- * the tags only: a save no published document can see (R17). */
190
- const fanOut = async (roots, options = {}) => {
191
- const entryIds = new Set();
192
- const pageIds = new Set();
193
- for (const root of typeof roots === "string" ? [roots] : roots) {
194
- const affected = await affectedTargets({ pages, entries }, root);
195
- for (const id of affected.entryIds)
196
- entryIds.add(id);
197
- for (const id of affected.pageIds)
198
- pageIds.add(id);
199
- }
200
- if (options.project !== false) {
201
- await project("entries", "entry", [...entryIds]);
202
- await project("entries", "page", [...pageIds]);
203
- }
204
- if (!effects?.revalidate)
205
- return;
206
- for (const id of entryIds)
207
- effects.revalidate(entryTag(id));
208
- for (const id of pageIds)
209
- effects.revalidate(pageTag(id));
210
- // A global item's row is an entry like any other: when the item
211
- // itself changes OR when something it REFERENCES does, the unit
212
- // `getSmoodlyGlobal` caches under `global:<name>` is stale too — and
213
- // that unit is a layout, on every page. `affectedTargets` returns row
214
- // ids, so resolve name → row id here. Once per fan-out, not memoized
215
- // per ops instance: a row can be materialized between two calls, and
216
- // an entry write is rare enough that one `entries.list` per
217
- // registration beats reasoning about a stale cache. Last, so the
218
- // existing entry-then-page order is untouched.
219
- const registered = config.registry.globals ?? [];
220
- if (registered.length === 0)
221
- return;
222
- const touched = new Set(entryIds);
223
- for (const item of registered) {
224
- const row = (await entries.list(item.name))[0];
225
- if (row && touched.has(row.id))
226
- effects.revalidate(globalTag(item.name));
227
- }
228
- };
229
- /** An asset's metadata (alt, title, caption, credit) is read wherever media
230
- * resolves (spec 2026-09-08 §9): the entry and page units that reference the
231
- * asset, directly or through an entry, the global items among those rows,
232
- * and the list units of every referencing entry's collection. The walk is
233
- * the entry fan-out's, seeded with the asset's id — which is no entry, so
234
- * it gets no tag of its own. */
235
- const expireAssetUsers = async (assetId) => {
236
- const { pageIds, entryIds } = await affectedTargets({ pages, entries }, assetId);
237
- const users = entryIds.filter((id) => id !== assetId);
238
- await project("assets", "entry", users);
239
- await project("assets", "page", pageIds);
240
- if (!effects?.revalidate)
241
- return;
242
- for (const id of users)
243
- effects.revalidate(entryTag(id));
244
- for (const id of pageIds)
245
- effects.revalidate(pageTag(id));
246
- const touched = new Set(users);
247
- for (const item of config.registry.globals ?? []) {
248
- const row = (await entries.list(item.name))[0];
249
- if (row && touched.has(row.id))
250
- effects.revalidate(globalTag(item.name));
251
- }
252
- for (const c of config.registry.collections ?? []) {
253
- for (const id of users) {
254
- if (await entries.get(c.name, id)) {
255
- effects.revalidate(collectionTag(c.name));
256
- break;
257
- }
258
- }
259
- }
260
- };
261
- /** Project the pages a tree change touched, then expire their cached units, in order, once each. Every id
262
- * expires, the ones past the projector's cap (marked stale) included. */
263
- const expirePages = async (ids) => {
264
- const unique = [...new Set(ids.filter((x) => typeof x === "string"))];
265
- await project("pages", "page", unique);
266
- if (!effects?.revalidate)
267
- return;
268
- for (const id of unique)
269
- effects.revalidate(pageTag(id));
270
- };
271
- /** The tree listing's unit (spec 2026-09-11 §2): every page op that changes what it shows. The same ops
272
- * change which addresses resolve, so the addresses tag fires with it (plan 2026-10-09 next cached routes). */
273
- const expireTree = () => {
274
- effects?.revalidate?.(pagesTreeTag());
275
- effects?.revalidate?.(addressesTag());
276
- };
277
- /** A collection's list units: every entry write, since a non-versioned collection's published read is its live
278
- * row. An entry write can give an address a document or take it away, so the addresses tag fires with it —
279
- * except on a save that changed no slug (`addresses: false`): an autosave of fields moves no address, and a
280
- * tag that fired per keystroke would re-render every cached miss and code-owned page for nothing. */
281
- const expireCollection = (name, options = {}) => {
282
- effects?.revalidate?.(collectionTag(name));
283
- if (options.addresses !== false)
284
- effects?.revalidate?.(addressesTag());
285
- };
286
- /** A node and every descendant, node first, in listPages() order. */
287
- const subtreeIds = async (id) => {
288
- const all = await pages.listPages();
289
- const out = [id];
290
- for (let i = 0; i < out.length; i++) {
291
- for (const p of all)
292
- if (p.parentId === out[i])
293
- out.push(p.id);
294
- }
295
- return out;
296
- };
297
- /** The ids under one parent: a move renumbers every one of them (their stored sort). */
298
- const childIds = async (parentId) => (await pages.listPages()).filter((p) => p.parentId === parentId).map((p) => p.id);
299
- /** Same walk over one collection's entries. */
300
- const entrySubtreeIds = async (collection, id) => {
301
- const all = await entries.list(collection);
302
- const out = [id];
303
- for (let i = 0; i < out.length; i++) {
304
- for (const e of all)
305
- if (e.parentId === out[i])
306
- out.push(e.id);
307
- }
308
- return out;
309
- };
310
- const def = config.locales.default;
311
- /** The name messages use for a node: its default-locale slug, else any locale's, else the id. */
312
- const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
313
- /** "kind:id" → title in the locale (+ collection for an entry): every page with a template, every entry of a routed collection. */
314
- const targetTitles = async (locale) => {
315
- const out = new Map();
316
- for (const p of await pages.listPages()) {
317
- if (p.template === null)
318
- continue;
319
- out.set(`page:${p.id}`, { title: p.locales[locale]?.title ?? nameOf(p), collection: null });
320
- }
321
- for (const c of config.registry.collections ?? []) {
322
- if (!c.path)
323
- continue;
324
- for (const e of await entries.list(c.name))
325
- out.set(`entry:${e.id}`, { title: entryTitleIn(e, locale, c), collection: c.name });
326
- }
327
- return out;
328
- };
329
- const assets = deps.assets;
330
- const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to the admin's route (createAdminRoute).");
331
- const noMembers = () => err("internal", "smoodly: no member store is configured — pass `members` to the admin's route (createAdminRoute).");
332
- const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
333
- /** unauthorized unless the actor may manage members; no actor (scripts, the seed) may. */
334
- const membersDenied = () => deps.actor && !can(deps.actor.role, "members.manage") ? err("unauthorized", "smoodly: only an admin can manage members.") : null;
335
- /** blocked when the control plane manages this space's members (a row, never a mode check). */
336
- const membersManaged = async () => (await deps.members.consoleUrl()) ? err("blocked", "smoodly: members are managed at smoodly.io.") : null;
337
- /** The two guards on setRole and remove: never your own row, never the last admin (unless the change keeps them admin). */
338
- const memberGuards = async (userId, staysAdmin) => {
339
- if (deps.actor?.userId === userId)
340
- return err("blocked", "smoodly: you cannot change your own membership.");
341
- if (staysAdmin)
342
- return null;
343
- const rows = await deps.members.list();
344
- const target = rows.find((m) => m.userId === userId);
345
- if (!target)
346
- return null; // the store answers not_found
347
- const admins = rows.filter((m) => normalizeRole(m.role) === "admin");
348
- if (normalizeRole(target.role) === "admin" && admins.length <= 1)
349
- return err("blocked", "smoodly: this site needs at least one admin.");
350
- return null;
351
- };
352
- const mb = (bytes) => `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
353
- const isDim = (v) => v === undefined || (typeof v === "number" && Number.isFinite(v) && v >= 0);
354
- /** What an asset's usage row points at, labelled for the Media screen. */
355
- const describeSource = async (sourceKind, sourceId) => {
356
- if (sourceKind === "page") {
357
- const record = await pages.getPage(sourceId);
358
- if (!record)
359
- return { title: sourceId, href: null };
360
- return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: adminHref(config.admin.path, `/pages/${sourceId}`) };
361
- }
362
- for (const item of config.registry.globals ?? []) {
363
- if (await entries.get(item.name, sourceId))
364
- return { title: item.title, href: adminHref(config.admin.path, `/globals/${encodeURIComponent(item.name)}`) };
365
- }
366
- for (const c of config.registry.collections ?? []) {
367
- const record = await entries.get(c.name, sourceId);
368
- if (record)
369
- return { title: entryTitleIn(record, def, c), href: adminHref(config.admin.path, `/${c.name}/${sourceId}`) };
370
- }
371
- return { title: sourceId, href: null };
372
- };
373
- const owned = (record, verb) => err("blocked", `smoodly: "${nameOf(record)}" is owned by code — ${verb}.`);
374
- /** The record serving "/" — guarded whether or not a registration claims
375
- * its slug: the config's homePageSlug is what routes it. Judged by the
376
- * DEFAULT locale's slug (paths.ts isHomeNode, the same rule). */
377
- const isHomeNode = (r) => r.parentId === null && r.locales[def]?.slug === config.homePageSlug;
378
- /** A ROOT page at the admin's segment can never be opened: Next serves
379
- * the static admin folder first (spec 2026-09-17 slug guard §4). */
380
- const adminSlug = (slug) => isAdminSlug(slug, config.admin.path);
381
- const slugTaken = () => err("validation", adminSlugTaken(config.admin.path));
382
- const supportedLocale = (locale) => typeof locale === "string" && config.locales.supported.includes(locale)
383
- ? null
384
- : err("validation", `smoodly: "${String(locale)}" is not a supported locale.`);
385
- const openTemplate = (name) => config.registry.pages.find((p) => p.kind === "page" && p.name === name);
386
- const mountGuard = (parent) => {
387
- const fixed = fixedNodeOf(parent, config);
388
- return fixed?.collection
389
- ? err("blocked", `smoodly: pages cannot be created under "${nameOf(parent)}" — the "${fixed.collection}" collection owns that URL space.`)
390
- : null;
391
- };
392
- /** Who wins a path in a locale over a node hanging on `own` — its chain root's owner key, `''` in the editors'
393
- * tree (spec 2026-09-29 anchored paths §5): the longest code address the path is or lies below, as the index
394
- * serves it (§2). Null when that is the node's own anchor, or the editors' tree. */
395
- const ownerOf = (path, locale, own = "") => {
396
- const served = servedAnchor(config.anchors, locale, path);
397
- return served === "" || served === own ? null : fixedNodeSpecs(config).find((s) => s.key === served) ?? null;
398
- };
399
- const pathOpts = { locales: config.locales, homePageSlug: config.homePageSlug, addresses: config.addresses };
400
- /** A record's own anchor: its chain root's owner key, `''` in the editors' tree. */
401
- const anchorOf = async (record) => {
402
- let r = record;
403
- while (r.parentId !== null) {
404
- const up = await pages.getPage(r.parentId);
405
- if (!up)
406
- break;
407
- r = up;
408
- }
409
- return r.fixed ?? "";
410
- };
411
- /** The refusal for a write that would put the node or its subtree where another code address wins (spec 2026-09-29
412
- * anchored paths §5): the tree after `change`, derived in memory, checked against the config. Only a path the write
413
- * changes counts: a page already hidden in one locale may still be renamed in another, or off the address. */
414
- const landsOnCode = async (id, change) => {
415
- const before = await pages.listPages();
416
- const was = new Map(before.map((r) => [r.id, r]));
417
- const all = before.map((r) => (r.id === id ? change(r) : r));
418
- const byId = new Map(all.map((r) => [r.id, r]));
419
- // A move into its own subtree is a cycle here; the store refuses it with its own message.
420
- for (let up = byId.get(id).parentId; up !== null; up = byId.get(up)?.parentId ?? null)
421
- if (up === id)
422
- return null;
423
- const inSubtree = (r) => r.id === id || (r.parentId !== null && byId.has(r.parentId) && inSubtree(byId.get(r.parentId)));
424
- for (const node of all.filter(inSubtree)) {
425
- const own = chainOf(node, (x) => byId.get(x))[0].fixed ?? "";
426
- for (const locale of Object.keys(node.locales)) {
427
- const path = pagePathIn(node, (x) => byId.get(x), locale, pathOpts);
428
- if (path === null || path === pagePathIn(was.get(node.id), (x) => was.get(x), locale, pathOpts))
429
- continue;
430
- const owner = ownerOf(path, locale, own);
431
- if (owner)
432
- return belongsTo(path, owner);
433
- }
434
- }
435
- return null;
436
- };
437
- const belongsTo = (path, owner) => err("validation", `smoodly: "${path}" belongs to the ${owner.title} ${owner.collection ? "collection" : "page"}.`);
438
- /** A parent where another code address wins — a page that sits there, hidden (§4) — takes no new children. */
439
- const insideCode = async (parent) => {
440
- const own = await anchorOf(parent);
441
- for (const [locale, path] of Object.entries(await pages.pathsOf(parent.id))) {
442
- const owner = ownerOf(path, locale, own);
443
- if (owner)
444
- return belongsTo(`/${owner.segments[locale]}`, owner);
445
- }
446
- return null;
447
- };
448
- /** A record whose owner no registration names any more: deleting it is all that is open (§5). */
449
- const orphan = (record) => record.fixed && !fixedNodeOf(record, config)
450
- ? err("blocked", `smoodly: "${nameOf(record)}" is no longer in code — delete it, or register it again.`)
451
- : null;
452
- const guard = async (work) => {
453
- try {
454
- return await work();
455
- }
456
- catch (e) {
457
- return classify(e);
458
- }
459
- };
460
- /** Where a new child may not go: a collection's record, a record no longer in code, a page where a code address wins. */
461
- const parentGuard = async (parent) => mountGuard(parent) ?? orphan(parent) ?? (await insideCode(parent));
462
- return {
463
- pages: {
464
- list: () => guard(async () => ok(await ensureFixedNodes(pages, config))),
465
- get: (id, locale) => guard(async () => {
466
- const unsupported = supportedLocale(locale);
467
- if (unsupported)
468
- return unsupported;
469
- const record = await pages.getPage(id);
470
- if (!record)
471
- return err("not_found", `smoodly: no page "${id}".`);
472
- const draft = record.template === null || !record.locales[locale] ? null : await pages.getDraftTree(id, locale);
473
- const paths = await pages.pathsOf(id);
474
- const urls = Object.fromEntries(Object.entries(paths).map(([l, path]) => [l, href(path, l)]));
475
- const redirects = await pages.redirectsOf(id);
476
- // A locale can only be added where the parent already has it; a
477
- // root has no parent, so every supported locale is open.
478
- const parentLocales = record.parentId === null
479
- ? config.locales.supported
480
- : Object.keys((await pages.getPage(record.parentId))?.locales ?? {});
481
- const liveTree = record.locales[locale]?.publishedVersionId ? await pages.getPublishedTree(id, locale) : null;
482
- const liveFindings = liveTree ? pageDrift(adminRegistry(), record.template, liveTree) : [];
483
- return ok({ record, locale, draft, paths, urls, redirects, parentLocales, liveFindings });
484
- }),
485
- create: (input) => guard(async () => {
486
- const unsupported = supportedLocale(input.locale);
487
- if (unsupported)
488
- return unsupported;
489
- if (input.parentId === null && adminSlug(input.slug))
490
- return slugTaken();
491
- if (input.template !== null) {
492
- const registration = openTemplate(input.template);
493
- if (!registration)
494
- return err("validation", `smoodly: no page type named "${input.template}".`);
495
- if (registration.slug !== undefined) {
496
- return err("validation", `smoodly: page type "${input.template}" is fixed — Smoodly creates that page for you.`);
497
- }
498
- }
499
- let parentPath = "";
500
- let own = "";
501
- if (input.parentId !== null) {
502
- const parent = await pages.getPage(input.parentId);
503
- if (!parent)
504
- return err("not_found", `smoodly: no page "${input.parentId}".`);
505
- const blocked = await parentGuard(parent);
506
- if (blocked)
507
- return blocked;
508
- parentPath = (await pages.pathsOf(parent.id))[input.locale];
509
- own = await anchorOf(parent);
510
- }
511
- // A code address is code's whether or not its record has landed yet (spec 2026-09-29 anchored paths §5).
512
- if (parentPath !== undefined) {
513
- // A root on the home slug serves "/" (isHomeNode), where a registered home page wins.
514
- const home = input.parentId === null && input.locale === def && input.slug === config.homePageSlug;
515
- const path = home ? "/" : `${parentPath}/${input.slug}`;
516
- const owner = ownerOf(path, input.locale, own);
517
- if (owner)
518
- return belongsTo(path, owner);
519
- }
520
- const created = await pages.createPage({
521
- locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
522
- ...(input.title !== undefined ? { title: input.title } : {}),
523
- }, by);
524
- // A folder is public at once: its row, and its parent's children list. A page starts as a draft.
525
- if (created.template === null)
526
- await expirePages([created.id, created.parentId]);
527
- expireTree();
528
- return ok(created);
529
- }),
530
- saveDraft: (id, locale, tree, expected) => guard(async () => {
531
- const unsupported = supportedLocale(locale);
532
- if (unsupported)
533
- return unsupported;
534
- if (!isPageTree(tree))
535
- return err("validation", "smoodly: malformed page tree.");
536
- const token = tokenArg(expected);
537
- if (token === BAD_TOKEN)
538
- return badToken();
539
- if (!(await pages.getPage(id)))
540
- return err("not_found", `smoodly: no page "${id}".`);
541
- const version = await pages.saveDraft(id, locale, tree, by, token);
542
- const after = await pages.getPage(id);
543
- return ok({ versionId: version.id, draft: after?.locales[locale] ? draftToken(after.locales[locale]) : null });
544
- }),
545
- publish: (id, locale, expected) => guard(async () => {
546
- const unsupported = supportedLocale(locale);
547
- if (unsupported)
548
- return unsupported;
549
- const token = tokenArg(expected);
550
- if (token === BAD_TOKEN)
551
- return badToken();
552
- const record = await pages.getPage(id);
553
- if (!record)
554
- return err("not_found", `smoodly: no page "${id}".`);
555
- const row = record.locales[locale];
556
- if (!row)
557
- return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
558
- const draft = record.template === null ? null : await pages.getDraftTree(id, locale);
559
- if (!draft) {
560
- return record.template === null
561
- ? err("blocked", `smoodly: "${nameOf(record)}" is a path — give it a page type first.`)
562
- : err("blocked", `smoodly: "${row.slug}" has no draft to publish.`);
563
- }
564
- const fieldErrors = [];
565
- // Only the page type's zones (spec 2026-09-27 editor safety §1): a removed zone's nodes never block.
566
- const shapeZones = Object.keys(openTemplate(record.template)?.zones ?? {});
567
- for (const zone of shapeZones) {
568
- const nodes = Array.isArray(draft.zones[zone]) ? draft.zones[zone] : [];
569
- for (const node of nodes) {
570
- const schema = sectionFields(node.type);
571
- if (!schema?.fields)
572
- continue;
573
- // Fields, settings (prefixed) and the css snippet (spec 2026-09-14 §3, §4); a custom section adds its row structure (§6).
574
- const errors = node.type === CUSTOM_NODE_TYPE
575
- ? validateCustomNode(node, schema.fields, elementDescs)
576
- : validateNode(node, { fields: schema.fields, settings: schema.settings }, elementDescs);
577
- for (const fe of errors) {
578
- fieldErrors.push({ field: `${node.id}.${fe.field}`, message: fe.message });
579
- }
580
- }
581
- }
582
- // The page's own fields (spec 2026-09-10 page fields §2); SEO meta is never validated.
583
- const pageDescs = openTemplate(record.template)?.fields ?? {};
584
- for (const fe of validateFields(draft.fields ?? {}, pageDescs, elementDescs)) {
585
- fieldErrors.push({ field: `page.fields.${fe.field}`, message: fe.message });
586
- }
587
- const refused = publishErrors(fieldErrors, pageDrift(adminRegistry(), record.template, draft));
588
- if (refused.length > 0) {
589
- return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed problems first.`, refused);
590
- }
591
- const published = await pages.publish(id, locale, by, token);
592
- // The parent's cached unit lists its published children; every descendant embeds the page in its ancestors.
593
- await expirePages([...(await subtreeIds(published.id)), published.parentId]);
594
- expireTree();
595
- return ok(published);
596
- }),
597
- unpublish: (id, locale) => guard(async () => {
598
- const unsupported = supportedLocale(locale);
599
- if (unsupported)
600
- return unsupported;
601
- const record = await pages.getPage(id);
602
- if (!record)
603
- return err("not_found", `smoodly: no page "${id}".`);
604
- if (!record.locales[locale])
605
- return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
606
- const gone = orphan(record);
607
- if (gone)
608
- return gone;
609
- if (isHomeNode(record))
610
- return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be unpublished.`);
611
- if (fixedNodeOf(record, config))
612
- return owned(record, "it stays published");
613
- const off = await pages.unpublish(id, locale, by);
614
- // The parent's cached unit lists its published children, the descendants' ancestors show it; the tree listing shows status.
615
- await expirePages([...(await subtreeIds(off.id)), off.parentId]);
616
- expireTree();
617
- return ok(off);
618
- }),
619
- discardDraft: (id, locale, expected) => guard(async () => {
620
- const unsupported = supportedLocale(locale);
621
- if (unsupported)
622
- return unsupported;
623
- const token = tokenArg(expected);
624
- if (token === BAD_TOKEN)
625
- return badToken();
626
- const record = await pages.getPage(id);
627
- if (!record)
628
- return err("not_found", `smoodly: no page "${id}".`);
629
- const row = record.locales[locale];
630
- if (!row)
631
- return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
632
- if (!row.publishedVersionId) {
633
- return err("blocked", `smoodly: "${nameOf(record)}" has nothing published in ${locale} — there is no version to restore.`);
634
- }
635
- const restored = await pages.discardDraft(id, locale, by, token);
636
- await expirePages([restored.id]); // the draft-mode render of this page changed
637
- return ok(restored);
638
- }),
639
- listVersions: (id, locale) => guard(async () => {
640
- const unsupported = supportedLocale(locale);
641
- if (unsupported)
642
- return unsupported;
643
- if (!(await pages.getPage(id)))
644
- return err("not_found", `smoodly: no page "${id}".`);
645
- return ok((await pages.listVersions(id, locale)).map(summary));
646
- }),
647
- restore: (id, locale, versionId, expected) => guard(async () => {
648
- const unsupported = supportedLocale(locale);
649
- if (unsupported)
650
- return unsupported;
651
- const token = tokenArg(expected);
652
- if (token === BAD_TOKEN)
653
- return badToken();
654
- const record = await pages.getPage(id);
655
- if (!record)
656
- return err("not_found", `smoodly: no page "${id}".`);
657
- if (!record.locales[locale])
658
- return err("not_found", `smoodly: page "${id}" does not exist in locale "${locale}".`);
659
- if (record.template === null)
660
- return err("blocked", `smoodly: "${nameOf(record)}" is a path — it has no versions.`);
661
- const version = typeof versionId === "string" ? await pages.getVersion(versionId) : null;
662
- if (!version || version.pageId !== id || version.locale !== locale) {
663
- return err("not_found", `smoodly: no version "${String(versionId)}" of this page in ${locale}.`);
664
- }
665
- const restored = await pages.restore(id, locale, versionId, by, token);
666
- await expirePages([restored.id]); // the draft-mode render of this page changed
667
- return ok(restored);
668
- }),
669
- delete: (id) => guard(async () => {
670
- const record = await pages.getPage(id);
671
- if (!record)
672
- return ok(null); // nothing to delete is not a failure
673
- if (fixedNodeOf(record, config)) {
674
- return err("blocked", `smoodly: "${nameOf(record)}" is owned by code — remove the registration to remove it.`);
675
- }
676
- // A home page no longer in code may go too (§5): the registration that serves "/" now lands after it.
677
- if (isHomeNode(record) && !orphan(record))
678
- return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it cannot be deleted.`);
679
- await pages.deletePage(id);
680
- // The cached unit is keyed by (locale, path) but tagged by the id
681
- // found at the first miss; the parent's unit lists its children.
682
- await expirePages([id, record.parentId]);
683
- expireTree();
684
- return ok(null);
685
- }),
686
- rename: (id, patch) => guard(async () => {
687
- const record = await pages.getPage(id);
688
- if (!record)
689
- return err("not_found", `smoodly: no page "${id}".`);
690
- const gone = orphan(record);
691
- if (gone)
692
- return gone;
693
- if (fixedNodeOf(record, config))
694
- return owned(record, "rename it in the registration");
695
- if (isHomeNode(record) && patch.slug !== undefined) {
696
- return err("blocked", `smoodly: "${nameOf(record)}" is the site root — its slug is the config's homePageSlug.`);
697
- }
698
- const unsupported = supportedLocale(patch.locale);
699
- if (unsupported)
700
- return unsupported;
701
- if (record.parentId === null && patch.slug !== undefined && adminSlug(patch.slug))
702
- return slugTaken();
703
- if (patch.slug !== undefined) {
704
- const slug = patch.slug;
705
- const lands = await landsOnCode(id, (r) => r.locales[patch.locale] ? { ...r, locales: { ...r.locales, [patch.locale]: { ...r.locales[patch.locale], slug } } } : r);
706
- if (lands)
707
- return lands;
708
- }
709
- const { redirect, ...rest } = patch;
710
- const renamed = await pages.rename(id, rest, by, { redirect: redirect ?? true });
711
- // Every descendant's path changed with this segment; the parent
712
- // lists this node's title and path.
713
- await expirePages([...(await subtreeIds(id)), record.parentId]);
714
- expireTree();
715
- return ok(renamed);
716
- }),
717
- move: (id, to) => guard(async () => {
718
- const record = await pages.getPage(id);
719
- if (!record)
720
- return err("not_found", `smoodly: no page "${id}".`);
721
- const gone = orphan(record);
722
- if (gone)
723
- return gone;
724
- if (to.parentId === null && Object.values(record.locales).some((row) => adminSlug(row.slug)))
725
- return slugTaken();
726
- if (to.parentId !== null) {
727
- // Code owns a fixed node's URL, never the root's listing order:
728
- // reparenting is refused, reordering among the root siblings is not.
729
- if (fixedNodeOf(record, config))
730
- return owned(record, "it stays at the root");
731
- if (isHomeNode(record))
732
- return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it stays at the root.`);
733
- const parent = await pages.getPage(to.parentId);
734
- if (!parent)
735
- return err("not_found", `smoodly: no page "${to.parentId}".`);
736
- // A reorder among a holding page's children is no new placement.
737
- const blocked = to.parentId === record.parentId ? null : await parentGuard(parent);
738
- if (blocked)
739
- return blocked;
740
- }
741
- if (to.parentId !== record.parentId) {
742
- const lands = await landsOnCode(id, (r) => ({ ...r, parentId: to.parentId }));
743
- if (lands)
744
- return lands;
745
- }
746
- const { redirect, ...dest } = to;
747
- const moved = await pages.move(id, dest, { redirect: redirect ?? true });
748
- // The destination's children are renumbered: their stored sort (the tree's order) changes too.
749
- await expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId, ...(await childIds(moved.parentId))]);
750
- expireTree();
751
- return ok(moved);
752
- }),
753
- setTemplate: (id, template) => guard(async () => {
754
- const record = await pages.getPage(id);
755
- if (!record)
756
- return err("not_found", `smoodly: no page "${id}".`);
757
- const gone = orphan(record);
758
- if (gone)
759
- return gone;
760
- const fixed = fixedNodeOf(record, config);
761
- if (fixed?.collection) {
762
- return err("blocked", `smoodly: "${nameOf(record)}" is the "${fixed.collection}" collection's mount — its index page is a fixed registration.`);
763
- }
764
- const registration = openTemplate(template);
765
- if (!registration)
766
- return err("validation", `smoodly: no page type named "${template}".`);
767
- if (registration.slug !== undefined) {
768
- return err("validation", `smoodly: page type "${template}" is fixed — Smoodly creates that page for you.`);
769
- }
770
- const withTemplate = await pages.setTemplate(id, template, by);
771
- // The folder becomes an unpublished page: its row goes, and with it its place in the parent's children and
772
- // the subtree's ancestors. Its own address served nothing before and serves nothing now: no unit to expire.
773
- const [, ...below] = await subtreeIds(id);
774
- await project("pages.setTemplate", "page", [id]);
775
- await expirePages([...below, record.parentId]);
776
- expireTree();
777
- return ok(withTemplate);
778
- }),
779
- addLocale: (id, locale, options) => guard(async () => {
780
- const unsupported = supportedLocale(locale) ?? supportedLocale(options?.from);
781
- if (unsupported)
782
- return unsupported;
783
- const record = await pages.getPage(id);
784
- if (!record)
785
- return err("not_found", `smoodly: no page "${id}".`);
786
- const gone = orphan(record);
787
- if (gone)
788
- return gone;
789
- // The new locale copies the source's slug: it may not land where code wins the address (spec 2026-09-29 anchored paths §5).
790
- const lands = await landsOnCode(id, (r) => r.locales[options.from] && !r.locales[locale] ? { ...r, locales: { ...r.locales, [locale]: r.locales[options.from] } } : r);
791
- if (lands)
792
- return lands;
793
- // A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
794
- const added = await pages.addLocale(id, locale, { from: options.from }, by);
795
- // A folder's new locale is public at once, in its parent's children too; a page's is a draft.
796
- if (record.template === null)
797
- await expirePages([id, record.parentId]);
798
- expireTree();
799
- return ok(added);
800
- }),
801
- removeLocale: (id, locale) => guard(async () => {
802
- const unsupported = supportedLocale(locale);
803
- if (unsupported)
804
- return unsupported;
805
- const record = await pages.getPage(id);
806
- if (!record)
807
- return err("not_found", `smoodly: no page "${id}".`);
808
- const gone = orphan(record);
809
- if (gone)
810
- return gone;
811
- if (fixedNodeOf(record, config))
812
- return owned(record, "it exists in every locale");
813
- if (isHomeNode(record))
814
- return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it exists in every locale.`);
815
- const removed = await pages.removeLocale(id, locale);
816
- // The locale may have been published: its unit and the parent's listing go.
817
- await expirePages([id, record.parentId]);
818
- expireTree();
819
- return ok(removed);
820
- }),
821
- },
822
- entries: {
823
- list: (collection) => guard(async () => ok(await entries.list(collection))),
824
- // In JS over the list the admin already reads: PostgREST has no DISTINCT over
825
- // jsonb_array_elements without an RPC (spec 2026-09-17 §6; the RPC is in the register).
826
- distinct: (collection, field, locale) => guard(async () => {
827
- const unsupported = supportedLocale(locale);
828
- if (unsupported)
829
- return unsupported;
830
- const descriptors = collectionFields(collection);
831
- if (!descriptors)
832
- return err("not_found", `smoodly: no collection named "${collection}".`);
833
- const d = descriptors[field];
834
- if (d?.type !== "tags")
835
- return err("validation", `smoodly: "${field}" is not a tags field of "${collection}".`);
836
- return ok(tagsIn(await entries.list(collection), field, d.localized === true, locale));
837
- }),
838
- get: (collection, id, locale) => guard(async () => {
839
- const unsupported = supportedLocale(locale);
840
- if (unsupported)
841
- return unsupported;
842
- const record = await entries.get(collection, id);
843
- if (!record)
844
- return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
845
- const fields = record.locales[locale] ? entryFieldsIn(record, locale) : null;
846
- // A locale can only be added where the parent already has it; a root has no parent.
847
- const parentLocales = record.parentId === null
848
- ? config.locales.supported
849
- : Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
850
- const paths = await entries.pathsOf(collection, id);
851
- const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
852
- const redirects = await entries.redirectsOf(collection, id);
853
- const liveFindings = await liveEntryFindings(collection, record, locale);
854
- return ok({ record, locale, fields, parentLocales, urls, redirects, liveFindings });
855
- }),
856
- create: (collection, input) => guard(async () => {
857
- const unsupported = supportedLocale(input.locale);
858
- if (unsupported)
859
- return unsupported;
860
- const descriptors = collectionFields(collection);
861
- if (!descriptors)
862
- return err("not_found", `smoodly: no collection named "${collection}".`);
863
- const created = await entries.create(collection, withDefaults(await foldTags(collection, input.locale, followSlug(input.fields, descriptors), descriptors), descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
864
- expireCollection(collection);
865
- return ok(created);
866
- }),
867
- save: (collection, id, locale, fields, expected, options) => guard(async () => {
868
- const unsupported = supportedLocale(locale);
869
- if (unsupported)
870
- return unsupported;
871
- const token = tokenArg(expected);
872
- if (token === BAD_TOKEN)
873
- return badToken();
874
- const descriptors = collectionFields(collection);
875
- if (!descriptors)
876
- return err("not_found", `smoodly: no collection named "${collection}".`);
877
- // A published row's slug is its live address and is never versioned, so this
878
- // save is its publish: the grammar holds here (spec 2026-09-28 ASCII slugs §3).
879
- const slug = typeof fields.slug === "string" && fields.slug !== "" ? fields.slug : null;
880
- const before = (await entries.get(collection, id))?.locales[locale];
881
- if (descriptors.slug !== undefined && (slug === null || !isSegment(slug))) {
882
- const row = before;
883
- if (row?.status === "published" && row.slug !== null && row.slug !== slug) {
884
- const message = slug === null ? "Required while the entry is published." : SLUG_GRAMMAR_MESSAGE;
885
- return err("validation", "Some fields need attention.", [{ field: "slug", message }]);
886
- }
887
- }
888
- let entry;
889
- try {
890
- entry = await entries.update(collection, id, locale, await foldTags(collection, locale, fields, descriptors, id), by, token, { redirect: options?.redirect ?? true });
891
- }
892
- catch (e) {
893
- // A sibling's slug is the slug field's problem, not the whole draft's (spec 2026-09-28 ASCII slugs §5).
894
- if (e instanceof Error && e.message === entryErrors.slugTaken(collection).message) {
895
- return err("validation", "Some fields need attention.", [{ field: "slug", message: "Another entry here already uses this slug." }]);
896
- }
897
- throw e;
898
- }
899
- // A save projects only when a published document can see it (R17): a published row whose read is its live
900
- // row, or a slug change (the slug is never versioned: it is live at once). In a tree collection every
901
- // descendant's address hangs on the slug, published or not. Any other save is a draft: tags only.
902
- const after = entry.locales[locale];
903
- const slugChanged = after !== undefined && after.slug !== (before?.slug ?? null);
904
- const tree = config.registry.collections?.find((c) => c.name === collection)?.tree !== undefined;
905
- const below = tree && slugChanged ? (await entrySubtreeIds(collection, id)).slice(1) : [];
906
- if (after?.status === "published" && (after.publishedVersionId === null || slugChanged)) {
907
- await fanOut([id, ...below]);
908
- }
909
- else {
910
- await fanOut(id, { project: false });
911
- if (below.length > 0)
912
- await fanOut(below);
913
- }
914
- expireCollection(collection, { addresses: slugChanged }); // the slug is the address; nothing else in a save moves one
915
- return ok(entry);
916
- }),
917
- setStatus: (collection, id, locale, status, expected) => guard(async () => {
918
- const unsupported = supportedLocale(locale);
919
- if (unsupported)
920
- return unsupported;
921
- const token = tokenArg(expected);
922
- if (token === BAD_TOKEN)
923
- return badToken();
924
- if (status === "published") {
925
- // A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
926
- const descriptors = collectionFields(collection);
927
- if (!descriptors)
928
- return err("not_found", `smoodly: no collection named "${collection}".`);
929
- const record = await entries.get(collection, id);
930
- if (!record)
931
- return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
932
- if (!record.locales[locale])
933
- return err("not_found", `smoodly: entry "${id}" does not exist in locale "${locale}".`);
934
- const merged = entryFieldsIn(record, locale);
935
- const fieldErrors = publishErrors(validateFields(merged, descriptors, elementDescs), entryDrift(adminRegistry(), collection, { fields: merged }));
936
- if (fieldErrors.length > 0)
937
- return err("validation", "Some fields need attention.", fieldErrors);
938
- }
939
- const entry = await entries.setStatus(collection, id, locale, status, by, token);
940
- await fanOut(id);
941
- expireCollection(collection);
942
- return ok(entry);
943
- }),
944
- discardDraft: (collection, id, locale, expected) => guard(async () => {
945
- const unsupported = supportedLocale(locale);
946
- if (unsupported)
947
- return unsupported;
948
- const token = tokenArg(expected);
949
- if (token === BAD_TOKEN)
950
- return badToken();
951
- const restored = await entries.discardDraft(collection, id, locale, by, token);
952
- await fanOut(id);
953
- expireCollection(collection);
954
- return ok(restored);
955
- }),
956
- listVersions: (collection, id, locale) => guard(async () => {
957
- const unsupported = supportedLocale(locale);
958
- if (unsupported)
959
- return unsupported;
960
- if (!(await entries.get(collection, id)))
961
- return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
962
- return ok((await entries.listVersions(collection, id, locale)).map(summary));
963
- }),
964
- getVersion: (collection, id, versionId) => guard(async () => {
965
- const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
966
- if (!version || version.entryId !== id) {
967
- return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}".`);
968
- }
969
- return ok({ ...summary(version), fields: version.fields });
970
- }),
971
- restore: (collection, id, locale, versionId, expected) => guard(async () => {
972
- const unsupported = supportedLocale(locale);
973
- if (unsupported)
974
- return unsupported;
975
- const token = tokenArg(expected);
976
- if (token === BAD_TOKEN)
977
- return badToken();
978
- const version = typeof versionId === "string" ? await entries.getVersion(collection, versionId) : null;
979
- if (!version || version.entryId !== id || version.locale !== locale) {
980
- return err("not_found", `smoodly: no version "${String(versionId)}" of entry "${id}" in "${collection}", locale ${locale}.`);
981
- }
982
- const restored = await entries.restore(collection, id, locale, versionId, by, token);
983
- await fanOut(id);
984
- expireCollection(collection);
985
- return ok(restored);
986
- }),
987
- // The tag expires only after the delete succeeded; a blocked delete expires nothing.
988
- delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
989
- move: (collection, id, to) => guard(async () => {
990
- const { redirect, ...dest } = to;
991
- const moved = await entries.move(collection, id, dest, { redirect: redirect ?? true });
992
- // The subtree's addresses changed, and with them every document embedding one of them.
993
- const subtree = await entrySubtreeIds(collection, id);
994
- await fanOut(subtree);
995
- // The destination's children are renumbered: their stored sort (a manual list's order) changes too.
996
- const inSubtree = new Set(subtree);
997
- const siblings = (await entries.list(collection)).filter((e) => e.parentId === moved.parentId && !inSubtree.has(e.id)).map((e) => e.id);
998
- await project("entries.move", "entry", siblings);
999
- if (effects?.revalidate) {
1000
- for (const entryId of siblings)
1001
- effects.revalidate(entryTag(entryId));
1002
- }
1003
- expireCollection(collection);
1004
- return ok(moved);
1005
- }),
1006
- // A new locale starts as a draft: nothing public changes, but the collection's
1007
- // list units are expired anyway (spec 2026-09-11 §2) so they can never go stale.
1008
- addLocale: (collection, id, locale, options) => guard(async () => {
1009
- const unsupported = supportedLocale(locale);
1010
- if (unsupported)
1011
- return unsupported;
1012
- const added = await entries.addLocale(collection, id, locale, { from: options.from }, by);
1013
- expireCollection(collection);
1014
- return ok(added);
1015
- }),
1016
- removeLocale: (collection, id, locale) => guard(async () => {
1017
- const unsupported = supportedLocale(locale);
1018
- if (unsupported)
1019
- return unsupported;
1020
- const entry = await entries.removeLocale(collection, id, locale);
1021
- await fanOut(id);
1022
- expireCollection(collection);
1023
- return ok(entry);
1024
- }),
1025
- },
1026
- globals: {
1027
- list: () => guard(async () => {
1028
- const records = await ensureGlobalItems(entries, config);
1029
- return ok((config.registry.globals ?? []).flatMap((s) => (records[s.name] ? [{ name: s.name, record: records[s.name] }] : [])));
1030
- }),
1031
- get: (name, locale) => guard(async () => {
1032
- const unsupported = supportedLocale(locale);
1033
- if (unsupported)
1034
- return unsupported;
1035
- const item = globalItem(name);
1036
- if (!item)
1037
- return err("not_found", `smoodly: no global item named "${name}".`);
1038
- await ensureGlobalItems(entries, config);
1039
- const record = await globalRecord(name);
1040
- if (!record?.locales[locale])
1041
- return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
1042
- const usage = await entries.referencesTo(name, record.id);
1043
- const liveFindings = await liveEntryFindings(name, record, locale);
1044
- return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage, liveFindings });
1045
- }),
1046
- save: (name, locale, fields, expected) => guard(async () => {
1047
- const unsupported = supportedLocale(locale);
1048
- if (unsupported)
1049
- return unsupported;
1050
- const token = tokenArg(expected);
1051
- if (token === BAD_TOKEN)
1052
- return badToken();
1053
- const item = globalItem(name);
1054
- if (!item)
1055
- return err("not_found", `smoodly: no global item named "${name}".`);
1056
- const record = await globalRecord(name);
1057
- if (!record)
1058
- return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
1059
- const saved = await entries.update(name, record.id, locale, fields, by, token);
1060
- // fanOut expires `global:<name>` itself — this row IS the item's. A global has no slug: it projects only when
1061
- // its published read is the live row (R17); a draft save expires the tags only.
1062
- const after = saved.locales[locale];
1063
- await fanOut(record.id, { project: after?.status === "published" && after.publishedVersionId === null });
1064
- return ok(saved);
1065
- }),
1066
- setStatus: (name, locale, status, expected) => guard(async () => {
1067
- const unsupported = supportedLocale(locale);
1068
- if (unsupported)
1069
- return unsupported;
1070
- const token = tokenArg(expected);
1071
- if (token === BAD_TOKEN)
1072
- return badToken();
1073
- const item = globalItem(name);
1074
- if (!item)
1075
- return err("not_found", `smoodly: no global item named "${name}".`);
1076
- const record = await globalRecord(name);
1077
- if (!record)
1078
- return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
1079
- if (status === "published") {
1080
- // A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
1081
- if (!record.locales[locale])
1082
- return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
1083
- // A visual item validates like a tree node: its fields, its section's settings under `$settings`
1084
- // (prefixed `settings.`), its `$css` (spec 2026-09-14 §3, §4).
1085
- const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...fields } = entryFieldsIn(record, locale);
1086
- const settingsDescs = item.section !== undefined ? sectionFields(item.section)?.settings : undefined;
1087
- const fieldErrors = validateNode({ fields, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: settingsDescs }, elementDescs);
1088
- const refused = publishErrors(fieldErrors, entryDrift(adminRegistry(), name, { fields: entryFieldsIn(record, locale) }));
1089
- if (refused.length > 0)
1090
- return err("validation", "Some fields need attention.", refused);
1091
- }
1092
- const changed = await entries.setStatus(name, record.id, locale, status, by, token);
1093
- // see save: the fan-out carries the item's own tag.
1094
- await fanOut(record.id);
1095
- return ok(changed);
1096
- }),
1097
- discardDraft: (name, locale, expected) => guard(async () => {
1098
- const unsupported = supportedLocale(locale);
1099
- if (unsupported)
1100
- return unsupported;
1101
- const token = tokenArg(expected);
1102
- if (token === BAD_TOKEN)
1103
- return badToken();
1104
- if (!globalItem(name))
1105
- return err("not_found", `smoodly: no global item named "${name}".`);
1106
- const record = await globalRecord(name);
1107
- if (!record)
1108
- return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
1109
- const restored = await entries.discardDraft(name, record.id, locale, by, token);
1110
- await fanOut(record.id);
1111
- return ok(restored);
1112
- }),
1113
- listVersions: (name, locale) => guard(async () => {
1114
- const unsupported = supportedLocale(locale);
1115
- if (unsupported)
1116
- return unsupported;
1117
- if (!globalItem(name))
1118
- return err("not_found", `smoodly: no global item named "${name}".`);
1119
- const record = await globalRecord(name);
1120
- if (!record)
1121
- return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
1122
- return ok((await entries.listVersions(name, record.id, locale)).map(summary));
1123
- }),
1124
- getVersion: (name, versionId) => guard(async () => {
1125
- if (!globalItem(name))
1126
- return err("not_found", `smoodly: no global item named "${name}".`);
1127
- const record = await globalRecord(name);
1128
- if (!record)
1129
- return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
1130
- const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
1131
- if (!version || version.entryId !== record.id) {
1132
- return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}".`);
1133
- }
1134
- return ok({ ...summary(version), fields: version.fields });
1135
- }),
1136
- restore: (name, locale, versionId, expected) => guard(async () => {
1137
- const unsupported = supportedLocale(locale);
1138
- if (unsupported)
1139
- return unsupported;
1140
- const token = tokenArg(expected);
1141
- if (token === BAD_TOKEN)
1142
- return badToken();
1143
- if (!globalItem(name))
1144
- return err("not_found", `smoodly: no global item named "${name}".`);
1145
- const record = await globalRecord(name);
1146
- if (!record)
1147
- return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
1148
- const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
1149
- if (!version || version.entryId !== record.id || version.locale !== locale) {
1150
- return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}" in ${locale}.`);
1151
- }
1152
- const restored = await entries.restore(name, record.id, locale, versionId, by, token);
1153
- await fanOut(record.id);
1154
- return ok(restored);
1155
- }),
1156
- },
1157
- assets: {
1158
- list: (query) => guard(async () => (assets ? ok(await assets.list(query)) : noMedia())),
1159
- get: (id) => guard(async () => {
1160
- if (!assets)
1161
- return noMedia();
1162
- const record = await assets.get(id);
1163
- return record ? ok(record) : err("not_found", `smoodly: no asset "${id}".`);
1164
- }),
1165
- beginUpload: (input) => guard(async () => {
1166
- if (!assets)
1167
- return noMedia();
1168
- if (typeof input?.name !== "string" || typeof input.mime !== "string" || typeof input.size !== "number") {
1169
- return err("validation", "smoodly: an upload needs a name, a mime type and a size.");
1170
- }
1171
- if (!config.media.accept.includes(input.mime))
1172
- return err("validation", `smoodly: "${input.mime}" is not an accepted file type.`);
1173
- if (input.size > config.media.maxSize)
1174
- return err("validation", `smoodly: the file is larger than ${mb(config.media.maxSize)}.`);
1175
- return ok(await assets.beginUpload({ filename: input.name, mime: input.mime, size: input.size }));
1176
- }),
1177
- finishUpload: (id, dims) => guard(async () => {
1178
- if (!assets)
1179
- return noMedia();
1180
- const d = dims ?? {};
1181
- if (!isDim(d.width) || !isDim(d.height) || !isDim(d.duration) || (d.posterId !== undefined && typeof d.posterId !== "string")) {
1182
- return err("validation", "smoodly: malformed dimensions.");
1183
- }
1184
- return ok(await assets.finishUpload(id, d));
1185
- }),
1186
- update: (id, meta) => guard(async () => {
1187
- if (!assets)
1188
- return noMedia();
1189
- const patch = {};
1190
- for (const key of ["title", "alt", "caption", "credit"]) {
1191
- const v = (meta ?? {})[key];
1192
- if (v === undefined)
1193
- continue;
1194
- if (v !== null && typeof v !== "string")
1195
- return err("validation", `smoodly: ${key} must be text.`);
1196
- patch[key] = v;
1197
- }
1198
- const updated = await assets.update(id, patch);
1199
- await expireAssetUsers(id);
1200
- return ok(updated);
1201
- }),
1202
- delete: (id) => guard(async () => (assets ? (await assets.delete(id), ok(null)) : noMedia())),
1203
- usage: (id) => guard(async () => {
1204
- if (!assets)
1205
- return noMedia();
1206
- const rows = await entries.referencesTo(ASSET_COLLECTION, id);
1207
- const out = [];
1208
- for (const r of rows)
1209
- out.push({ ...r, ...(await describeSource(r.sourceKind, r.sourceId)) });
1210
- return ok(out);
1211
- }),
1212
- },
1213
- members: {
1214
- list: () => guard(async () => {
1215
- if (!deps.members)
1216
- return noMembers();
1217
- const denied = membersDenied();
1218
- if (denied)
1219
- return denied;
1220
- const [members, consoleUrl] = await Promise.all([deps.members.list(), deps.members.consoleUrl()]);
1221
- return ok({ members, consoleUrl });
1222
- }),
1223
- me: () => guard(async () => ok({ role: deps.actor?.role ?? "admin" })),
1224
- invite: (input) => guard(async () => {
1225
- if (!deps.members)
1226
- return noMembers();
1227
- const refused = membersDenied() ?? (await membersManaged());
1228
- if (refused)
1229
- return refused;
1230
- const email = typeof input?.email === "string" ? input.email.trim() : "";
1231
- if (!EMAIL_RE.test(email))
1232
- return err("validation", "smoodly: enter a valid email address.");
1233
- if (!isMemberRole(input.role))
1234
- return err("validation", "smoodly: the role must be admin or editor.");
1235
- if (typeof input.redirectTo !== "string" || !input.redirectTo)
1236
- return err("validation", "smoodly: the invite needs a return URL.");
1237
- const rows = await deps.members.list();
1238
- if (rows.some((m) => m.email?.toLowerCase() === email.toLowerCase()))
1239
- return err("conflict", "smoodly: already a member.");
1240
- return ok(await deps.members.invite({ email, role: input.role, redirectTo: input.redirectTo }));
1241
- }),
1242
- setRole: (userId, role) => guard(async () => {
1243
- if (!deps.members)
1244
- return noMembers();
1245
- const refused = membersDenied() ?? (await membersManaged());
1246
- if (refused)
1247
- return refused;
1248
- if (!isMemberRole(role))
1249
- return err("validation", "smoodly: the role must be admin or editor.");
1250
- const guardHit = await memberGuards(userId, role === "admin");
1251
- if (guardHit)
1252
- return guardHit;
1253
- return ok(await deps.members.setRole(userId, role));
1254
- }),
1255
- remove: (userId) => guard(async () => {
1256
- if (!deps.members)
1257
- return noMembers();
1258
- const refused = membersDenied() ?? (await membersManaged());
1259
- if (refused)
1260
- return refused;
1261
- const guardHit = await memberGuards(userId, false);
1262
- if (guardHit)
1263
- return guardHit;
1264
- await deps.members.remove(userId);
1265
- return ok(null);
1266
- }),
1267
- },
1268
- redirects: {
1269
- list: (locale) => guard(async () => {
1270
- const unsupported = supportedLocale(locale);
1271
- if (unsupported)
1272
- return unsupported;
1273
- const rows = await pages.listRedirects(locale);
1274
- const titles = await targetTitles(locale);
1275
- const items = [];
1276
- for (const r of rows) {
1277
- const t = titles.get(`${r.kind}:${r.id}`);
1278
- const currentPath = (await pages.targetPaths({ kind: r.kind, id: r.id }))[locale] ?? null;
1279
- items.push({ locale, path: r.path, target: { kind: r.kind, id: r.id }, collection: t?.collection ?? null, title: t?.title ?? r.id, currentPath, url: currentPath ? href(currentPath, locale) : null });
1280
- }
1281
- return ok(items.sort((a, b) => a.path.localeCompare(b.path)));
1282
- }),
1283
- targets: (locale) => guard(async () => {
1284
- const unsupported = supportedLocale(locale);
1285
- if (unsupported)
1286
- return unsupported;
1287
- const titles = await targetTitles(locale);
1288
- const out = [];
1289
- for (const r of await pages.listLive(locale)) {
1290
- const t = titles.get(`${r.kind}:${r.id}`);
1291
- if (!t)
1292
- continue; // a folder, or an entry outside a routed collection
1293
- out.push({ target: { kind: r.kind, id: r.id }, collection: t.collection, title: t.title, path: r.path });
1294
- }
1295
- return ok(out.sort((a, b) => a.path.localeCompare(b.path)));
1296
- }),
1297
- add: (locale, typed, target) => guard(async () => {
1298
- const path = normalizeRedirectPath(typed);
1299
- const unsupported = supportedLocale(locale);
1300
- if (unsupported)
1301
- return unsupported;
1302
- const problem = redirectPathProblem(path, { adminPath: config.admin.path, multilingual: config.locales.supported.length > 1 });
1303
- if (problem)
1304
- return err("validation", REDIRECT_PATH_MESSAGE[problem]);
1305
- const titles = await targetTitles(locale);
1306
- if (!titles.has(`${target.kind}:${target.id}`))
1307
- return err("not_found", `smoodly: no ${target.kind} "${target.id}" to redirect to.`);
1308
- const holder = await pages.resolvePath(locale, path);
1309
- if (holder && !holder.redirect) {
1310
- const owner = titles.get(`${holder.kind}:${holder.id}`)?.title ?? holder.id;
1311
- return err("validation", `smoodly: "${path}" is the address of "${owner}".`);
1312
- }
1313
- await pages.addRedirect({ kind: target.kind, id: target.id, locale, path });
1314
- effects?.revalidate?.(addressesTag()); // a cached 404 at the old address must become the 308
1315
- return ok(null);
1316
- }),
1317
- remove: (locale, path) => guard(async () => {
1318
- const unsupported = supportedLocale(locale);
1319
- if (unsupported)
1320
- return unsupported;
1321
- await pages.removeRedirect(locale, path); // the stored address, as list gave it
1322
- effects?.revalidate?.(addressesTag()); // a cached 308 must become the 404
1323
- return ok(null);
1324
- }),
1325
- },
1326
- health: {
1327
- scan: () => guard(async () => {
1328
- const [trees, rows, delivery] = await Promise.all([pages.scanTrees(), entries.scanEntries(), projector.delivery()]);
1329
- return ok({ ...buildHealthReport(adminRegistry(), trees, rows), delivery });
1330
- }),
1331
- },
1332
- schema: {
1333
- status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
1334
- },
1335
- preview: {
1336
- enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
1337
- disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
1338
- },
1339
- deploy: deployOps(deps.deploy, by, guard),
1340
- };
1341
- }
1342
- /** The marker is fetched with a throwaway query and no-store, so no cache between answers with the old
1343
- * file (§5). A site that does not answer is "unreachable", never a throw; a 404 (a site built before this
1344
- * version, or live mode by mistake) is reachable with no marker, so every row counts as waiting. */
1345
- function deployOps(d, by, guard) {
1346
- const notConfigured = () => err("not_found", "smoodly: this site has no deploy step (live mode).");
1347
- if (!d)
1348
- return { status: async () => notConfigured(), trigger: async () => notConfigured() };
1349
- const get = d.fetch ?? fetch;
1350
- const now = () => d.now?.() ?? Date.now();
1351
- const read = async () => {
1352
- const at = now();
1353
- let marker = null;
1354
- let reachable = true;
1355
- try {
1356
- // A hanging address is "unreachable" after five seconds, never a held operation.
1357
- const res = await get(`${d.siteUrl.replace(/\/$/, "")}/${MARKER_PATH}?t=${at}`, { cache: "no-store", signal: AbortSignal.timeout(5000) });
1358
- marker = res.ok ? parseMarker(await res.text()) : null;
1359
- reachable = res.ok || res.status === 404;
1360
- }
1361
- catch {
1362
- reachable = false;
1363
- }
1364
- const through = marker?.publishedThrough ?? 0;
1365
- const [press, newer, removedAt] = await Promise.all([d.store.deployRequest(), d.store.countPublishedAfter(through), d.store.lastRemovalAt()]);
1366
- // An unpublish or a delete after the build is a change too: one the count of newer rows cannot see.
1367
- const waiting = newer + (removedAt !== null && removedAt > through ? 1 : 0);
1368
- return { ...deployState({ marker, reachable, press, waiting, now: at }), hook: Boolean(d.hook) };
1369
- };
1370
- return {
1371
- status: () => guard(async () => ok(await read())),
1372
- trigger: () => guard(async () => {
1373
- if (!d.hook)
1374
- return err("blocked", "smoodly: SMOODLY_DEPLOY_HOOK is not set — see the deploying guide.");
1375
- const current = await read();
1376
- if (current.state === "building")
1377
- return err("blocked", "smoodly: a build is already in flight.");
1378
- const res = await get(d.hook, { method: "POST", signal: AbortSignal.timeout(10000) });
1379
- if (!res.ok)
1380
- return err("blocked", `smoodly: the deploy hook answered ${res.status}.`);
1381
- await d.store.requestDeploy(by ?? null, now());
1382
- return ok(await read());
1383
- }),
1384
- };
1385
- }
1386
- /** Path-dispatch for the one-endpoint seam. Own-property checks only —
1387
- * "constructor.constructor" and prototype paths must not resolve. */
1388
- export async function runAdminOp(ops, call) {
1389
- if (!isPlainObject(call) || typeof call.path !== "string" || !Array.isArray(call.args)) {
1390
- return { ok: false, code: "not_found", message: "smoodly: malformed op call." };
1391
- }
1392
- const [group, method, ...rest] = call.path.split(".");
1393
- const groupObj = rest.length === 0 && group && Object.prototype.hasOwnProperty.call(ops, group)
1394
- ? ops[group]
1395
- : undefined;
1396
- const fn = groupObj && method && Object.prototype.hasOwnProperty.call(groupObj, method)
1397
- ? groupObj[method]
1398
- : undefined;
1399
- if (typeof fn !== "function") {
1400
- return { ok: false, code: "not_found", message: `smoodly: unknown op "${call.path}".` };
1401
- }
1402
- return fn(...call.args);
1403
- }