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
package/dist/store.d.ts CHANGED
@@ -2,14 +2,35 @@ import type { PageTree } from "./render.tsx";
2
2
  import type { EntryStore } from "./entry-store.ts";
3
3
  import { type RefEdge, type SectionLike } from "./refs.ts";
4
4
  import type { MemoryRefIndex } from "./ref-index.ts";
5
+ import { type PathIndex } from "./path-index.ts";
6
+ import { type LocaleSet, type PagePathOptions, type PathTarget } from "./paths.ts";
7
+ export type PageI18n = Record<string, {
8
+ slug?: string;
9
+ title?: string;
10
+ }>;
5
11
  export type PageRecord = {
6
12
  id: string;
13
+ parentId: string | null;
14
+ /** The node's OWN segment in the default locale; the URL is derived (paths.ts). */
7
15
  slug: string;
8
- template: string;
16
+ title: string;
17
+ i18n: PageI18n | null;
18
+ /** null = folder: a node with a title and a position but no page behind it. */
19
+ template: string | null;
9
20
  status: "draft" | "published";
10
- draftVersionId: string;
21
+ sort: number | null;
22
+ locales: string[] | null;
23
+ draftVersionId: string | null;
11
24
  publishedVersionId: string | null;
12
25
  };
26
+ export type CreatePageInput = {
27
+ slug: string;
28
+ template: string | null;
29
+ title?: string;
30
+ parentId?: string | null;
31
+ i18n?: PageI18n | null;
32
+ locales?: string[] | null;
33
+ };
13
34
  export type PageVersion = {
14
35
  id: string;
15
36
  pageId: string;
@@ -17,22 +38,33 @@ export type PageVersion = {
17
38
  createdAt: number;
18
39
  };
19
40
  export interface PageStore {
20
- createPage(input: {
21
- slug: string;
22
- template: string;
23
- }): Promise<PageRecord>;
24
- getPage(slug: string): Promise<PageRecord | null>;
25
- /** By-id lookup — the fan-out walk holds page ids, not slugs. */
26
- getPageById(id: string): Promise<PageRecord | null>;
27
- saveDraft(slug: string, tree: PageTree): Promise<PageVersion>;
28
- publish(slug: string): Promise<PageRecord>;
29
- getDraftTree(slug: string): Promise<PageTree | null>;
30
- getPublishedTree(slug: string): Promise<PageTree | null>;
31
- listVersions(slug: string): Promise<PageVersion[]>;
32
- /** All pages, slug-ascending — the admin's pages list. */
41
+ createPage(input: CreatePageInput): Promise<PageRecord>;
42
+ getPage(id: string): Promise<PageRecord | null>;
33
43
  listPages(): Promise<PageRecord[]>;
34
- /** Removes the page, its versions and its refs rows. Missing slug: no-op. */
35
- deletePage(slug: string): Promise<void>;
44
+ resolvePath(locale: string, path: string): Promise<PathTarget | null>;
45
+ pathsOf(id: string): Promise<Record<string, string>>;
46
+ rename(id: string, patch: {
47
+ locale: string;
48
+ slug?: string;
49
+ title?: string;
50
+ }): Promise<PageRecord>;
51
+ /**
52
+ * Reparents `id` under `to.parentId`. `to.index` is the moved node's
53
+ * final 0-based position among the destination siblings: the node is
54
+ * removed from the sibling list first, then spliced back in at `index`.
55
+ * Omitted, it appends; out-of-range values clamp to [0, siblings.length].
56
+ */
57
+ move(id: string, to: {
58
+ parentId: string | null;
59
+ index?: number;
60
+ }): Promise<PageRecord>;
61
+ setTemplate(id: string, template: string): Promise<PageRecord>;
62
+ saveDraft(id: string, tree: PageTree): Promise<PageVersion>;
63
+ publish(id: string): Promise<PageRecord>;
64
+ getDraftTree(id: string): Promise<PageTree | null>;
65
+ getPublishedTree(id: string): Promise<PageTree | null>;
66
+ listVersions(id: string): Promise<PageVersion[]>;
67
+ deletePage(id: string): Promise<void>;
36
68
  }
37
69
  /** What a PageStore needs beyond its tables to maintain the refs index
38
70
  * and gate publishes. All optional: without `sections` no refs are
@@ -40,7 +72,30 @@ export interface PageStore {
40
72
  export type PageStoreOptions = {
41
73
  sections?: SectionLike[];
42
74
  entries?: EntryStore;
75
+ /** The shared URL index (one instance for pages AND entries). */
76
+ paths?: PathIndex;
77
+ locales?: LocaleSet;
78
+ homePageSlug?: string;
79
+ tree?: {
80
+ depth: number;
81
+ };
43
82
  };
