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.
- package/README.md +318 -74
- package/dist/admin/client-ops.js +3 -2
- package/dist/admin/editor/EditorView.js +229 -30
- package/dist/admin/editor/PageSettings.d.ts +8 -4
- package/dist/admin/editor/PageSettings.js +25 -25
- package/dist/admin/fixed-nodes.d.ts +16 -10
- package/dist/admin/fixed-nodes.js +55 -41
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +218 -36
- package/dist/admin/ops.d.ts +73 -12
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +11 -34
- package/dist/admin/shell/EntriesList.d.ts +7 -0
- package/dist/admin/shell/EntriesList.js +95 -0
- package/dist/admin/shell/EntryForm.js +167 -49
- package/dist/admin/shell/PagesList.js +86 -26
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/entries-list.d.ts +21 -0
- package/dist/admin/shell/entries-list.js +36 -0
- package/dist/admin/shell/pages-tree.d.ts +30 -12
- package/dist/admin/shell/pages-tree.js +48 -14
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +10 -1
- package/dist/collections.js +16 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +58 -4
- package/dist/entry-store.d.ts +141 -55
- package/dist/entry-store.js +317 -87
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -5
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +6 -1
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +16 -2
- package/dist/site.js +92 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +16 -13
- package/dist/sql.js +95 -70
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +30 -9
- package/dist/supabase-entry-store.js +360 -178
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- 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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
99
|
-
/**
|
|
100
|
-
|
|
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(
|
|
103
|
-
/** The publish-integrity gate (DESIGN.md §3): a page must not
|
|
104
|
-
* showing entries the published site
|
|
105
|
-
* entry must exist
|
|
106
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
35
|
-
*
|
|
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(
|
|
38
|
-
const edges =
|
|
39
|
-
|
|
40
|
-
edges.push(...collectTreeRefs(
|
|
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
|
|
44
|
-
* showing entries the published site
|
|
45
|
-
* entry must exist
|
|
46
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
218
|
+
return this.snapshot(page);
|
|
165
219
|
}
|
|
166
220
|
async getPage(id) {
|
|
167
221
|
const page = this.pages.get(id);
|
|
168
|
-
return page ?
|
|
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) => (
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
320
|
+
Object.assign(row, before);
|
|
215
321
|
throw e;
|
|
216
322
|
}
|
|
217
|
-
return
|
|
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
|
-
|
|
238
|
-
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
this.writeRefs(
|
|
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
|
|
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(
|
|
396
|
+
const draft = this.versions.get(row.draftVersionId)?.tree ?? { zones: {} };
|
|
280
397
|
if (sections && entries)
|
|
281
|
-
await assertPublishable(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
this.writeRefs(
|
|
285
|
-
return
|
|
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
|
|
289
|
-
if (!
|
|
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(
|
|
408
|
+
return structuredClone(this.versions.get(row.draftVersionId)?.tree ?? null);
|
|
292
409
|
}
|
|
293
|
-
async getPublishedTree(id) {
|
|
294
|
-
const
|
|
295
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
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
|
}
|