smoodly 0.0.6 → 0.0.8

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 (70) hide show
  1. package/README.md +318 -74
  2. package/dist/admin/client-ops.js +3 -2
  3. package/dist/admin/editor/EditorView.js +229 -30
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +218 -36
  11. package/dist/admin/ops.d.ts +73 -12
  12. package/dist/admin/serialize.d.ts +11 -0
  13. package/dist/admin/serialize.js +8 -0
  14. package/dist/admin/shared-items.d.ts +9 -0
  15. package/dist/admin/shared-items.js +61 -0
  16. package/dist/admin/shell/AdminApp.js +11 -34
  17. package/dist/admin/shell/EntriesList.d.ts +7 -0
  18. package/dist/admin/shell/EntriesList.js +95 -0
  19. package/dist/admin/shell/EntryForm.js +167 -49
  20. package/dist/admin/shell/PagesList.js +86 -26
  21. package/dist/admin/shell/SharedForm.d.ts +7 -0
  22. package/dist/admin/shell/SharedForm.js +146 -0
  23. package/dist/admin/shell/SharedList.d.ts +6 -0
  24. package/dist/admin/shell/SharedList.js +62 -0
  25. package/dist/admin/shell/entries-list.d.ts +21 -0
  26. package/dist/admin/shell/entries-list.js +36 -0
  27. package/dist/admin/shell/pages-tree.d.ts +30 -12
  28. package/dist/admin/shell/pages-tree.js +48 -14
  29. package/dist/admin/shell/shared-list.d.ts +14 -0
  30. package/dist/admin/shell/shared-list.js +17 -0
  31. package/dist/admin/tree-ops.d.ts +12 -5
  32. package/dist/admin/tree-ops.js +39 -8
  33. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  34. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  35. package/dist/collections.d.ts +10 -1
  36. package/dist/collections.js +16 -0
  37. package/dist/config.d.ts +3 -0
  38. package/dist/config.js +58 -4
  39. package/dist/entry-store.d.ts +141 -55
  40. package/dist/entry-store.js +317 -87
  41. package/dist/index.d.ts +11 -7
  42. package/dist/index.js +8 -5
  43. package/dist/localize.d.ts +0 -11
  44. package/dist/localize.js +11 -29
  45. package/dist/next/page-renderer.d.ts +4 -0
  46. package/dist/next/page-renderer.js +14 -1
  47. package/dist/page.js +5 -1
  48. package/dist/paths.d.ts +46 -27
  49. package/dist/paths.js +62 -29
  50. package/dist/refs.js +8 -0
  51. package/dist/resolve.d.ts +3 -0
  52. package/dist/resolve.js +38 -0
  53. package/dist/revalidate.d.ts +4 -0
  54. package/dist/revalidate.js +6 -1
  55. package/dist/shared.d.ts +46 -0
  56. package/dist/shared.js +36 -0
  57. package/dist/site.d.ts +16 -2
  58. package/dist/site.js +92 -39
  59. package/dist/sql-space.d.ts +1 -1
  60. package/dist/sql-space.js +16 -13
  61. package/dist/sql.js +95 -70
  62. package/dist/store.d.ts +66 -32
  63. package/dist/store.js +205 -90
  64. package/dist/supabase-entry-store.d.ts +30 -9
  65. package/dist/supabase-entry-store.js +360 -178
  66. package/dist/supabase-store.d.ts +21 -9
  67. package/dist/supabase-store.js +202 -111
  68. package/dist/zones.d.ts +6 -2
  69. package/dist/zones.js +8 -2
  70. package/package.json +1 -1
package/dist/store.d.ts CHANGED
@@ -4,36 +4,43 @@ import { type RefEdge, type SectionLike } from "./refs.ts";
4
4
  import type { MemoryRefIndex } from "./ref-index.ts";
5
5
  import { type PathIndex } from "./path-index.ts";
6
6
  import { type LocaleSet, type PagePathOptions, type PathTarget } from "./paths.ts";