83
+ export declare const DEFAULT_LOCALES: LocaleSet;
84
+ /** The options every adapter resolves the same way. */
85
+ export declare function pathOptions(options: PageStoreOptions): PagePathOptions & {
86
+ depth: number;
87
+ };
88
+ /** Shared write-side rules, so both adapters throw the same messages. */
89
+ export declare const pageErrors: {
90
+ notFound: (id: string) => Error;
91
+ slugTaken: (slug: string) => Error;
92
+ folder: (id: string) => Error;
93
+ hasTemplate: (id: string) => Error;
94
+ homeChildren: () => Error;
95
+ cycle: () => Error;
96
+ hasChildren: (id: string) => Error;
97
+ };
98
+ export declare function assertPageI18n(i18n: PageI18n | null | undefined): void;
44
99
  /** A page's refs rows cover BOTH live trees — edges(draft) ∪
45
100
  * edges(published) — so an entry stays delete-guarded while either tree
46
101
  * shows it. Callers dedupe on write (the SQL PK does it for real). */
@@ -48,26 +103,47 @@ export declare function pageRefEdges(draft: PageTree, published: PageTree | null
48
103
  /** The publish-integrity gate (DESIGN.md §3): a page must not go live
49
104
  * showing entries the published site can't resolve — every referenced
50
105
  * entry must exist and be published. Throws naming the offenders. */
51
- export declare function assertPublishable(slug: string, tree: PageTree, sections: SectionLike[], entries: EntryStore): Promise<void>;
106
+ export declare function assertPublishable(label: string, tree: PageTree, sections: SectionLike[], entries: EntryStore): Promise<void>;
52
107
  export declare class MemoryPageStore implements PageStore {
53
108
  private options;
54
109
  private pages;
55
110
  private versions;
111
+ /** Append-only creation-order tie-break; `size + 1` stays fresh only because entries are never deleted. */
112
+ private seq;
113
+ private paths;
114
+ private opts;
56
115
  constructor(options?: PageStoreOptions & {
57
116
  refs?: MemoryRefIndex;
58
117
  });
118
+ private byId;
119
+ private must;
120
+ private isHome;
121
+ private hasChildren;
122
+ private siblings;
123
+ private assertSlugFree;
124
+ private subtree;
125
+ private writePaths;
59
126
  private writeRefs;
60
- createPage({ slug, template }: {
61
- slug: string;
62
- template: string;
63
- }): Promise<PageRecord>;
64
- getPage(slug: string): Promise<PageRecord | null>;
65
- getPageById(id: string): Promise<PageRecord | null>;
66
- saveDraft(slug: string, tree: PageTree): Promise<PageVersion>;
67
- publish(slug: string): Promise<PageRecord>;
68
- getDraftTree(slug: string): Promise<PageTree | null>;
69
- getPublishedTree(slug: string): Promise<PageTree | null>;
70
- listVersions(slug: string): Promise<PageVersion[]>;
127
+ private newVersion;
128
+ createPage(input: CreatePageInput): Promise<PageRecord>;
129
+ getPage(id: string): Promise<PageRecord | null>;
71
130
  listPages(): Promise<PageRecord[]>;
72
- deletePage(slug: string): Promise<void>;
131
+ resolvePath(locale: string, path: string): Promise<PathTarget | null>;
132
+ pathsOf(id: string): Promise<Record<string, string>>;
133
+ rename(id: string, patch: {
134
+ locale: string;
135
+ slug?: string;
136
+ title?: string;
137
+ }): Promise<PageRecord>;
138
+ move(id: string, to: {
139
+ parentId: string | null;
140
+ index?: number;
141
+ }): Promise<PageRecord>;
142
+ setTemplate(id: string, template: string): Promise<PageRecord>;
143
+ saveDraft(id: string, tree: PageTree): Promise<PageVersion>;
144
+ publish(id: string): Promise<PageRecord>;
145
+ getDraftTree(id: string): Promise<PageTree | null>;
146
+ getPublishedTree(id: string): Promise<PageTree | null>;
147
+ listVersions(id: string): Promise<PageVersion[]>;
148
+ deletePage(id: string): Promise<void>;
73
149
  }
package/dist/store.js CHANGED
@@ -4,6 +4,33 @@
4
4
  // implementation and the contract-test subject; the Supabase adapter
5
5
  // implements the same interface over pages/page_versions.
6
6
  import { collectTreeRefs } from "./refs.js";
7
+ import { MemoryPathIndex } from "./path-index.js";
8
+ import { assertDepth, assertSegment, chainOf, pagePathRows, } from "./paths.js";
9
+ export const DEFAULT_LOCALES = { default: "en", supported: ["en"] };
10
+ /** The options every adapter resolves the same way. */
11
+ export function pathOptions(options) {
12
+ return {
13
+ locales: options.locales ?? DEFAULT_LOCALES,
14
+ homePageSlug: options.homePageSlug ?? "home",
15
+ depth: options.tree?.depth ?? 1,
16
+ };
17
+ }
18
+ /** Shared write-side rules, so both adapters throw the same messages. */
19
+ export const pageErrors = {
20
+ notFound: (id) => new Error(`smoodly: no page "${id}".`),
21
+ slugTaken: (slug) => new Error(`smoodly: a page with slug "${slug}" already exists under this parent.`),
22
+ folder: (id) => new Error(`smoodly: "${id}" is a folder — give it a template first.`),
23
+ hasTemplate: (id) => new Error(`smoodly: "${id}" already has a template.`),
24
+ homeChildren: () => new Error("smoodly: the home page cannot have child pages."),
25
+ cycle: () => new Error("smoodly: a page cannot be moved under itself."),
26
+ hasChildren: (id) => new Error(`smoodly: "${id}" has child pages — move or delete them first.`),
27
+ };
28
+ export function assertPageI18n(i18n) {
29
+ for (const [locale, v] of Object.entries(i18n ?? {})) {
30
+ if (v.slug !== undefined)
31
+ assertSegment(v.slug, `page slug (${locale})`);
32
+ }
33
+ }
7
34
  /** A page's refs rows cover BOTH live trees — edges(draft) ∪
8
35
  * edges(published) — so an entry stays delete-guarded while either tree
9
36
  * shows it. Callers dedupe on write (the SQL PK does it for real). */
@@ -16,7 +43,7 @@ export function pageRefEdges(draft, published, sections) {
16
43
  /** The publish-integrity gate (DESIGN.md §3): a page must not go live
17
44
  * showing entries the published site can't resolve — every referenced
18
45
  * entry must exist and be published. Throws naming the offenders. */
19
- export async function assertPublishable(slug, tree, sections, entries) {
46
+ export async function assertPublishable(label, tree, sections, entries) {
20
47
  const targets = new Map();
21
48
  for (const edge of collectTreeRefs(tree, sections)) {
22
49
  if (!targets.has(edge.targetId))
@@ -29,7 +56,7 @@ export async function assertPublishable(slug, tree, sections, entries) {
29
56
  offenders.push(`${collection}/${id} (${field}${entry ? "" : ", missing"})`);
30
57
  }
31
58
  if (offenders.length > 0) {
32
- throw new Error(`smoodly: cannot publish "${slug}" — it references entries that are not published: ${offenders.join(", ")}.`);
59
+ throw new Error(`smoodly: cannot publish "${label}" — it references entries that are not published: ${offenders.join(", ")}.`);
33
60
  }
34
61
  }
35
62
  let counter = 0;
@@ -39,6 +66,50 @@ export class MemoryPageStore {
39
66
  this.options = options;
40
67
  this.pages = new Map();
41
68
  this.versions = new Map();
69
+ /** Append-only creation-order tie-break; `size + 1` stays fresh only because entries are never deleted. */
70
+ this.seq = new Map();
71
+ this.byId = (id) => this.pages.get(id);
72
+ this.paths = options.paths ?? new MemoryPathIndex();
73
+ this.opts = pathOptions(options);
74
+ }
75
+ must(id) {
76
+ const page = this.pages.get(id);
77
+ if (!page)
78
+ throw pageErrors.notFound(id);
79
+ return page;
80
+ }
81
+ isHome(page) {
82
+ return page.parentId === null && page.slug === this.opts.homePageSlug;
83
+ }
84
+ hasChildren(id) {
85
+ return [...this.pages.values()].some((p) => p.parentId === id);
86
+ }
87
+ siblings(parentId, except) {
88
+ return [...this.pages.values()]
89
+ .filter((p) => p.parentId === parentId && p.id !== except)
90
+ // null sorts last; the SQL equivalent is ORDER BY sort ASC NULLS LAST, created_at ASC.
91
+ .sort((a, b) => (a.sort ?? Infinity) - (b.sort ?? Infinity) || this.seq.get(a.id) - this.seq.get(b.id));
92
+ }
93
+ assertSlugFree(parentId, slug, except) {
94
+ if (this.siblings(parentId, except).some((p) => p.slug === slug))
95
+ throw pageErrors.slugTaken(slug);
96
+ }
97
+ subtree(id) {
98
+ const out = [];
99
+ const walk = (parentId) => {
100
+ for (const p of this.pages.values())
101
+ if (p.parentId === parentId) {
102
+ out.push(p);
103
+ walk(p.id);
104
+ }
105
+ };
106
+ const root = this.must(id);
107
+ out.push(root);
108
+ walk(id);
109
+ return out;
110
+ }
111
+ async writePaths(nodes) {
112
+ await this.paths.rewrite(nodes.map((n) => ({ kind: "page", id: n.id })), pagePathRows(nodes, this.byId, this.opts));
42
113
  }
43
114
  writeRefs(pageId, draft, published) {
44
115
  const { sections, refs } = this.options;
@@ -46,100 +117,205 @@ export class MemoryPageStore {
46
117
  return;
47
118
  refs.rewrite({ sourceKind: "page", sourceId: pageId }, pageRefEdges(draft, published, sections));
48
119
  }
49
- async createPage({ slug, template }) {
50
- if (this.pages.has(slug))
51
- throw new Error(`smoodly: a page with slug "${slug}" already exists.`);
52
- const pageId = uid();
53
- const version = { id: uid(), pageId, tree: { zones: {} }, createdAt: Date.now() };
120
+ newVersion(pageId, tree) {
121
+ const version = { id: uid(), pageId, tree: structuredClone(tree), createdAt: Date.now() };
54
122
  this.versions.set(version.id, version);
123
+ return version;
124
+ }
125
+ async createPage(input) {
126
+ assertSegment(input.slug, "page slug");
127
+ assertPageI18n(input.i18n);
128
+ const parentId = input.parentId ?? null;
129
+ if (parentId !== null) {
130
+ const parent = this.must(parentId);
131
+ if (this.isHome(parent))
132
+ throw pageErrors.homeChildren();
133
+ assertDepth(chainOf(parent, this.byId).length + 1, this.opts.depth, "the page");
134
+ }
135
+ this.assertSlugFree(parentId, input.slug);
136
+ const id = uid();
137
+ const version = input.template === null ? null : this.newVersion(id, { zones: {} });
55
138
  const page = {
56
- id: pageId,
57
- slug,
58
- template,
139
+ id,
140
+ parentId,
141
+ slug: input.slug,
142
+ title: input.title ?? input.slug,
143
+ i18n: input.i18n ?? null,
144
+ template: input.template,
59
145
  status: "draft",
60
- draftVersionId: version.id,
146
+ // An ordering hint, not a unique key: duplicates are possible after
147
+ // moves/deletes, and the creation-order tie-break settles them.
148
+ sort: this.siblings(parentId).length + 1,
149
+ locales: input.locales ?? null,
150
+ draftVersionId: version?.id ?? null,
61
151
  publishedVersionId: null,
62
152
  };
63
- this.pages.set(slug, page);
153
+ this.pages.set(id, page);
154
+ this.seq.set(id, this.seq.size + 1);
155
+ try {
156
+ await this.writePaths([page]);
157
+ }
158
+ catch (e) {
159
+ this.pages.delete(id);
160
+ if (version)
161
+ this.versions.delete(version.id);
162
+ throw e;
163
+ }
64
164
  return { ...page };
65
165
  }
66
- async getPage(slug) {
67
- const page = this.pages.get(slug);
166
+ async getPage(id) {
167
+ const page = this.pages.get(id);
68
168
  return page ? { ...page } : null;
69
169
  }
70
- async getPageById(id) {
71
- for (const page of this.pages.values())
72
- if (page.id === id)
73
- return { ...page };
74
- return null;
170
+ async listPages() {
171
+ return [...this.pages.values()]
172
+ // null sorts last; the SQL equivalent is ORDER BY sort ASC NULLS LAST, created_at ASC.
173
+ .sort((a, b) => (a.sort ?? Infinity) - (b.sort ?? Infinity) || this.seq.get(a.id) - this.seq.get(b.id))
174
+ .map((p) => ({ ...p }));
175
+ }
176
+ resolvePath(locale, path) {
177
+ return this.paths.resolve(locale, path);
75
178
  }
76
- async saveDraft(slug, tree) {
77
- const page = this.pages.get(slug);
78
- if (!page)
79
- throw new Error(`smoodly: no page with slug "${slug}".`);
80
- const version = {
81
- id: uid(),
82
- pageId: page.id,
83
- tree: structuredClone(tree),
84
- createdAt: Date.now(),
85
- };
86
- this.versions.set(version.id, version);
179
+ pathsOf(id) {
180
+ return this.paths.pathsOf({ kind: "page", id });
181
+ }
182
+ async rename(id, patch) {
183
+ const page = this.must(id);
184
+ if (patch.slug !== undefined)
185
+ assertSegment(patch.slug, `page slug (${patch.locale})`);
186
+ // A rename that turns this node INTO the home node (root, default-locale
187
+ // slug === homePageSlug) is subject to the same invariant as createPage:
188
+ // the home page cannot have child pages.
189
+ if (patch.locale === this.opts.locales.default &&
190
+ patch.slug !== undefined &&
191
+ page.parentId === null &&
192
+ patch.slug === this.opts.homePageSlug &&
193
+ this.hasChildren(id)) {
194
+ throw pageErrors.homeChildren();
195
+ }
196
+ const before = { ...page, i18n: page.i18n ? structuredClone(page.i18n) : null };
197
+ if (patch.locale === this.opts.locales.default) {
198
+ if (patch.slug !== undefined) {
199
+ this.assertSlugFree(page.parentId, patch.slug, id);
200
+ page.slug = patch.slug;
201
+ }
202
+ if (patch.title !== undefined)
203
+ page.title = patch.title;
204
+ }
205
+ else {
206
+ const i18n = { ...(page.i18n ?? {}) };
207
+ i18n[patch.locale] = { ...(i18n[patch.locale] ?? {}), ...(patch.slug !== undefined ? { slug: patch.slug } : {}), ...(patch.title !== undefined ? { title: patch.title } : {}) };
208
+ page.i18n = i18n;
209
+ }
210
+ try {
211
+ await this.writePaths(this.subtree(id));
212
+ }
213
+ catch (e) {
214
+ Object.assign(page, before);
215
+ throw e;
216
+ }
217
+ return { ...page };
218
+ }
219
+ async move(id, to) {
220
+ const page = this.must(id);
221
+ if (to.parentId !== null) {
222
+ const parent = this.must(to.parentId);
223
+ if (to.parentId === id || chainOf(parent, this.byId).some((n) => n.id === id))
224
+ throw pageErrors.cycle();
225
+ if (this.isHome(parent))
226
+ throw pageErrors.homeChildren();
227
+ const subtreeDepth = Math.max(...this.subtree(id).map((n) => chainOf(n, this.byId).length)) - chainOf(page, this.byId).length + 1;
228
+ assertDepth(chainOf(parent, this.byId).length + subtreeDepth, this.opts.depth, "the page");
229
+ }
230
+ else if (page.slug === this.opts.homePageSlug && this.hasChildren(id)) {
231
+ // Moving a home-slugged node TO the root is the mirror case of the
232
+ // rename guard above: it would make it the home node while it still
233
+ // has children.
234
+ throw pageErrors.homeChildren();
235
+ }
236
+ if (to.parentId !== page.parentId)
237
+ this.assertSlugFree(to.parentId, page.slug, id);
238
+ const before = { ...page };
239
+ page.parentId = to.parentId;
240
+ const siblings = this.siblings(to.parentId, id);
241
+ const index = Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length);
242
+ siblings.splice(index, 0, page);
243
+ const previousSort = new Map(siblings.map((s) => [s.id, s.sort]));
244
+ siblings.forEach((s, i) => (s.sort = i + 1));
245
+ try {
246
+ await this.writePaths(this.subtree(id));
247
+ }
248
+ catch (e) {
249
+ Object.assign(page, before);
250
+ for (const s of siblings)
251
+ s.sort = previousSort.get(s.id) ?? null;
252
+ throw e;
253
+ }
254
+ return { ...page };
255
+ }
256
+ async setTemplate(id, template) {
257
+ const page = this.must(id);
258
+ if (page.template !== null)
259
+ throw pageErrors.hasTemplate(id);
260
+ page.template = template;
261
+ page.draftVersionId = this.newVersion(id, { zones: {} }).id;
262
+ return { ...page };
263
+ }
264
+ async saveDraft(id, tree) {
265
+ const page = this.must(id);
266
+ if (page.template === null)
267
+ throw pageErrors.folder(id);
268
+ const version = this.newVersion(id, tree);
87
269
  page.draftVersionId = version.id;
88
- const published = page.publishedVersionId
89
- ? (this.versions.get(page.publishedVersionId)?.tree ?? null)
90
- : null;
91
- this.writeRefs(page.id, version.tree, published);
270
+ const published = page.publishedVersionId ? (this.versions.get(page.publishedVersionId)?.tree ?? null) : null;
271
+ this.writeRefs(id, version.tree, published);
92
272
  return { ...version };
93
273
  }
94
- async publish(slug) {
95
- const page = this.pages.get(slug);
96
- if (!page)
97
- throw new Error(`smoodly: no page with slug "${slug}".`);
274
+ async publish(id) {
275
+ const page = this.must(id);
276
+ if (page.template === null || !page.draftVersionId)
277
+ throw pageErrors.folder(id);
98
278
  const { sections, entries } = this.options;
99
- if (sections && entries) {
100
- const draft = this.versions.get(page.draftVersionId)?.tree ?? { zones: {} };
101
- await assertPublishable(slug, draft, sections, entries);
102
- }
279
+ const draft = this.versions.get(page.draftVersionId)?.tree ?? { zones: {} };
280
+ if (sections && entries)
281
+ await assertPublishable(page.slug, draft, sections, entries);
103
282
  page.publishedVersionId = page.draftVersionId;
104
283
  page.status = "published";
105
- const tree = this.versions.get(page.draftVersionId)?.tree ?? { zones: {} };
106
- this.writeRefs(page.id, tree, null); // draft IS published now — one tree
284
+ this.writeRefs(id, draft, null);
107
285
  return { ...page };
108
286
  }
109
- async getDraftTree(slug) {
110
- const page = this.pages.get(slug);
111
- if (!page)
287
+ async getDraftTree(id) {
288
+ const page = this.pages.get(id);
289
+ if (!page?.draftVersionId)
112
290
  return null;
113
291
  return structuredClone(this.versions.get(page.draftVersionId)?.tree ?? null);
114
292
  }
115
- async getPublishedTree(slug) {
116
- const page = this.pages.get(slug);
293
+ async getPublishedTree(id) {
294
+ const page = this.pages.get(id);
117
295
  if (!page?.publishedVersionId)
118
296
  return null;
119
297
  return structuredClone(this.versions.get(page.publishedVersionId)?.tree ?? null);
120
298
  }
121
- async listVersions(slug) {
122
- const page = this.pages.get(slug);
123
- if (!page)
124
- return [];
299
+ async listVersions(id) {
125
300
  return [...this.versions.values()]
126
- .filter((v) => v.pageId === page.id)
301
+ .filter((v) => v.pageId === id)
127
302
  .sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id))
128
303
  .map((v) => ({ ...v, tree: structuredClone(v.tree) }));
129
304
  }
130
- async listPages() {
131
- return [...this.pages.values()]
132
- .sort((a, b) => a.slug.localeCompare(b.slug))
133
- .map((p) => ({ ...p }));
134
- }
135
- async deletePage(slug) {
136
- const page = this.pages.get(slug);
305
+ async deletePage(id) {
306
+ const page = this.pages.get(id);
137
307
  if (!page)
138
308
  return;
139
- for (const [id, v] of this.versions)
140
- if (v.pageId === page.id)
141
- this.versions.delete(id);
142
- this.options.refs?.remove({ sourceKind: "page", sourceId: page.id });
143
- this.pages.delete(slug);
309
+ if (this.hasChildren(id))
310
+ throw pageErrors.hasChildren(id);
311
+ // The index removal goes first: in the Supabase adapter it is a network
312
+ // call that can fail, and a failed rewrite must leave this store's own
313
+ // state (versions, refs, the page map) untouched.
314
+ await this.paths.remove([{ kind: "page", id }]);
315
+ for (const [vid, v] of this.versions)
316
+ if (v.pageId === id)
317
+ this.versions.delete(vid);
318
+ this.options.refs?.remove({ sourceKind: "page", sourceId: id });
319
+ this.pages.delete(id);
144
320
  }
145
321
  }
@@ -1,13 +1,21 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
- import type { EntryRecord, EntryStore, EntryUsage, ListOptions } from "./entry-store.ts";
3
+ import type { CreateEntryOptions, EntryRecord, EntryStore, EntryStoreOptions, EntryUsage, ListOptions } from "./entry-store.ts";
4
4
  export declare class SupabaseEntryStore implements EntryStore {
5
5
  private db;
6
6
  private collections;
7
- constructor(db: SupabaseClient, collections: CollectionSchema<any>[]);
7
+ private paths;
8
+ private locales;
9
+ constructor(db: SupabaseClient, collections: CollectionSchema<any>[], options?: EntryStoreOptions);
8
10
  private fail;
11
+ private failWriteError;
9
12
  private writeRefs;
10
- create(collection: string, fields: Record<string, unknown>): Promise<EntryRecord>;
13
+ private byIdMap;
14
+ private chain;
15
+ private subtree;
16
+ private writePaths;
17
+ private assertParent;
18
+ create(collection: string, fields: Record<string, unknown>, options?: CreateEntryOptions): Promise<EntryRecord>;
11
19
  get(collection: string, id: string): Promise<EntryRecord | null>;
12
20
  list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
13
21
  getMany(collection: string, ids: string[], options?: {
@@ -16,7 +24,13 @@ export declare class SupabaseEntryStore implements EntryStore {
16
24
  private patch;
17
25
  update(collection: string, id: string, fields: Record<string, unknown>): Promise<EntryRecord>;
18
26
  setStatus(collection: string, id: string, status: "draft" | "published"): Promise<EntryRecord>;
27
+ move(collection: string, id: string, to: {
28
+ parentId: string | null;
29
+ index?: number;
30
+ }): Promise<EntryRecord>;
19
31
  delete(collection: string, id: string): Promise<void>;
32
+ resolvePath(locale: string, path: string): Promise<import("./paths.ts").PathTarget | null>;
33
+ pathsOf(collection: string, id: string): Promise<Record<string, string>>;
20
34
  reorder(collection: string, orderedIds: string[]): Promise<void>;
21
35
  incoming(targetId: string): Promise<{
22
36
  sourceKind: string;