smoodly 0.0.4 → 0.0.6

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 (89) hide show
  1. package/README.md +408 -34
  2. package/dist/admin/client-ops.js +2 -2
  3. package/dist/admin/editor/EditorView.d.ts +3 -2
  4. package/dist/admin/editor/EditorView.js +133 -80
  5. package/dist/admin/editor/PageSettings.d.ts +13 -0
  6. package/dist/admin/editor/PageSettings.js +40 -0
  7. package/dist/admin/fixed-nodes.d.ts +23 -0
  8. package/dist/admin/fixed-nodes.js +83 -0
  9. package/dist/admin/forms/FieldWidget.d.ts +2 -1
  10. package/dist/admin/forms/FieldWidget.js +16 -16
  11. package/dist/admin/index.d.ts +2 -1
  12. package/dist/admin/index.js +1 -0
  13. package/dist/admin/next/create-admin.js +7 -2
  14. package/dist/admin/next/op-handler.d.ts +1 -0
  15. package/dist/admin/next/op-handler.js +7 -2
  16. package/dist/admin/ops-impl.d.ts +4 -1
  17. package/dist/admin/ops-impl.js +190 -48
  18. package/dist/admin/ops.d.ts +39 -11
  19. package/dist/admin/serialize.d.ts +10 -1
  20. package/dist/admin/serialize.js +13 -2
  21. package/dist/admin/shell/AdminApp.js +36 -74
  22. package/dist/admin/shell/EntryForm.js +34 -10
  23. package/dist/admin/shell/ErrorPane.d.ts +4 -0
  24. package/dist/admin/shell/ErrorPane.js +9 -0
  25. package/dist/admin/shell/ListView.d.ts +8 -5
  26. package/dist/admin/shell/ListView.js +12 -11
  27. package/dist/admin/shell/LoginView.js +6 -2
  28. package/dist/admin/shell/PagesList.d.ts +6 -0
  29. package/dist/admin/shell/PagesList.js +148 -0
  30. package/dist/admin/shell/base.d.ts +1 -0
  31. package/dist/admin/shell/base.js +4 -0
  32. package/dist/admin/shell/pages-tree.d.ts +39 -0
  33. package/dist/admin/shell/pages-tree.js +65 -0
  34. package/dist/admin/ui/format.d.ts +1 -0
  35. package/dist/admin/ui/format.js +25 -0
  36. package/dist/admin/ui/primitives.d.ts +89 -0
  37. package/dist/admin/ui/primitives.js +115 -0
  38. package/dist/admin/ui/theme.d.ts +5 -0
  39. package/dist/admin/ui/theme.js +396 -0
  40. package/dist/collections.d.ts +23 -2
  41. package/dist/collections.js +19 -0
  42. package/dist/config.d.ts +11 -1
  43. package/dist/config.js +71 -13
  44. package/dist/entry-page.d.ts +16 -0
  45. package/dist/entry-page.js +6 -0
  46. package/dist/entry-store.d.ts +94 -7
  47. package/dist/entry-store.js +175 -26
  48. package/dist/index.d.ts +24 -8
  49. package/dist/index.js +14 -3
  50. package/dist/localize.d.ts +19 -0
  51. package/dist/localize.js +42 -0
  52. package/dist/next/index.d.ts +2 -2
  53. package/dist/next/index.js +1 -1
  54. package/dist/next/meta.d.ts +0 -6
  55. package/dist/next/meta.js +0 -11
  56. package/dist/next/page-renderer.d.ts +26 -18
  57. package/dist/next/page-renderer.js +79 -56
  58. package/dist/page.d.ts +10 -3
  59. package/dist/page.js +12 -2
  60. package/dist/pairing.d.ts +7 -6
  61. package/dist/path-index.d.ts +16 -0
  62. package/dist/path-index.js +33 -0
  63. package/dist/paths.d.ts +80 -0
  64. package/dist/paths.js +110 -0
  65. package/dist/render.d.ts +34 -5
  66. package/dist/render.js +9 -2
  67. package/dist/resolve.d.ts +8 -0
  68. package/dist/resolve.js +19 -7
  69. package/dist/revalidate.d.ts +10 -2
  70. package/dist/revalidate.js +13 -8
  71. package/dist/site.d.ts +85 -0
  72. package/dist/site.js +164 -0
  73. package/dist/sql-space.d.ts +1 -1
  74. package/dist/sql-space.js +2 -0
  75. package/dist/sql.d.ts +1 -1
  76. package/dist/sql.js +92 -15
  77. package/dist/store.d.ts +106 -30
  78. package/dist/store.js +242 -66
  79. package/dist/supabase-entry-store.d.ts +17 -3
  80. package/dist/supabase-entry-store.js +198 -22
  81. package/dist/supabase-path-index.d.ts +11 -0
  82. package/dist/supabase-path-index.js +52 -0
  83. package/dist/supabase-store.d.ts +46 -16
  84. package/dist/supabase-store.js +322 -110
  85. package/package.json +5 -4
  86. package/dist/admin/page-path.d.ts +0 -1
  87. package/dist/admin/page-path.js +0 -6
  88. package/dist/admin/shell/pages-list.d.ts +0 -22
  89. package/dist/admin/shell/pages-list.js +0 -31