7
- export type PageI18n = Record<string, {
8
- slug?: string;
9
- title?: string;
10
- }>;
7
+ export type PageLocale = {
8
+ /** The node's OWN segment in this locale; the URL is derived (paths.ts). */
9
+ slug: string;
10
+ title: string;
11
+ status: "draft" | "published";
12
+ draftVersionId: string | null;
13
+ publishedVersionId: string | null;
14
+ };
11
15
  export type PageRecord = {
12
16
  id: string;
13
17
  parentId: string | null;
14
- /** The node's OWN segment in the default locale; the URL is derived (paths.ts). */
15
- slug: string;
16
- title: string;
17
- i18n: PageI18n | null;
18
18
  /** null = folder: a node with a title and a position but no page behind it. */
19
19
  template: string | null;
20
- status: "draft" | "published";
21
20
  sort: number | null;
22
- locales: string[] | null;
23
- draftVersionId: string | null;
24
- publishedVersionId: string | null;
21
+ /** One entry per locale the page exists in. */
22
+ locales: Record<string, PageLocale>;
25
23
  };
26
24
  export type CreatePageInput = {
25
+ /** The one locale the page is created in; others are added with addLocale. */
26
+ locale: string;
27
27
  slug: string;
28
28
  template: string | null;
29
29
  title?: string;
30
30
  parentId?: string | null;
31
- i18n?: PageI18n | null;
32
- locales?: string[] | null;
31
+ };
32
+ /** `from` names the locale whose draft tree, title and slug are copied;
33
+ * `slug` and `title` override the copies (the materializer's per-locale
34
+ * segments for fixed pages). */
35
+ export type AddLocaleOptions = {
36
+ from: string;
37
+ slug?: string;
38
+ title?: string;
33
39
  };
34
40
  export type PageVersion = {
35
41
  id: string;
36
42
  pageId: string;
43
+ locale: string;
37
44
  tree: PageTree;
38
45
  createdAt: number;
39
46
  };
