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.
- package/README.md +408 -34
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.d.ts +3 -2
- package/dist/admin/editor/EditorView.js +133 -80
- package/dist/admin/editor/PageSettings.d.ts +13 -0
- package/dist/admin/editor/PageSettings.js +40 -0
- package/dist/admin/fixed-nodes.d.ts +23 -0
- package/dist/admin/fixed-nodes.js +83 -0
- package/dist/admin/forms/FieldWidget.d.ts +2 -1
- package/dist/admin/forms/FieldWidget.js +16 -16
- package/dist/admin/index.d.ts +2 -1
- package/dist/admin/index.js +1 -0
- package/dist/admin/next/create-admin.js +7 -2
- package/dist/admin/next/op-handler.d.ts +1 -0
- package/dist/admin/next/op-handler.js +7 -2
- package/dist/admin/ops-impl.d.ts +4 -1
- package/dist/admin/ops-impl.js +190 -48
- package/dist/admin/ops.d.ts +39 -11
- package/dist/admin/serialize.d.ts +10 -1
- package/dist/admin/serialize.js +13 -2
- package/dist/admin/shell/AdminApp.js +36 -74
- package/dist/admin/shell/EntryForm.js +34 -10
- package/dist/admin/shell/ErrorPane.d.ts +4 -0
- package/dist/admin/shell/ErrorPane.js +9 -0
- package/dist/admin/shell/ListView.d.ts +8 -5
- package/dist/admin/shell/ListView.js +12 -11
- package/dist/admin/shell/LoginView.js +6 -2
- package/dist/admin/shell/PagesList.d.ts +6 -0
- package/dist/admin/shell/PagesList.js +148 -0
- package/dist/admin/shell/base.d.ts +1 -0
- package/dist/admin/shell/base.js +4 -0
- package/dist/admin/shell/pages-tree.d.ts +39 -0
- package/dist/admin/shell/pages-tree.js +65 -0
- package/dist/admin/ui/format.d.ts +1 -0
- package/dist/admin/ui/format.js +25 -0
- package/dist/admin/ui/primitives.d.ts +89 -0
- package/dist/admin/ui/primitives.js +115 -0
- package/dist/admin/ui/theme.d.ts +5 -0
- package/dist/admin/ui/theme.js +396 -0
- package/dist/collections.d.ts +23 -2
- package/dist/collections.js +19 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +71 -13
- package/dist/entry-page.d.ts +16 -0
- package/dist/entry-page.js +6 -0
- package/dist/entry-store.d.ts +94 -7
- package/dist/entry-store.js +175 -26
- package/dist/index.d.ts +24 -8
- package/dist/index.js +14 -3
- package/dist/localize.d.ts +19 -0
- package/dist/localize.js +42 -0
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/next/meta.d.ts +0 -6
- package/dist/next/meta.js +0 -11
- package/dist/next/page-renderer.d.ts +26 -18
- package/dist/next/page-renderer.js +79 -56
- package/dist/page.d.ts +10 -3
- package/dist/page.js +12 -2
- package/dist/pairing.d.ts +7 -6
- package/dist/path-index.d.ts +16 -0
- package/dist/path-index.js +33 -0
- package/dist/paths.d.ts +80 -0
- package/dist/paths.js +110 -0
- package/dist/render.d.ts +34 -5
- package/dist/render.js +9 -2
- package/dist/resolve.d.ts +8 -0
- package/dist/resolve.js +19 -7
- package/dist/revalidate.d.ts +10 -2
- package/dist/revalidate.js +13 -8
- package/dist/site.d.ts +85 -0
- package/dist/site.js +164 -0
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +2 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +92 -15
- package/dist/store.d.ts +106 -30
- package/dist/store.js +242 -66
- package/dist/supabase-entry-store.d.ts +17 -3
- package/dist/supabase-entry-store.js +198 -22
- package/dist/supabase-path-index.d.ts +11 -0
- package/dist/supabase-path-index.js +52 -0
- package/dist/supabase-store.d.ts +46 -16
- package/dist/supabase-store.js +322 -110
- package/package.json +5 -4
- package/dist/admin/page-path.d.ts +0 -1
- package/dist/admin/page-path.js +0 -6
- package/dist/admin/shell/pages-list.d.ts +0 -22
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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(
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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(
|
|
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 "${
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
67
|
-
const page = this.pages.get(
|
|
166
|
+
async getPage(id) {
|
|
167
|
+
const page = this.pages.get(id);
|
|
68
168
|
return page ? { ...page } : null;
|
|
69
169
|
}
|
|
70
|
-
async
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
this.
|
|
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
|
-
|
|
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(
|
|
95
|
-
const page = this.
|
|
96
|
-
if (!page)
|
|
97
|
-
throw
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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(
|
|
110
|
-
const page = this.pages.get(
|
|
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(
|
|
116
|
-
const page = this.pages.get(
|
|
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(
|
|
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 ===
|
|
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
|
|
131
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|