@@ -1,16 +1,25 @@
1
1
  // The Supabase adapter for the PageStore contract, over pages +
2
- // page_versions. Server-side only, service-role client — writes bypass
3
- // RLS by design because they flow through Smoodly's API (DESIGN.md §1).
4
- // No FK links pages' version pointers to page_versions (they'd be
5
- // circular), so tree reads are two indexed lookups, not embeds.
6
- import { assertPublishable, pageRefEdges } from "./store.js";
2
+ // page_versions + paths. Server-side only (the service role in self-host,
3
+ // a write key in the cloud); writes flow through Smoodly's API by design
4
+ // (DESIGN.md §1). No FK links pages' version pointers to page_versions
5
+ // (they'd be circular), so tree reads are two indexed lookups. Path rows
6
+ // are rewritten through ONE RPC per write, so a collision rolls back.
7
+ import { assertPageI18n, assertPublishable, pageErrors, pageRefEdges, pathOptions, } from "./store.js";
8
+ import { SupabasePathIndex } from "./supabase-path-index.js";
9
+ import { assertDepth, assertSegment, pagePathRows } from "./paths.js";
7
10
  const UNIQUE_VIOLATION = "23505";
11
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
12
  function toRecord(row) {
9
13
  return {
10
14
  id: row.id,
15
+ parentId: row.parent_id,
11
16
  slug: row.slug,
17
+ title: row.title,
18
+ i18n: row.i18n,
12
19
  template: row.template,
13
20
  status: row.status,
21
+ sort: row.sort === null ? null : Number(row.sort),
22
+ locales: row.locales,
14
23
  draftVersionId: row.draft_version_id,
15
24
  publishedVersionId: row.published_version_id,
16
25
  };
@@ -22,159 +31,362 @@ export class SupabasePageStore {
22
31
  constructor(db, options = {}) {
23
32
  this.db = db;
24
33
  this.options = options;
34
+ this.paths = options.paths ?? new SupabasePathIndex(db);
35
+ this.opts = pathOptions(options);
36
+ }
37
+ fail(message) {
38
+ throw new Error(`smoodly: ${message}`);
39
+ }
40
+ // Callers always pass a PageRecord (camelCase), never a raw row.
41
+ isHome(page) {
42
+ return page.parentId === null && page.slug === this.opts.homePageSlug;
43
+ }
44
+ // ── rows ──
45
+ async row(id) {
46
+ if (!UUID_RE.test(id))
47
+ return null;
48
+ const res = await this.db.from("pages").select("*").eq("id", id).maybeSingle();
49
+ if (res.error)
50
+ this.fail(res.error.message);
51
+ return res.data ? toRecord(res.data) : null;
52
+ }
53
+ async must(id) {
54
+ const page = await this.row(id);
55
+ if (!page)
56
+ throw pageErrors.notFound(id);
57
+ return page;
58
+ }
59
+ /** A one-row existence check — the shape `deletePage` needs too. */
60
+ async hasChildren(id) {
61
+ const res = await this.db.from("pages").select("id").eq("parent_id", id).limit(1);
62
+ if (res.error)
63
+ this.fail(res.error.message);
64
+ return res.data.length > 0;
65
+ }
66
+ async siblings(parentId, except) {
67
+ let query = this.db.from("pages").select("*");
68
+ query = parentId === null ? query.is("parent_id", null) : query.eq("parent_id", parentId);
69
+ const res = await query
70
+ .order("sort", { ascending: true, nullsFirst: false })
71
+ .order("created_at", { ascending: true })
72
+ .order("id", { ascending: true });
73
+ if (res.error)
74
+ this.fail(res.error.message);
75
+ return res.data.map(toRecord).filter((p) => p.id !== except);
76
+ }
77
+ /** The node's ancestors (root first, node last), by walking parent_id. */
78
+ async chain(page) {
79
+ const chain = [page];
80
+ let current = page;
81
+ while (current.parentId !== null) {
82
+ const parent = await this.row(current.parentId);
83
+ if (!parent)
84
+ this.fail(`broken parent chain at "${page.id}".`);
85
+ chain.unshift(parent);
86
+ current = parent;
87
+ }
88
+ return chain;
89
+ }
90
+ /** The node and every descendant, breadth-first. */
91
+ async subtree(page) {
92
+ const out = [page];
93
+ let frontier = [page.id];
94
+ while (frontier.length > 0) {
95
+ const res = await this.db.from("pages").select("*").in("parent_id", frontier);
96
+ if (res.error)
97
+ this.fail(res.error.message);
98
+ const rows = res.data.map(toRecord);
99
+ out.push(...rows);
100
+ frontier = rows.map((r) => r.id);
101
+ }
102
+ return out;
103
+ }
104
+ /** Rewrite path rows for a subtree: ancestors are loaded once so the
105
+ * chain lookup is a map, then ONE RPC replaces every target's rows. */
106
+ async writePaths(top) {
107
+ const ancestors = await this.chain(top);
108
+ const nodes = await this.subtree(top);
109
+ const byId = new Map([...ancestors, ...nodes].map((n) => [n.id, n]));
110
+ await this.paths.rewrite(nodes.map((n) => ({ kind: "page", id: n.id })), pagePathRows(nodes, (id) => byId.get(id), this.opts));
111
+ }
112
+ /** `touch` defaults to true (the normal edit path bumps updated_at).
113
+ * A rollback restoring prior column values passes `{ touch: false }`,
114
+ * which omits updated_at entirely — undoing a write is not an edit
115
+ * (same pattern as SupabaseEntryStore's `patch`). */
116
+ async update(id, patch, options = {}) {
117
+ const payload = (options.touch ?? true) ? { ...patch, updated_at: new Date().toISOString() } : { ...patch };
118
+ const res = await this.db
119
+ .from("pages")
120
+ .update(payload)
121
+ .eq("id", id)
122
+ .select("*")
123
+ .single();
124
+ if (res.error) {
125
+ if (res.error.code === UNIQUE_VIOLATION)
126
+ throw pageErrors.slugTaken(String(patch.slug ?? ""));
127
+ this.fail(res.error.message);
128
+ }
129
+ return toRecord(res.data);
130
+ }
131
+ async insertVersion(pageId, tree) {
132
+ const res = await this.db.from("page_versions").insert({ page_id: pageId, tree }).select("*").single();
133
+ if (res.error)
134
+ this.fail(res.error.message);
135
+ return res.data;
136
+ }
137
+ async versionTree(versionId) {
138
+ if (!versionId)
139
+ return null;
140
+ const res = await this.db.from("page_versions").select("tree").eq("id", versionId).maybeSingle();
141
+ if (res.error)
142
+ this.fail(res.error.message);
143
+ return res.data?.tree ?? null;
25
144
  }
26
145
  async writeRefs(pageId, edges) {
27
- const deleted = await this.db
28
- .from("refs")
29
- .delete()
30
- .eq("source_kind", "page")
31
- .eq("source_id", pageId);
146
+ const deleted = await this.db.from("refs").delete().eq("source_kind", "page").eq("source_id", pageId);
32
147
  if (deleted.error)
33
- throw new Error(`smoodly: ${deleted.error.message}`);
34
- // dedupe: the refs PK is (source_kind, source_id, source_field, target_id)
148
+ this.fail(deleted.error.message);
35
149
  const rows = new Map();
36
150
  for (const edge of edges) {
37
151
  rows.set(`${edge.field} ${edge.targetId}`, {
38
- source_kind: "page",
39
- source_id: pageId,
40
- source_field: edge.field,
41
- target_collection: edge.targetCollection,
42
- target_id: edge.targetId,
152
+ source_kind: "page", source_id: pageId, source_field: edge.field,
153
+ target_collection: edge.targetCollection, target_id: edge.targetId,
43
154
  });
44
155
  }
45
156
  if (rows.size > 0) {
46
157
  const inserted = await this.db.from("refs").insert([...rows.values()]);
47
158
  if (inserted.error)
48
- throw new Error(`smoodly: ${inserted.error.message}`);
159
+ this.fail(inserted.error.message);
49
160
  }
50
161
  }
51
- async pageRow(slug) {
52
- const res = await this.db.from("pages").select("*").eq("slug", slug).maybeSingle();
162
+ async reorder(ids) {
163
+ if (ids.length === 0)
164
+ return;
165
+ const res = await this.db.rpc("smoodly_reorder_pages", { p_ids: ids });
53
166
  if (res.error)
54
- throw new Error(`smoodly: ${res.error.message}`);
55
- return res.data ?? null;
167
+ this.fail(res.error.message);
56
168
  }
57
- async insertVersion(pageId, tree) {
58
- const res = await this.db
59
- .from("page_versions")
60
- .insert({ page_id: pageId, tree })
169
+ async hardDelete(id) {
170
+ for (const [table, col] of [["refs", "source_id"], ["page_versions", "page_id"], ["pages", "id"]]) {
171
+ const res = table === "refs"
172
+ ? await this.db.from(table).delete().eq("source_kind", "page").eq(col, id)
173
+ : await this.db.from(table).delete().eq(col, id);
174
+ if (res.error)
175
+ this.fail(res.error.message);
176
+ }
177
+ }
178
+ // ── contract ──
179
+ async createPage(input) {
180
+ assertSegment(input.slug, "page slug");
181
+ assertPageI18n(input.i18n);
182
+ const parentId = input.parentId ?? null;
183
+ if (parentId !== null) {
184
+ const parent = await this.must(parentId);
185
+ if (this.isHome(parent))
186
+ throw pageErrors.homeChildren();
187
+ assertDepth((await this.chain(parent)).length + 1, this.opts.depth, "the page");
188
+ }
189
+ const siblings = await this.siblings(parentId);
190
+ const inserted = await this.db
191
+ .from("pages")
192
+ .insert({
193
+ parent_id: parentId,
194
+ slug: input.slug,
195
+ title: input.title ?? input.slug,
196
+ i18n: input.i18n ?? null,
197
+ template: input.template,
198
+ sort: siblings.length + 1,
199
+ locales: input.locales ?? null,
200
+ })
61
201
  .select("*")
62
202
  .single();
63
- if (res.error)
64
- throw new Error(`smoodly: ${res.error.message}`);
65
- return res.data;
203
+ if (inserted.error) {
204
+ if (inserted.error.code === UNIQUE_VIOLATION)
205
+ throw pageErrors.slugTaken(input.slug);
206
+ this.fail(inserted.error.message);
207
+ }
208
+ let page = toRecord(inserted.data);
209
+ try {
210
+ if (input.template !== null) {
211
+ const version = await this.insertVersion(page.id, { zones: {} });
212
+ page = await this.update(page.id, { draft_version_id: version.id });
213
+ }
214
+ await this.writePaths(page);
215
+ }
216
+ catch (e) {
217
+ await this.hardDelete(page.id);
218
+ throw e;
219
+ }
220
+ return page;
221
+ }
222
+ getPage(id) {
223
+ return this.row(id);
66
224
  }
67
- async setPointers(id, patch) {
225
+ async listPages() {
68
226
  const res = await this.db
69
227
  .from("pages")
70
- .update({ ...patch, updated_at: new Date().toISOString() })
71
- .eq("id", id)
72
228
  .select("*")
73
- .single();
229
+ .order("sort", { ascending: true, nullsFirst: false })
230
+ .order("created_at", { ascending: true })
231
+ .order("id", { ascending: true });
74
232
  if (res.error)
75
- throw new Error(`smoodly: ${res.error.message}`);
76
- return res.data;
233
+ this.fail(res.error.message);
234
+ return res.data.map(toRecord);
77
235
  }
78
- async createPage({ slug, template }) {
79
- const inserted = await this.db.from("pages").insert({ slug, template }).select("*").single();
80
- if (inserted.error) {
81
- if (inserted.error.code === UNIQUE_VIOLATION)
82
- throw new Error(`smoodly: a page with slug "${slug}" already exists.`);
83
- throw new Error(`smoodly: ${inserted.error.message}`);
236
+ resolvePath(locale, path) {
237
+ return this.paths.resolve(locale, path);
238
+ }
239
+ pathsOf(id) {
240
+ return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
241
+ }
242
+ async rename(id, patch) {
243
+ const page = await this.must(id);
244
+ if (patch.slug !== undefined)
245
+ assertSegment(patch.slug, `page slug (${patch.locale})`);
246
+ // A rename that turns this node INTO the home node (root, default-locale
247
+ // slug === homePageSlug) is subject to the same invariant as createPage:
248
+ // the home page cannot have child pages.
249
+ if (patch.locale === this.opts.locales.default &&
250
+ patch.slug !== undefined &&
251
+ page.parentId === null &&
252
+ patch.slug === this.opts.homePageSlug &&
253
+ (await this.hasChildren(id))) {
254
+ throw pageErrors.homeChildren();
84
255
  }
85
- const page = inserted.data;
86
- const version = await this.insertVersion(page.id, { zones: {} });
87
- return toRecord(await this.setPointers(page.id, { draft_version_id: version.id }));
256
+ const columns = {};
257
+ if (patch.locale === this.opts.locales.default) {
258
+ if (patch.slug !== undefined)
259
+ columns.slug = patch.slug;
260
+ if (patch.title !== undefined)
261
+ columns.title = patch.title;
262
+ }
263
+ else {
264
+ const i18n = { ...(page.i18n ?? {}) };
265
+ i18n[patch.locale] = {
266
+ ...(i18n[patch.locale] ?? {}),
267
+ ...(patch.slug !== undefined ? { slug: patch.slug } : {}),
268
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
269
+ };
270
+ columns.i18n = i18n;
271
+ }
272
+ const renamed = await this.update(id, columns);
273
+ try {
274
+ await this.writePaths(renamed);
275
+ }
276
+ catch (e) {
277
+ await this.update(id, { slug: page.slug, title: page.title, i18n: page.i18n }, { touch: false });
278
+ throw e;
279
+ }
280
+ return renamed;
88
281
  }
89
- async getPage(slug) {
90
- const row = await this.pageRow(slug);
91
- return row ? toRecord(row) : null;
282
+ async move(id, to) {
283
+ const page = await this.must(id);
284
+ const subtree = await this.subtree(page);
285
+ if (to.parentId !== null) {
286
+ if (to.parentId === id || subtree.some((n) => n.id === to.parentId))
287
+ throw pageErrors.cycle();
288
+ const parent = await this.must(to.parentId);
289
+ if (this.isHome(parent))
290
+ throw pageErrors.homeChildren();
291
+ const byId = new Map(subtree.map((n) => [n.id, n]));
292
+ // The subtree's own height: walk each node up to the moved node
293
+ // itself (not to the tree root — its parentId points OUTSIDE this
294
+ // subtree map, at its current, pre-move parent, so chainOf would
295
+ // throw "broken parent chain" there instead of stopping).
296
+ const heightOf = (n) => (n.id === page.id ? 1 : 1 + heightOf(byId.get(n.parentId)));
297
+ const height = Math.max(...subtree.map(heightOf));
298
+ assertDepth((await this.chain(parent)).length + height, this.opts.depth, "the page");
299
+ }
300
+ else if (page.slug === this.opts.homePageSlug && (await this.hasChildren(id))) {
301
+ // Moving a home-slugged node TO the root is the mirror case of the
302
+ // rename guard above: it would make it the home node while it still
303
+ // has children.
304
+ throw pageErrors.homeChildren();
305
+ }
306
+ const siblings = await this.siblings(to.parentId, id);
307
+ if (to.parentId !== page.parentId && siblings.some((s) => s.slug === page.slug))
308
+ throw pageErrors.slugTaken(page.slug);
309
+ // The destination siblings' sorts as they stood BEFORE the reorder.
310
+ // A rollback restores these exact values rather than re-running the
311
+ // reorder RPC, which renumbers 1..N and would silently close gaps
312
+ // left by earlier deletes and moves.
313
+ const previousSorts = siblings.map((s) => ({ id: s.id, sort: s.sort }));
314
+ const moved = await this.update(id, { parent_id: to.parentId });
315
+ const index = Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length);
316
+ const ordered = siblings.map((s) => s.id);
317
+ ordered.splice(index, 0, id);
318
+ await this.reorder(ordered);
319
+ try {
320
+ await this.writePaths(moved);
321
+ }
322
+ catch (e) {
323
+ await this.update(id, { parent_id: page.parentId, sort: page.sort }, { touch: false });
324
+ for (const s of previousSorts)
325
+ await this.update(s.id, { sort: s.sort }, { touch: false });
326
+ throw e;
327
+ }
328
+ return { ...moved, sort: index + 1 };
92
329
  }
93
- async getPageById(id) {
94
- const res = await this.db.from("pages").select("*").eq("id", id).maybeSingle();
95
- if (res.error)
96
- throw new Error(`smoodly: ${res.error.message}`);
97
- return res.data ? toRecord(res.data) : null;
330
+ async setTemplate(id, template) {
331
+ const page = await this.must(id);
332
+ if (page.template !== null)
333
+ throw pageErrors.hasTemplate(id);
334
+ const version = await this.insertVersion(id, { zones: {} });
335
+ return this.update(id, { template, draft_version_id: version.id });
98
336
  }
99
- async saveDraft(slug, tree) {
100
- const page = await this.pageRow(slug);
101
- if (!page)
102
- throw new Error(`smoodly: no page with slug "${slug}".`);
103
- const version = await this.insertVersion(page.id, tree);
104
- await this.setPointers(page.id, { draft_version_id: version.id });
337
+ async saveDraft(id, tree) {
338
+ const page = await this.must(id);
339
+ if (page.template === null)
340
+ throw pageErrors.folder(id);
341
+ const version = await this.insertVersion(id, tree);
342
+ await this.update(id, { draft_version_id: version.id });
105
343
  if (this.options.sections) {
106
- const published = await this.versionTree(page.published_version_id);
107
- await this.writeRefs(page.id, pageRefEdges(tree, published, this.options.sections));
344
+ const published = await this.versionTree(page.publishedVersionId);
345
+ await this.writeRefs(id, pageRefEdges(tree, published, this.options.sections));
108
346
  }
109
347
  return toVersion(version);
110
348
  }
111
- async publish(slug) {
112
- const page = await this.pageRow(slug);
113
- if (!page)
114
- throw new Error(`smoodly: no page with slug "${slug}".`);
349
+ async publish(id) {
350
+ const page = await this.must(id);
351
+ if (page.template === null || !page.draftVersionId)
352
+ throw pageErrors.folder(id);
115
353
  const { sections, entries } = this.options;
116
- const draft = (await this.versionTree(page.draft_version_id)) ?? { zones: {} };
354
+ const draft = (await this.versionTree(page.draftVersionId)) ?? { zones: {} };
117
355
  if (sections && entries)
118
- await assertPublishable(slug, draft, sections, entries);
119
- const record = toRecord(await this.setPointers(page.id, {
120
- published_version_id: page.draft_version_id,
121
- status: "published",
122
- }));
123
- // draft IS published now — one tree
356
+ await assertPublishable(page.slug, draft, sections, entries);
357
+ const record = await this.update(id, { published_version_id: page.draftVersionId, status: "published" });
124
358
  if (sections)
125
- await this.writeRefs(page.id, pageRefEdges(draft, null, sections));
359
+ await this.writeRefs(id, pageRefEdges(draft, null, sections));
126
360
  return record;
127
361
  }
128
- async versionTree(versionId) {
129
- if (!versionId)
130
- return null;
131
- const res = await this.db.from("page_versions").select("tree").eq("id", versionId).maybeSingle();
132
- if (res.error)
133
- throw new Error(`smoodly: ${res.error.message}`);
134
- return res.data?.tree ?? null;
135
- }
136
- async getDraftTree(slug) {
137
- const page = await this.pageRow(slug);
138
- return page ? this.versionTree(page.draft_version_id) : null;
362
+ async getDraftTree(id) {
363
+ const page = await this.row(id);
364
+ return page ? this.versionTree(page.draftVersionId) : null;
139
365
  }
140
- async getPublishedTree(slug) {
141
- const page = await this.pageRow(slug);
142
- return page ? this.versionTree(page.published_version_id) : null;
366
+ async getPublishedTree(id) {
367
+ const page = await this.row(id);
368
+ return page ? this.versionTree(page.publishedVersionId) : null;
143
369
  }
144
- async listVersions(slug) {
145
- const page = await this.pageRow(slug);
146
- if (!page)
370
+ async listVersions(id) {
371
+ if (!UUID_RE.test(id))
147
372
  return [];
148
373
  const res = await this.db
149
374
  .from("page_versions")
150
375
  .select("*")
151
- .eq("page_id", page.id)
376
+ .eq("page_id", id)
152
377
  .order("created_at", { ascending: false })
153
378
  .order("id", { ascending: false });
154
379
  if (res.error)
155
- throw new Error(`smoodly: ${res.error.message}`);
380
+ this.fail(res.error.message);
156
381
  return res.data.map(toVersion);
157
382
  }
158
- async listPages() {
159
- const res = await this.db.from("pages").select("*").order("slug", { ascending: true });
160
- if (res.error)
161
- throw new Error(`smoodly: ${res.error.message}`);
162
- return res.data.map(toRecord);
163
- }
164
- async deletePage(slug) {
165
- const page = await this.pageRow(slug);
383
+ async deletePage(id) {
384
+ const page = await this.row(id);
166
385
  if (!page)
167
386
  return;
168
- for (const [table, col, val] of [
169
- ["refs", "source_id", page.id],
170
- ["page_versions", "page_id", page.id],
171
- ["pages", "id", page.id],
172
- ]) {
173
- const res = table === "refs"
174
- ? await this.db.from(table).delete().eq("source_kind", "page").eq(col, val)
175
- : await this.db.from(table).delete().eq(col, val);
176
- if (res.error)
177
- throw new Error(`smoodly: ${res.error.message}`);
178
- }
387
+ if (await this.hasChildren(id))
388
+ throw pageErrors.hasChildren(id);
389
+ await this.paths.remove([{ kind: "page", id }]);
390
+ await this.hardDelete(id);
179
391
  }
180
392
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,8 +45,8 @@
45
45
  "test:supabase": "bash scripts/test-supabase.sh"
46
46
  },
47
47
  "peerDependencies": {
48
- "react": "^18 || ^19",
49
- "next": ">=15"
48
+ "next": ">=16",
49
+ "react": "^18 || ^19"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "next": {
@@ -64,6 +64,7 @@
64
64
  "vitest": "^3"
65
65
  },
66
66
  "dependencies": {
67
- "@supabase/supabase-js": "^2.113.0"
67
+ "@supabase/supabase-js": "^2.113.0",
68
+ "lucide-react": "^1.41.0"
68
69
  }
69
70
  }
@@ -1 +0,0 @@
1
- export declare function pagePath(slug: string, homePageSlug: string): string;
@@ -1,6 +0,0 @@
1
- // The one place the admin turns a page slug into the site URL that serves
2
- // it. The root's slug is config (`homePageSlug`), not a convention the
3
- // package guesses at; a registration-declared path is the logged follow-up.
4
- export function pagePath(slug, homePageSlug) {
5
- return slug === homePageSlug ? "/" : `/${slug}`;
6
- }
@@ -1,22 +0,0 @@
1
- import type { PageRecord } from "../../store.ts";
2
- import type { AdminPageTemplate } from "../serialize.ts";
3
- /** Registrations editors may create pages against. A fixed-slug
4
- * registration is a singleton owned by one route file, so it is never
5
- * offered — and with exactly one open template left, the New Page flow
6
- * needs no template prompt at all. */
7
- export declare function openTemplates(templates: AdminPageTemplate[]): AdminPageTemplate[];
8
- export type PagesRow = {
9
- kind: "record";
10
- record: PageRecord;
11
- templateTitle: string;
12
- fixed: boolean;
13
- } | {
14
- kind: "missing";
15
- slug: string;
16
- template: string;
17
- templateTitle: string;
18
- };
19
- /** Fixed registrations always get a row, even before their record
20
- * exists: otherwise a fresh install's "/" is a 404 the admin offers no
21
- * way to fix, since New Page filters singletons out. */
22
- export declare function pagesRows(records: PageRecord[], templates: AdminPageTemplate[]): PagesRow[];
@@ -1,31 +0,0 @@
1
- /** Registrations editors may create pages against. A fixed-slug
2
- * registration is a singleton owned by one route file, so it is never
3
- * offered — and with exactly one open template left, the New Page flow
4
- * needs no template prompt at all. */
5
- export function openTemplates(templates) {
6
- return templates.filter((t) => t.slug === undefined);
7
- }
8
- /** Fixed registrations always get a row, even before their record
9
- * exists: otherwise a fresh install's "/" is a 404 the admin offers no
10
- * way to fix, since New Page filters singletons out. */
11
- export function pagesRows(records, templates) {
12
- const titleOf = (name) => templates.find((t) => t.name === name)?.title ?? name;
13
- const bySlug = new Map(records.map((r) => [r.slug, r]));
14
- const fixedSlugs = new Set();
15
- const rows = [];
16
- for (const t of templates) {
17
- if (t.slug === undefined)
18
- continue;
19
- fixedSlugs.add(t.slug);
20
- const record = bySlug.get(t.slug);
21
- rows.push(record
22
- ? { kind: "record", record, templateTitle: titleOf(record.template), fixed: true }
23
- : { kind: "missing", slug: t.slug, template: t.name, templateTitle: t.title });
24
- }
25
- for (const record of records) {
26
- if (fixedSlugs.has(record.slug))
27
- continue;
28
- rows.push({ kind: "record", record, templateTitle: titleOf(record.template), fixed: false });
29
- }
30
- return rows;
31
- }