@@ -42,7 +49,13 @@ export interface PageStore {
42
49
  getPage(id: string): Promise<PageRecord | null>;
43
50
  listPages(): Promise<PageRecord[]>;
44
51
  resolvePath(locale: string, path: string): Promise<PathTarget | null>;
52
+ /** locale → path, for the locales the page exists in. */
45
53
  pathsOf(id: string): Promise<Record<string, string>>;
54
+ /** Refused when the page already has the locale or its parent lacks it (the parent rule). */
55
+ addLocale(id: string, locale: string, options: AddLocaleOptions): Promise<PageRecord>;
56
+ /** Deletes the row, its versions and its paths. Refused while a
57
+ * descendant has the locale, and for the page's last locale. */
58
+ removeLocale(id: string, locale: string): Promise<PageRecord>;
46
59
  rename(id: string, patch: {
47
60
  locale: string;
48
61
  slug?: string;
@@ -53,17 +66,19 @@ export interface PageStore {
53
66
  * final 0-based position among the destination siblings: the node is
54
67
  * removed from the sibling list first, then spliced back in at `index`.
55
68
  * Omitted, it appends; out-of-range values clamp to [0, siblings.length].
69
+ * Refused when the destination lacks one of the page's locales.
56
70
  */
57
71
  move(id: string, to: {
58
72
  parentId: string | null;
59
73
  index?: number;
60
74
  }): Promise<PageRecord>;
75
+ /** A folder becomes a page: an empty draft version per present locale. */
61
76
  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[]>;
77
+ saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
78
+ publish(id: string, locale: string): Promise<PageRecord>;
79
+ getDraftTree(id: string, locale: string): Promise<PageTree | null>;
80
+ getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
81
+ listVersions(id: string, locale: string): Promise<PageVersion[]>;
67
82
  deletePage(id: string): Promise<void>;
68
83
  }
69
84
  /** What a PageStore needs beyond its tables to maintain the refs index
@@ -94,16 +109,26 @@ export declare const pageErrors: {
94
109
  homeChildren: () => Error;
95
110
  cycle: () => Error;
96
111
  hasChildren: (id: string) => Error;
112
+ unsupportedLocale: (locale: string) => Error;
113
+ notInLocale: (id: string, locale: string) => Error;
114
+ alreadyInLocale: (id: string, locale: string) => Error;
115
+ parentNotInLocale: (locale: string) => Error;
116
+ lastLocale: (id: string) => Error;
117
+ localeInUse: (locale: string, titles: string[]) => Error;
118
+ moveLocales: (locale: string) => Error;
97
119
  };
98
- export declare function assertPageI18n(i18n: PageI18n | null | undefined): void;
99
- /** A page's refs rows cover BOTH live trees — edges(draft) ∪
100
- * edges(published) — so an entry stays delete-guarded while either tree
120
+ export { assertSupportedLocale } from "./paths.ts";
121
+ /** The page's row for a locale — the "does not exist in locale" guard every per-locale write runs. */
122
+ export declare function localeRow(page: PageRecord, locale: string): PageLocale;
123
+ /** A page's refs rows cover EVERY live tree — each locale's draft and
124
+ * published trees — so an entry stays delete-guarded while any of them
101
125
  * shows it. Callers dedupe on write (the SQL PK does it for real). */
102
- export declare function pageRefEdges(draft: PageTree, published: PageTree | null, sections: SectionLike[]): RefEdge[];
103
- /** The publish-integrity gate (DESIGN.md §3): a page must not go live
104
- * showing entries the published site can't resolve — every referenced
105
- * entry must exist and be published. Throws naming the offenders. */
106
- export declare function assertPublishable(label: string, tree: PageTree, sections: SectionLike[], entries: EntryStore): Promise<void>;
126
+ export declare function pageRefEdges(trees: PageTree[], sections: SectionLike[]): RefEdge[];
127
+ /** The publish-integrity gate (DESIGN.md §3), per locale: a page must not
128
+ * go live in a locale showing entries the published site cannot resolve
129
+ * THERE — every referenced entry must exist, have a row in that locale,
130
+ * and be published in it. Throws naming the offenders. */
131
+ export declare function assertPublishable(label: string, tree: PageTree, sections: SectionLike[], entries: EntryStore, locale: string): Promise<void>;
107
132
  export declare class MemoryPageStore implements PageStore {
108
133
  private options;
109
134
  private pages;
@@ -118,18 +143,27 @@ export declare class MemoryPageStore implements PageStore {
118
143
  private byId;
119
144
  private must;
120
145
  private isHome;
146
+ private snapshot;
147
+ private children;
121
148
  private hasChildren;
122
149
  private siblings;
150
+ /** Sibling slugs are unique per parent PER LOCALE — a friendlier error
151
+ * than the path index's, which stays the real invariant. */
123
152
  private assertSlugFree;
124
153
  private subtree;
125
154
  private writePaths;
155
+ /** Every locale's draft and published trees, deduped by version id. */
156
+ private liveTrees;
126
157
  private writeRefs;
127
158
  private newVersion;
159
+ private dropVersions;
128
160
  createPage(input: CreatePageInput): Promise<PageRecord>;
129
161
  getPage(id: string): Promise<PageRecord | null>;
130
162
  listPages(): Promise<PageRecord[]>;
131
163
  resolvePath(locale: string, path: string): Promise<PathTarget | null>;
132
164
  pathsOf(id: string): Promise<Record<string, string>>;
165
+ addLocale(id: string, locale: string, options: AddLocaleOptions): Promise<PageRecord>;
166
+ removeLocale(id: string, locale: string): Promise<PageRecord>;
133
167
  rename(id: string, patch: {
134
168
  locale: string;
135
169
  slug?: string;
@@ -140,10 +174,10 @@ export declare class MemoryPageStore implements PageStore {
140
174
  index?: number;
141
175
  }): Promise<PageRecord>;
142
176
  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[]>;
177
+ saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
178
+ publish(id: string, locale: string): Promise<PageRecord>;
179
+ getDraftTree(id: string, locale: string): Promise<PageTree | null>;
180
+ getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
181
+ listVersions(id: string, locale: string): Promise<PageVersion[]>;
148
182
  deletePage(id: string): Promise<void>;
149
183
  }
package/dist/store.js CHANGED
@@ -2,10 +2,16 @@
2
2
  // never raw table access — so validation, versioning and publish hooks
3
3
  // can't be bypassed (DESIGN.md §1). MemoryPageStore is the reference
4
4
  // implementation and the contract-test subject; the Supabase adapter
5
- // implements the same interface over pages/page_versions.
5
+ // implements the same interface over pages/page_locales/page_versions.
6
+ //
7
+ // The model (spec 2026-09-06): a page is ONE node with ONE row per
8
+ // locale it exists in. The node holds identity and tree position; each
9
+ // locale row owns its slug, title, publish state and version pointers,
10
+ // and each locale has its own history. Nothing falls back to another
11
+ // locale — presence IS the row.
6
12
  import { collectTreeRefs } from "./refs.js";
7
13
  import { MemoryPathIndex } from "./path-index.js";
8
- import { assertDepth, assertSegment, chainOf, pagePathRows, } from "./paths.js";
14
+ import { assertDepth, assertSegment, assertSupportedLocale, chainOf, isHomeNode, pagePathRows, unsupportedLocale, } from "./paths.js";
9
15
  export const DEFAULT_LOCALES = { default: "en", supported: ["en"] };
10
16
  /** The options every adapter resolves the same way. */
11
17
  export function pathOptions(options) {
@@ -24,26 +30,36 @@ export const pageErrors = {
24
30
  homeChildren: () => new Error("smoodly: the home page cannot have child pages."),
25
31
  cycle: () => new Error("smoodly: a page cannot be moved under itself."),
26
32
  hasChildren: (id) => new Error(`smoodly: "${id}" has child pages — move or delete them first.`),
33
+ unsupportedLocale,
34
+ notInLocale: (id, locale) => new Error(`smoodly: page "${id}" does not exist in locale "${locale}".`),
35
+ alreadyInLocale: (id, locale) => new Error(`smoodly: page "${id}" already exists in locale "${locale}".`),
36
+ parentNotInLocale: (locale) => new Error(`smoodly: the parent page does not exist in locale "${locale}" — add it there first.`),
37
+ lastLocale: (id) => new Error(`smoodly: page "${id}" must exist in at least one locale — delete the page instead.`),
38
+ localeInUse: (locale, titles) => new Error(`smoodly: child pages still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
39
+ moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the page cannot move under it.`),
27
40
  };
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
- }
41
+ export { assertSupportedLocale } from "./paths.js";
42
+ /** The page's row for a locale — the "does not exist in locale" guard every per-locale write runs. */
43
+ export function localeRow(page, locale) {
44
+ const row = page.locales[locale];
45
+ if (!row)
46
+ throw pageErrors.notInLocale(page.id, locale);
47
+ return row;
33
48
  }
34
- /** A page's refs rows cover BOTH live trees — edges(draft) ∪
35
- * edges(published) — so an entry stays delete-guarded while either tree
49
+ /** A page's refs rows cover EVERY live tree — each locale's draft and
50
+ * published trees — so an entry stays delete-guarded while any of them
36
51
  * shows it. Callers dedupe on write (the SQL PK does it for real). */
37
- export function pageRefEdges(draft, published, sections) {
38
- const edges = collectTreeRefs(draft, sections);
39
- if (published)
40
- edges.push(...collectTreeRefs(published, sections));
52
+ export function pageRefEdges(trees, sections) {
53
+ const edges = [];
54
+ for (const tree of trees)
55
+ edges.push(...collectTreeRefs(tree, sections));
41
56
  return edges;
42
57
  }
43
- /** The publish-integrity gate (DESIGN.md §3): a page must not go live
44
- * showing entries the published site can't resolve — every referenced
45
- * entry must exist and be published. Throws naming the offenders. */
46
- export async function assertPublishable(label, tree, sections, entries) {
58
+ /** The publish-integrity gate (DESIGN.md §3), per locale: a page must not
59
+ * go live in a locale showing entries the published site cannot resolve
60
+ * THERE — every referenced entry must exist, have a row in that locale,
61
+ * and be published in it. Throws naming the offenders. */
62
+ export async function assertPublishable(label, tree, sections, entries, locale) {
47
63
  const targets = new Map();
48
64
  for (const edge of collectTreeRefs(tree, sections)) {
49
65
  if (!targets.has(edge.targetId))
@@ -52,8 +68,11 @@ export async function assertPublishable(label, tree, sections, entries) {
52
68
  const offenders = [];
53
69
  for (const [id, { collection, field }] of targets) {
54
70
  const entry = await entries.get(collection, id);
55
- if (entry?.status !== "published")
56
- offenders.push(`${collection}/${id} (${field}${entry ? "" : ", missing"})`);
71
+ const row = entry?.locales[locale];
72
+ if (row?.status !== "published") {
73
+ const why = !entry ? ", missing" : !row ? `, not in ${locale}` : "";
74
+ offenders.push(`${collection}/${id} (${field}${why})`);
75
+ }
57
76
  }
58
77
  if (offenders.length > 0) {
59
78
  throw new Error(`smoodly: cannot publish "${label}" — it references entries that are not published: ${offenders.join(", ")}.`);
@@ -79,10 +98,16 @@ export class MemoryPageStore {
79
98
  return page;
80
99
  }
81
100
  isHome(page) {
82
- return page.parentId === null && page.slug === this.opts.homePageSlug;
101
+ return isHomeNode(page, this.opts);
102
+ }
103
+ snapshot(page) {
104
+ return { ...page, locales: structuredClone(page.locales) };
105
+ }
106
+ children(id) {
107
+ return [...this.pages.values()].filter((p) => p.parentId === id);
83
108
  }
84
109
  hasChildren(id) {
85
- return [...this.pages.values()].some((p) => p.parentId === id);
110
+ return this.children(id).length > 0;
86
111
  }
87
112
  siblings(parentId, except) {
88
113
  return [...this.pages.values()]
@@ -90,8 +115,10 @@ export class MemoryPageStore {
90
115
  // null sorts last; the SQL equivalent is ORDER BY sort ASC NULLS LAST, created_at ASC.
91
116
  .sort((a, b) => (a.sort ?? Infinity) - (b.sort ?? Infinity) || this.seq.get(a.id) - this.seq.get(b.id));
92
117
  }
93
- assertSlugFree(parentId, slug, except) {
94
- if (this.siblings(parentId, except).some((p) => p.slug === slug))
118
+ /** Sibling slugs are unique per parent PER LOCALE — a friendlier error
119
+ * than the path index's, which stays the real invariant. */
120
+ assertSlugFree(parentId, locale, slug, except) {
121
+ if (this.siblings(parentId, except).some((p) => p.locales[locale]?.slug === slug))
95
122
  throw pageErrors.slugTaken(slug);
96
123
  }
97
124
  subtree(id) {
@@ -111,44 +138,71 @@ export class MemoryPageStore {
111
138
  async writePaths(nodes) {
112
139
  await this.paths.rewrite(nodes.map((n) => ({ kind: "page", id: n.id })), pagePathRows(nodes, this.byId, this.opts));
113
140
  }
114
- writeRefs(pageId, draft, published) {
141
+ /** Every locale's draft and published trees, deduped by version id. */
142
+ liveTrees(page) {
143
+ const ids = new Set();
144
+ for (const row of Object.values(page.locales)) {
145
+ if (row.draftVersionId)
146
+ ids.add(row.draftVersionId);
147
+ if (row.publishedVersionId)
148
+ ids.add(row.publishedVersionId);
149
+ }
150
+ const trees = [];
151
+ for (const id of ids) {
152
+ const tree = this.versions.get(id)?.tree;
153
+ if (tree)
154
+ trees.push(tree);
155
+ }
156
+ return trees;
157
+ }
158
+ writeRefs(page) {
115
159
  const { sections, refs } = this.options;
116
160
  if (!sections || !refs)
117
161
  return;
118
- refs.rewrite({ sourceKind: "page", sourceId: pageId }, pageRefEdges(draft, published, sections));
162
+ refs.rewrite({ sourceKind: "page", sourceId: page.id }, pageRefEdges(this.liveTrees(page), sections));
119
163
  }
120
- newVersion(pageId, tree) {
121
- const version = { id: uid(), pageId, tree: structuredClone(tree), createdAt: Date.now() };
164
+ newVersion(pageId, locale, tree) {
165
+ const version = { id: uid(), pageId, locale, tree: structuredClone(tree), createdAt: Date.now() };
122
166
  this.versions.set(version.id, version);
123
167
  return version;
124
168
  }
169
+ dropVersions(pageId, locale) {
170
+ for (const [vid, v] of this.versions) {
171
+ if (v.pageId === pageId && (locale === undefined || v.locale === locale))
172
+ this.versions.delete(vid);
173
+ }
174
+ }
125
175
  async createPage(input) {
176
+ assertSupportedLocale(input.locale, this.opts.locales);
126
177
  assertSegment(input.slug, "page slug");
127
- assertPageI18n(input.i18n);
128
178
  const parentId = input.parentId ?? null;
129
179
  if (parentId !== null) {
130
180
  const parent = this.must(parentId);
131
181
  if (this.isHome(parent))
132
182
  throw pageErrors.homeChildren();
183
+ if (!parent.locales[input.locale])
184
+ throw pageErrors.parentNotInLocale(input.locale);
133
185
  assertDepth(chainOf(parent, this.byId).length + 1, this.opts.depth, "the page");
134
186
  }
135
- this.assertSlugFree(parentId, input.slug);
187
+ this.assertSlugFree(parentId, input.locale, input.slug);
136
188
  const id = uid();
137
- const version = input.template === null ? null : this.newVersion(id, { zones: {} });
189
+ const version = input.template === null ? null : this.newVersion(id, input.locale, { zones: {} });
138
190
  const page = {
139
191
  id,
140
192
  parentId,
141
- slug: input.slug,
142
- title: input.title ?? input.slug,
143
- i18n: input.i18n ?? null,
144
193
  template: input.template,
145
- status: "draft",
146
194
  // An ordering hint, not a unique key: duplicates are possible after
147
195
  // moves/deletes, and the creation-order tie-break settles them.
148
196
  sort: this.siblings(parentId).length + 1,
149
- locales: input.locales ?? null,
150
- draftVersionId: version?.id ?? null,
151
- publishedVersionId: null,
197
+ locales: {
198
+ [input.locale]: {
199
+ slug: input.slug,
200
+ title: input.title ?? input.slug,
201
+ status: "draft",
202
+ draftVersionId: version?.id ?? null,
203
+ publishedVersionId: null,
204
+ },
205
+ },
152
206
  };
153
207
  this.pages.set(id, page);
154
208
  this.seq.set(id, this.seq.size + 1);
@@ -161,17 +215,17 @@ export class MemoryPageStore {
161
215
  this.versions.delete(version.id);
162
216
  throw e;
163
217
  }
164
- return { ...page };
218
+ return this.snapshot(page);
165
219
  }
166
220
  async getPage(id) {
167
221
  const page = this.pages.get(id);
168
- return page ? { ...page } : null;
222
+ return page ? this.snapshot(page) : null;
169
223
  }
170
224
  async listPages() {
171
225
  return [...this.pages.values()]
172
226
  // null sorts last; the SQL equivalent is ORDER BY sort ASC NULLS LAST, created_at ASC.
173
227
  .sort((a, b) => (a.sort ?? Infinity) - (b.sort ?? Infinity) || this.seq.get(a.id) - this.seq.get(b.id))
174
- .map((p) => ({ ...p }));
228
+ .map((p) => this.snapshot(p));
175
229
  }
176
230
  resolvePath(locale, path) {
177
231
  return this.paths.resolve(locale, path);
@@ -179,8 +233,67 @@ export class MemoryPageStore {
179
233
  pathsOf(id) {
180
234
  return this.paths.pathsOf({ kind: "page", id });
181
235
  }
236
+ async addLocale(id, locale, options) {
237
+ assertSupportedLocale(locale, this.opts.locales);
238
+ const page = this.must(id);
239
+ if (page.locales[locale])
240
+ throw pageErrors.alreadyInLocale(id, locale);
241
+ const source = localeRow(page, options.from);
242
+ const slug = options.slug ?? source.slug;
243
+ if (options.slug !== undefined)
244
+ assertSegment(slug, `page slug (${locale})`);
245
+ if (page.parentId !== null && !this.must(page.parentId).locales[locale])
246
+ throw pageErrors.parentNotInLocale(locale);
247
+ this.assertSlugFree(page.parentId, locale, slug, id);
248
+ // The SOURCE'S DRAFT is the copy: what the editor sees on screen when
249
+ // they add the locale (spec §3). A folder has no tree to copy.
250
+ const version = page.template === null
251
+ ? null
252
+ : this.newVersion(id, locale, (source.draftVersionId && this.versions.get(source.draftVersionId)?.tree) || { zones: {} });
253
+ page.locales[locale] = {
254
+ slug,
255
+ title: options.title ?? source.title,
256
+ status: "draft",
257
+ draftVersionId: version?.id ?? null,
258
+ publishedVersionId: null,
259
+ };
260
+ try {
261
+ await this.writePaths([page]);
262
+ }
263
+ catch (e) {
264
+ delete page.locales[locale];
265
+ if (version)
266
+ this.versions.delete(version.id);
267
+ throw e;
268
+ }
269
+ this.writeRefs(page);
270
+ return this.snapshot(page);
271
+ }
272
+ async removeLocale(id, locale) {
273
+ const page = this.must(id);
274
+ const removed = localeRow(page, locale);
275
+ if (Object.keys(page.locales).length === 1)
276
+ throw pageErrors.lastLocale(id);
277
+ // Under the parent rule only direct children can hold the locale, but
278
+ // the walk is cheap and the message must name every blocker.
279
+ const blockers = this.subtree(id).filter((n) => n.id !== id && n.locales[locale]);
280
+ if (blockers.length > 0)
281
+ throw pageErrors.localeInUse(locale, blockers.map((b) => b.locales[locale].title));
282
+ delete page.locales[locale];
283
+ try {
284
+ await this.writePaths([page]);
285
+ }
286
+ catch (e) {
287
+ page.locales[locale] = removed;
288
+ throw e;
289
+ }
290
+ this.dropVersions(id, locale);
291
+ this.writeRefs(page);
292
+ return this.snapshot(page);
293
+ }
182
294
  async rename(id, patch) {
183
295
  const page = this.must(id);
296
+ const row = localeRow(page, patch.locale);
184
297
  if (patch.slug !== undefined)
185
298
  assertSegment(patch.slug, `page slug (${patch.locale})`);
186
299
  // A rename that turns this node INTO the home node (root, default-locale
@@ -193,28 +306,21 @@ export class MemoryPageStore {
193
306
  this.hasChildren(id)) {
194
307
  throw pageErrors.homeChildren();
195
308
  }
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
- }
309
+ if (patch.slug !== undefined)
310
+ this.assertSlugFree(page.parentId, patch.locale, patch.slug, id);
311
+ const before = { ...row };
312
+ if (patch.slug !== undefined)
313
+ row.slug = patch.slug;
314
+ if (patch.title !== undefined)
315
+ row.title = patch.title;
210
316
  try {
211
317
  await this.writePaths(this.subtree(id));
212
318
  }
213
319
  catch (e) {
214
- Object.assign(page, before);
320
+ Object.assign(row, before);
215
321
  throw e;
216
322
  }
217
- return { ...page };
323
+ return this.snapshot(page);
218
324
  }
219
325
  async move(id, to) {
220
326
  const page = this.must(id);
@@ -224,18 +330,25 @@ export class MemoryPageStore {
224
330
  throw pageErrors.cycle();
225
331
  if (this.isHome(parent))
226
332
  throw pageErrors.homeChildren();
333
+ // The parent rule, on the move: every locale the page has must be
334
+ // present at the destination, or its chain would break there.
335
+ for (const locale of Object.keys(page.locales))
336
+ if (!parent.locales[locale])
337
+ throw pageErrors.moveLocales(locale);
227
338
  const subtreeDepth = Math.max(...this.subtree(id).map((n) => chainOf(n, this.byId).length)) - chainOf(page, this.byId).length + 1;
228
339
  assertDepth(chainOf(parent, this.byId).length + subtreeDepth, this.opts.depth, "the page");
229
340
  }
230
- else if (page.slug === this.opts.homePageSlug && this.hasChildren(id)) {
341
+ else if (page.locales[this.opts.locales.default]?.slug === this.opts.homePageSlug && this.hasChildren(id)) {
231
342
  // Moving a home-slugged node TO the root is the mirror case of the
232
343
  // rename guard above: it would make it the home node while it still
233
344
  // has children.
234
345
  throw pageErrors.homeChildren();
235
346
  }
236
- if (to.parentId !== page.parentId)
237
- this.assertSlugFree(to.parentId, page.slug, id);
238
- const before = { ...page };
347
+ if (to.parentId !== page.parentId) {
348
+ for (const [locale, row] of Object.entries(page.locales))
349
+ this.assertSlugFree(to.parentId, locale, row.slug, id);
350
+ }
351
+ const before = { parentId: page.parentId, sort: page.sort };
239
352
  page.parentId = to.parentId;
240
353
  const siblings = this.siblings(to.parentId, id);
241
354
  const index = Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length);
@@ -251,54 +364,58 @@ export class MemoryPageStore {
251
364
  s.sort = previousSort.get(s.id) ?? null;
252
365
  throw e;
253
366
  }
254
- return { ...page };
367
+ return this.snapshot(page);
255
368
  }
256
369
  async setTemplate(id, template) {
257
370
  const page = this.must(id);
258
371
  if (page.template !== null)
259
372
  throw pageErrors.hasTemplate(id);
260
373
  page.template = template;
261
- page.draftVersionId = this.newVersion(id, { zones: {} }).id;
262
- return { ...page };
374
+ for (const [locale, row] of Object.entries(page.locales))
375
+ row.draftVersionId = this.newVersion(id, locale, { zones: {} }).id;
376
+ return this.snapshot(page);
263
377
  }
264
- async saveDraft(id, tree) {
378
+ async saveDraft(id, locale, tree) {
265
379
  const page = this.must(id);
266
380
  if (page.template === null)
267
381
  throw pageErrors.folder(id);
268
- const version = this.newVersion(id, tree);
269
- page.draftVersionId = version.id;
270
- const published = page.publishedVersionId ? (this.versions.get(page.publishedVersionId)?.tree ?? null) : null;
271
- this.writeRefs(id, version.tree, published);
272
- return { ...version };
382
+ const row = localeRow(page, locale);
383
+ const version = this.newVersion(id, locale, tree);
384
+ row.draftVersionId = version.id;
385
+ this.writeRefs(page);
386
+ return { ...version, tree: structuredClone(version.tree) };
273
387
  }
274
- async publish(id) {
388
+ async publish(id, locale) {
275
389
  const page = this.must(id);
276
- if (page.template === null || !page.draftVersionId)
390
+ if (page.template === null)
391
+ throw pageErrors.folder(id);
392
+ const row = localeRow(page, locale);
393
+ if (!row.draftVersionId)
277
394
  throw pageErrors.folder(id);
278
395
  const { sections, entries } = this.options;
279
- const draft = this.versions.get(page.draftVersionId)?.tree ?? { zones: {} };
396
+ const draft = this.versions.get(row.draftVersionId)?.tree ?? { zones: {} };
280
397
  if (sections && entries)
281
- await assertPublishable(page.slug, draft, sections, entries);
282
- page.publishedVersionId = page.draftVersionId;
283
- page.status = "published";
284
- this.writeRefs(id, draft, null);
285
- return { ...page };
398
+ await assertPublishable(row.slug, draft, sections, entries, locale);
399
+ row.publishedVersionId = row.draftVersionId;
400
+ row.status = "published";
401
+ this.writeRefs(page);
402
+ return this.snapshot(page);
286
403
  }
287
- async getDraftTree(id) {
288
- const page = this.pages.get(id);
289
- if (!page?.draftVersionId)
404
+ async getDraftTree(id, locale) {
405
+ const row = this.pages.get(id)?.locales[locale];
406
+ if (!row?.draftVersionId)
290
407
  return null;
291
- return structuredClone(this.versions.get(page.draftVersionId)?.tree ?? null);
408
+ return structuredClone(this.versions.get(row.draftVersionId)?.tree ?? null);
292
409
  }
293
- async getPublishedTree(id) {
294
- const page = this.pages.get(id);
295
- if (!page?.publishedVersionId)
410
+ async getPublishedTree(id, locale) {
411
+ const row = this.pages.get(id)?.locales[locale];
412
+ if (!row?.publishedVersionId)
296
413
  return null;
297
- return structuredClone(this.versions.get(page.publishedVersionId)?.tree ?? null);
414
+ return structuredClone(this.versions.get(row.publishedVersionId)?.tree ?? null);
298
415
  }
299
- async listVersions(id) {
416
+ async listVersions(id, locale) {
300
417
  return [...this.versions.values()]
301
- .filter((v) => v.pageId === id)
418
+ .filter((v) => v.pageId === id && v.locale === locale)
302
419
  .sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id))
303
420
  .map((v) => ({ ...v, tree: structuredClone(v.tree) }));
304
421
  }
@@ -312,9 +429,7 @@ export class MemoryPageStore {
312
429
  // call that can fail, and a failed rewrite must leave this store's own
313
430
  // state (versions, refs, the page map) untouched.
314
431
  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);
432
+ this.dropVersions(id);
318
433
  this.options.refs?.remove({ sourceKind: "page", sourceId: id });
319
434
  this.pages.delete(id);
320
435
  }