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/entry-store.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import type { CollectionSchema } from "./collections.ts";
|
|
1
|
+
import type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
2
2
|
import { MemoryRefIndex } from "./ref-index.ts";
|
|
3
|
+
import { type PathIndex } from "./path-index.ts";
|
|
4
|
+
import { type LocaleSet, type PathTarget } from "./paths.ts";
|
|
3
5
|
export type EntryRecord = {
|
|
4
6
|
id: string;
|
|
5
7
|
collection: string;
|
|
8
|
+
parentId: string | null;
|
|
6
9
|
fields: Record<string, unknown>;
|
|
10
|
+
i18n: Record<string, Record<string, unknown>> | null;
|
|
7
11
|
status: "draft" | "published";
|
|
8
12
|
sort: number | null;
|
|
13
|
+
locales: string[] | null;
|
|
9
14
|
createdAt: number;
|
|
10
15
|
updatedAt: number;
|
|
11
16
|
};
|
|
@@ -15,12 +20,26 @@ export type EntryUsage = {
|
|
|
15
20
|
sourceField: string;
|
|
16
21
|
};
|
|
17
22
|
export type ListOptions = {
|
|
18
|
-
/**
|
|
19
|
-
order?:
|
|
23
|
+
/** Absent = the collection's configured order (config); `"manual"` sorts by `sort` asc. */
|
|
24
|
+
order?: EntryOrder;
|
|
20
25
|
status?: "published";
|
|
26
|
+
/**
|
|
27
|
+
* `null` lists roots; a string id lists that parent's children; omitted
|
|
28
|
+
* (or explicitly `undefined`) applies no parent filter — everything in
|
|
29
|
+
* the collection, at every depth.
|
|
30
|
+
*/
|
|
31
|
+
parent?: string | null;
|
|
32
|
+
};
|
|
33
|
+
export type CreateEntryOptions = {
|
|
34
|
+
parentId?: string | null;
|
|
35
|
+
locales?: string[] | null;
|
|
36
|
+
};
|
|
37
|
+
export type EntryStoreOptions = {
|
|
38
|
+
paths?: PathIndex;
|
|
39
|
+
locales?: LocaleSet;
|
|
21
40
|
};
|
|
22
41
|
export interface EntryStore {
|
|
23
|
-
create(collection: string, fields: Record<string, unknown
|
|
42
|
+
create(collection: string, fields: Record<string, unknown>, options?: CreateEntryOptions): Promise<EntryRecord>;
|
|
24
43
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
25
44
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
26
45
|
/** Batched read for the resolver: fields keyed by id, missing ids omitted.
|
|
@@ -33,8 +52,22 @@ export interface EntryStore {
|
|
|
33
52
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
34
53
|
update(collection: string, id: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
35
54
|
setStatus(collection: string, id: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
36
|
-
/** Blocked while anything references the entry (the safe-delete guard)
|
|
55
|
+
/** Blocked while anything references the entry (the safe-delete guard),
|
|
56
|
+
* and while it still has child entries. */
|
|
37
57
|
delete(collection: string, id: string): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Reparents `id` under `to.parentId`. `to.index` is the moved entry's
|
|
60
|
+
* final 0-based position among the destination siblings: the entry is
|
|
61
|
+
* removed from the sibling list first, then spliced back in at `index`.
|
|
62
|
+
* Omitted, it appends; out-of-range values clamp to [0, siblings.length].
|
|
63
|
+
* Destination siblings are renumbered 1..N afterward. Like `reorder`,
|
|
64
|
+
* `move` does NOT bump `updatedAt` — reparenting/resequencing is a
|
|
65
|
+
* structure change, not an edit to the entry's content.
|
|
66
|
+
*/
|
|
67
|
+
move(collection: string, id: string, to: {
|
|
68
|
+
parentId: string | null;
|
|
69
|
+
index?: number;
|
|
70
|
+
}): Promise<EntryRecord>;
|
|
38
71
|
/** Rewrite manual order to 1..N for the given ids, one batched pass. */
|
|
39
72
|
reorder(collection: string, orderedIds: string[]): Promise<void>;
|
|
40
73
|
/** The refs index's reverse question: who points at this entry? */
|
|
@@ -45,18 +78,66 @@ export interface EntryStore {
|
|
|
45
78
|
sourceKind: string;
|
|
46
79
|
sourceId: string;
|
|
47
80
|
}[]>;
|
|
81
|
+
resolvePath(locale: string, path: string): Promise<PathTarget | null>;
|
|
82
|
+
pathsOf(collection: string, id: string): Promise<Record<string, string>>;
|
|
48
83
|
}
|
|
49
84
|
export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
|
|
85
|
+
export declare const DEFAULT_ENTRY_LOCALES: LocaleSet;
|
|
86
|
+
export declare const entryErrors: {
|
|
87
|
+
notFound: (collection: string, id: string) => Error;
|
|
88
|
+
hasChildren: (collection: string, id: string) => Error;
|
|
89
|
+
cycle: () => Error;
|
|
90
|
+
foreignParent: () => Error;
|
|
91
|
+
/** Entry slugs are unique among SIBLINGS (DESIGN.md §3, decided
|
|
92
|
+
* 2026-09-05): the same rule pages have, since the URL is the tree.
|
|
93
|
+
* ONE source for the text — the Supabase adapter maps its
|
|
94
|
+
* entries_sibling_slug_key 23505 onto this same error. */
|
|
95
|
+
slugTaken: (collection: string) => Error;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Sort comparator for one order rule; `seq` breaks ties by creation.
|
|
99
|
+
*
|
|
100
|
+
* Nulls sort last in BOTH directions (the Supabase adapter must use
|
|
101
|
+
* `nullsFirst: false` to match). The creation tiebreak follows `direction`
|
|
102
|
+
* for non-manual orders — two entries created in the same millisecond
|
|
103
|
+
* must still land newest-first under the default order, not oldest-first
|
|
104
|
+
* — while the manual branch's tiebreak stays ascending (creation order
|
|
105
|
+
* is the natural fallback for otherwise-unordered `sort` values).
|
|
106
|
+
* Declared fields compare numerically when both values are numbers,
|
|
107
|
+
* otherwise as strings via `localeCompare`. The Supabase adapter orders
|
|
108
|
+
* by the jsonb VALUE (`fields->x`, decided 2026-09-05), so the two agree
|
|
109
|
+
* for a field holding numbers and for one holding strings (ISO dates
|
|
110
|
+
* included). Two edges still differ: MIXED types in one field — Postgres
|
|
111
|
+
* orders jsonb by type first (null < string < number < boolean < array <
|
|
112
|
+
* object) rather than by `String(...)` — and an explicit JSON `null`
|
|
113
|
+
* VALUE, which is a jsonb null and sorts FIRST in Postgres but last here
|
|
114
|
+
* (a MISSING key is SQL NULL and does sort last, matching). See the
|
|
115
|
+
* package README's follow-ups.
|
|
116
|
+
*/
|
|
117
|
+
export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
|
|
50
118
|
export declare class MemoryEntryStore implements EntryStore {
|
|
51
119
|
private collections;
|
|
52
120
|
private refs;
|
|
53
121
|
private entries;
|
|
54
122
|
private seq;
|
|
55
123
|
private order;
|
|
56
|
-
|
|
124
|
+
private paths;
|
|
125
|
+
private locales;
|
|
126
|
+
constructor(collections: CollectionSchema<any>[], refs?: MemoryRefIndex, options?: EntryStoreOptions);
|
|
57
127
|
private row;
|
|
128
|
+
private must;
|
|
129
|
+
private byId;
|
|
130
|
+
private subtree;
|
|
131
|
+
private writePaths;
|
|
58
132
|
private writeRefs;
|
|
59
|
-
|
|
133
|
+
private siblings;
|
|
134
|
+
/** The memory mirror of entries_sibling_slug_key: a string slug must be
|
|
135
|
+
* unique among the entries sharing a parent. An absent, null or empty
|
|
136
|
+
* slug claims no URL and never conflicts (the index's partial
|
|
137
|
+
* predicate folds "" to null too via nullif). */
|
|
138
|
+
private assertSiblingSlug;
|
|
139
|
+
private assertParent;
|
|
140
|
+
create(collection: string, fields: Record<string, unknown>, options?: CreateEntryOptions): Promise<EntryRecord>;
|
|
60
141
|
get(collection: string, id: string): Promise<EntryRecord | null>;
|
|
61
142
|
list(collection: string, options?: ListOptions): Promise<EntryRecord[]>;
|
|
62
143
|
getMany(collection: string, ids: string[], options?: {
|
|
@@ -64,6 +145,10 @@ export declare class MemoryEntryStore implements EntryStore {
|
|
|
64
145
|
}): Promise<Record<string, Record<string, unknown>>>;
|
|
65
146
|
update(collection: string, id: string, fields: Record<string, unknown>): Promise<EntryRecord>;
|
|
66
147
|
setStatus(collection: string, id: string, status: "draft" | "published"): Promise<EntryRecord>;
|
|
148
|
+
move(collection: string, id: string, to: {
|
|
149
|
+
parentId: string | null;
|
|
150
|
+
index?: number;
|
|
151
|
+
}): Promise<EntryRecord>;
|
|
67
152
|
delete(collection: string, id: string): Promise<void>;
|
|
68
153
|
reorder(collection: string, orderedIds: string[]): Promise<void>;
|
|
69
154
|
referencesTo(collection: string, id: string): Promise<EntryUsage[]>;
|
|
@@ -71,4 +156,6 @@ export declare class MemoryEntryStore implements EntryStore {
|
|
|
71
156
|
sourceKind: string;
|
|
72
157
|
sourceId: string;
|
|
73
158
|
}[]>;
|
|
159
|
+
resolvePath(locale: string, path: string): Promise<PathTarget | null>;
|
|
160
|
+
pathsOf(collection: string, id: string): Promise<{}>;
|
|
74
161
|
}
|
package/dist/entry-store.js
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
// versioning in v1 (DESIGN.md §3). Reorder rewrites sort 1..N in one
|
|
7
7
|
// pass and NEVER touches updatedAt — order is a property of the
|
|
8
8
|
// collection, not an edit to its entries.
|
|
9
|
+
import { collectionDepth, collectionOrder } from "./collections.js";
|
|
9
10
|
import { collectEntryRefs } from "./refs.js";
|
|
10
11
|
import { MemoryRefIndex } from "./ref-index.js";
|
|
12
|
+
import { MemoryPathIndex } from "./path-index.js";
|
|
13
|
+
import { assertDepth, chainOf, entryPathRows, slugOf } from "./paths.js";
|
|
11
14
|
export function schemaOf(collections, name) {
|
|
12
15
|
const schema = collections.find((c) => c.name === name);
|
|
13
16
|
if (!schema)
|
|
@@ -16,38 +19,150 @@ export function schemaOf(collections, name) {
|
|
|
16
19
|
}
|
|
17
20
|
let counter = 0;
|
|
18
21
|
const uid = () => `entry-${++counter}-${Date.now().toString(36)}`;
|
|
22
|
+
export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
|
|
23
|
+
export const entryErrors = {
|
|
24
|
+
notFound: (collection, id) => new Error(`smoodly: no entry "${id}" in "${collection}".`),
|
|
25
|
+
hasChildren: (collection, id) => new Error(`smoodly: "${collection}/${id}" has child entries — move or delete them first.`),
|
|
26
|
+
cycle: () => new Error("smoodly: an entry cannot be moved under itself."),
|
|
27
|
+
foreignParent: () => new Error("smoodly: the parent of an entry must be in the same collection."),
|
|
28
|
+
/** Entry slugs are unique among SIBLINGS (DESIGN.md §3, decided
|
|
29
|
+
* 2026-09-05): the same rule pages have, since the URL is the tree.
|
|
30
|
+
* ONE source for the text — the Supabase adapter maps its
|
|
31
|
+
* entries_sibling_slug_key 23505 onto this same error. */
|
|
32
|
+
slugTaken: (collection) => new Error(`smoodly: a "${collection}" entry with that slug already exists under this parent.`),
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Sort comparator for one order rule; `seq` breaks ties by creation.
|
|
36
|
+
*
|
|
37
|
+
* Nulls sort last in BOTH directions (the Supabase adapter must use
|
|
38
|
+
* `nullsFirst: false` to match). The creation tiebreak follows `direction`
|
|
39
|
+
* for non-manual orders — two entries created in the same millisecond
|
|
40
|
+
* must still land newest-first under the default order, not oldest-first
|
|
41
|
+
* — while the manual branch's tiebreak stays ascending (creation order
|
|
42
|
+
* is the natural fallback for otherwise-unordered `sort` values).
|
|
43
|
+
* Declared fields compare numerically when both values are numbers,
|
|
44
|
+
* otherwise as strings via `localeCompare`. The Supabase adapter orders
|
|
45
|
+
* by the jsonb VALUE (`fields->x`, decided 2026-09-05), so the two agree
|
|
46
|
+
* for a field holding numbers and for one holding strings (ISO dates
|
|
47
|
+
* included). Two edges still differ: MIXED types in one field — Postgres
|
|
48
|
+
* orders jsonb by type first (null < string < number < boolean < array <
|
|
49
|
+
* object) rather than by `String(...)` — and an explicit JSON `null`
|
|
50
|
+
* VALUE, which is a jsonb null and sorts FIRST in Postgres but last here
|
|
51
|
+
* (a MISSING key is SQL NULL and does sort last, matching). See the
|
|
52
|
+
* package README's follow-ups.
|
|
53
|
+
*/
|
|
54
|
+
export function compareEntries(order, seq) {
|
|
55
|
+
return (a, b) => {
|
|
56
|
+
if (order === "manual") {
|
|
57
|
+
const d = (a.sort ?? Infinity) - (b.sort ?? Infinity);
|
|
58
|
+
return d !== 0 ? d : seq(a) - seq(b);
|
|
59
|
+
}
|
|
60
|
+
const dir = order.direction === "asc" ? 1 : -1;
|
|
61
|
+
const value = (e) => order.by === "createdAt" ? e.createdAt : order.by === "updatedAt" ? e.updatedAt : e.fields[order.by];
|
|
62
|
+
const va = value(a), vb = value(b);
|
|
63
|
+
if (va == null && vb == null)
|
|
64
|
+
return (seq(a) - seq(b)) * dir;
|
|
65
|
+
if (va == null)
|
|
66
|
+
return 1;
|
|
67
|
+
if (vb == null)
|
|
68
|
+
return -1;
|
|
69
|
+
const d = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb));
|
|
70
|
+
return d !== 0 ? d * dir : (seq(a) - seq(b)) * dir;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
19
73
|
export class MemoryEntryStore {
|
|
20
74
|
constructor(collections,
|
|
21
75
|
// pass the SAME index to MemoryPageStore so cross-kind questions work
|
|
22
|
-
refs = new MemoryRefIndex()) {
|
|
76
|
+
refs = new MemoryRefIndex(), options = {}) {
|
|
23
77
|
this.collections = collections;
|
|
24
78
|
this.refs = refs;
|
|
25
79
|
this.entries = new Map();
|
|
26
80
|
this.seq = 0; // creation-order tiebreak (Date.now can tie)
|
|
27
81
|
this.order = new Map();
|
|
82
|
+
this.byId = (id) => this.entries.get(id);
|
|
83
|
+
this.paths = options.paths ?? new MemoryPathIndex();
|
|
84
|
+
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
28
85
|
}
|
|
29
86
|
row(collection, id) {
|
|
30
87
|
const entry = this.entries.get(id);
|
|
31
88
|
return entry && entry.collection === collection ? entry : null;
|
|
32
89
|
}
|
|
90
|
+
must(collection, id) {
|
|
91
|
+
const entry = this.row(collection, id);
|
|
92
|
+
if (!entry)
|
|
93
|
+
throw entryErrors.notFound(collection, id);
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
subtree(entry) {
|
|
97
|
+
const out = [entry];
|
|
98
|
+
const walk = (parentId) => {
|
|
99
|
+
for (const e of this.entries.values())
|
|
100
|
+
if (e.parentId === parentId) {
|
|
101
|
+
out.push(e);
|
|
102
|
+
walk(e.id);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
walk(entry.id);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
async writePaths(collection, nodes) {
|
|
109
|
+
const schema = schemaOf(this.collections, collection);
|
|
110
|
+
await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byId, schema, this.locales));
|
|
111
|
+
}
|
|
33
112
|
writeRefs(collection, id, fields) {
|
|
34
113
|
const schema = schemaOf(this.collections, collection);
|
|
35
114
|
this.refs.rewrite({ sourceKind: "entry", sourceId: id }, collectEntryRefs(fields, schema));
|
|
36
115
|
}
|
|
37
|
-
|
|
116
|
+
siblings(collection, parentId, except) {
|
|
117
|
+
return [...this.entries.values()]
|
|
118
|
+
.filter((e) => e.collection === collection && e.parentId === parentId && e.id !== except)
|
|
119
|
+
.sort(compareEntries("manual", (e) => this.order.get(e.id)));
|
|
120
|
+
}
|
|
121
|
+
/** The memory mirror of entries_sibling_slug_key: a string slug must be
|
|
122
|
+
* unique among the entries sharing a parent. An absent, null or empty
|
|
123
|
+
* slug claims no URL and never conflicts (the index's partial
|
|
124
|
+
* predicate folds "" to null too via nullif). */
|
|
125
|
+
assertSiblingSlug(collection, parentId, fields, except) {
|
|
126
|
+
const slug = slugOf(fields);
|
|
127
|
+
if (slug === null)
|
|
128
|
+
return;
|
|
129
|
+
for (const e of this.siblings(collection, parentId, except)) {
|
|
130
|
+
if (slugOf(e.fields) === slug)
|
|
131
|
+
throw entryErrors.slugTaken(collection);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
assertParent(collection, parentId, subtreeHeight) {
|
|
135
|
+
if (parentId === null)
|
|
136
|
+
return;
|
|
137
|
+
const parent = this.entries.get(parentId);
|
|
138
|
+
if (!parent)
|
|
139
|
+
throw entryErrors.notFound(collection, parentId);
|
|
140
|
+
if (parent.collection !== collection)
|
|
141
|
+
throw entryErrors.foreignParent();
|
|
142
|
+
assertDepth(chainOf(parent, this.byId).length + subtreeHeight, collectionDepth(schemaOf(this.collections, collection)), "the entry");
|
|
143
|
+
}
|
|
144
|
+
async create(collection, fields, options = {}) {
|
|
38
145
|
schemaOf(this.collections, collection);
|
|
146
|
+
const parentId = options.parentId ?? null;
|
|
147
|
+
this.assertParent(collection, parentId, 1);
|
|
148
|
+
this.assertSiblingSlug(collection, parentId, fields);
|
|
39
149
|
const now = Date.now();
|
|
40
150
|
const entry = {
|
|
41
|
-
id: uid(),
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
sort: null,
|
|
46
|
-
createdAt: now,
|
|
47
|
-
updatedAt: now,
|
|
151
|
+
id: uid(), collection, parentId,
|
|
152
|
+
fields: structuredClone(fields), i18n: null,
|
|
153
|
+
status: "draft", sort: null, locales: options.locales ?? null,
|
|
154
|
+
createdAt: now, updatedAt: now,
|
|
48
155
|
};
|
|
49
156
|
this.entries.set(entry.id, entry);
|
|
50
157
|
this.order.set(entry.id, ++this.seq);
|
|
158
|
+
try {
|
|
159
|
+
await this.writePaths(collection, [entry]);
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
this.entries.delete(entry.id);
|
|
163
|
+
this.order.delete(entry.id);
|
|
164
|
+
throw e;
|
|
165
|
+
}
|
|
51
166
|
this.writeRefs(collection, entry.id, fields);
|
|
52
167
|
return structuredClone(entry);
|
|
53
168
|
}
|
|
@@ -56,22 +171,13 @@ export class MemoryEntryStore {
|
|
|
56
171
|
return entry ? structuredClone(entry) : null;
|
|
57
172
|
}
|
|
58
173
|
async list(collection, options) {
|
|
59
|
-
schemaOf(this.collections, collection);
|
|
174
|
+
const schema = schemaOf(this.collections, collection);
|
|
60
175
|
let rows = [...this.entries.values()].filter((e) => e.collection === collection);
|
|
61
176
|
if (options?.status)
|
|
62
177
|
rows = rows.filter((e) => e.status === options.status);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return b.updatedAt - a.updatedAt;
|
|
67
|
-
if (order === "manual") {
|
|
68
|
-
const sa = a.sort ?? Infinity;
|
|
69
|
-
const sb = b.sort ?? Infinity;
|
|
70
|
-
if (sa !== sb)
|
|
71
|
-
return sa - sb;
|
|
72
|
-
}
|
|
73
|
-
return this.order.get(a.id) - this.order.get(b.id);
|
|
74
|
-
});
|
|
178
|
+
if (options?.parent !== undefined)
|
|
179
|
+
rows = rows.filter((e) => e.parentId === options.parent);
|
|
180
|
+
rows.sort(compareEntries(options?.order ?? collectionOrder(schema), (e) => this.order.get(e.id)));
|
|
75
181
|
return rows.map((e) => structuredClone(e));
|
|
76
182
|
}
|
|
77
183
|
async getMany(collection, ids, options) {
|
|
@@ -88,10 +194,17 @@ export class MemoryEntryStore {
|
|
|
88
194
|
return out;
|
|
89
195
|
}
|
|
90
196
|
async update(collection, id, fields) {
|
|
91
|
-
const entry = this.
|
|
92
|
-
|
|
93
|
-
|
|
197
|
+
const entry = this.must(collection, id);
|
|
198
|
+
this.assertSiblingSlug(collection, entry.parentId, fields, id);
|
|
199
|
+
const before = entry.fields;
|
|
94
200
|
entry.fields = structuredClone(fields);
|
|
201
|
+
try {
|
|
202
|
+
await this.writePaths(collection, this.subtree(entry));
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
entry.fields = before;
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
95
208
|
entry.updatedAt = Date.now() > entry.updatedAt ? Date.now() : entry.updatedAt + 1;
|
|
96
209
|
this.writeRefs(collection, id, fields);
|
|
97
210
|
return structuredClone(entry);
|
|
@@ -99,19 +212,49 @@ export class MemoryEntryStore {
|
|
|
99
212
|
async setStatus(collection, id, status) {
|
|
100
213
|
const entry = this.row(collection, id);
|
|
101
214
|
if (!entry)
|
|
102
|
-
throw
|
|
215
|
+
throw entryErrors.notFound(collection, id);
|
|
103
216
|
entry.status = status;
|
|
104
217
|
entry.updatedAt = Date.now() > entry.updatedAt ? Date.now() : entry.updatedAt + 1;
|
|
105
218
|
return structuredClone(entry);
|
|
106
219
|
}
|
|
220
|
+
async move(collection, id, to) {
|
|
221
|
+
const entry = this.must(collection, id);
|
|
222
|
+
const subtree = this.subtree(entry);
|
|
223
|
+
if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
|
|
224
|
+
throw entryErrors.cycle();
|
|
225
|
+
const height = Math.max(...subtree.map((e) => chainOf(e, this.byId).length)) - chainOf(entry, this.byId).length + 1;
|
|
226
|
+
this.assertParent(collection, to.parentId, height);
|
|
227
|
+
if (to.parentId !== entry.parentId)
|
|
228
|
+
this.assertSiblingSlug(collection, to.parentId, entry.fields, id);
|
|
229
|
+
const before = { parentId: entry.parentId, sort: entry.sort };
|
|
230
|
+
entry.parentId = to.parentId;
|
|
231
|
+
const siblings = this.siblings(collection, to.parentId, id);
|
|
232
|
+
const index = Math.min(Math.max(to.index ?? siblings.length, 0), siblings.length);
|
|
233
|
+
siblings.splice(index, 0, entry);
|
|
234
|
+
const previous = new Map(siblings.map((s) => [s.id, s.sort]));
|
|
235
|
+
siblings.forEach((s, i) => (s.sort = i + 1));
|
|
236
|
+
try {
|
|
237
|
+
await this.writePaths(collection, subtree);
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
Object.assign(entry, before);
|
|
241
|
+
for (const s of siblings)
|
|
242
|
+
s.sort = previous.get(s.id) ?? null;
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
245
|
+
return structuredClone(entry);
|
|
246
|
+
}
|
|
107
247
|
async delete(collection, id) {
|
|
108
248
|
const entry = this.row(collection, id);
|
|
109
249
|
if (!entry)
|
|
110
250
|
return;
|
|
251
|
+
if ([...this.entries.values()].some((e) => e.parentId === id))
|
|
252
|
+
throw entryErrors.hasChildren(collection, id);
|
|
111
253
|
const usage = await this.referencesTo(collection, id);
|
|
112
254
|
if (usage.length > 0) {
|
|
113
255
|
throw new Error(`smoodly: "${collection}/${id}" is referenced by ${usage.length} document(s) — remove those references first.`);
|
|
114
256
|
}
|
|
257
|
+
await this.paths.remove([{ kind: "entry", id }]);
|
|
115
258
|
this.entries.delete(id);
|
|
116
259
|
this.refs.remove({ sourceKind: "entry", sourceId: id });
|
|
117
260
|
}
|
|
@@ -129,4 +272,10 @@ export class MemoryEntryStore {
|
|
|
129
272
|
async incoming(targetId) {
|
|
130
273
|
return this.refs.incoming(targetId);
|
|
131
274
|
}
|
|
275
|
+
resolvePath(locale, path) {
|
|
276
|
+
return this.paths.resolve(locale, path);
|
|
277
|
+
}
|
|
278
|
+
pathsOf(collection, id) {
|
|
279
|
+
return this.row(collection, id) ? this.paths.pathsOf({ kind: "entry", id }) : Promise.resolve({});
|
|
280
|
+
}
|
|
132
281
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,12 @@ import { section, element } from "./pairing.tsx";
|
|
|
2
2
|
import { collection, toolset } from "./collections.ts";
|
|
3
3
|
import { Section } from "./section-wrapper.tsx";
|
|
4
4
|
import { page } from "./render.tsx";
|
|
5
|
-
|
|
6
|
-
export
|
|
5
|
+
import { entryPage } from "./entry-page.tsx";
|
|
6
|
+
export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.tsx";
|
|
7
|
+
export type { PageTree, TreeNode, ZoneContent, PairedPage, Registry, PageContext, PageContextLink, PageLink, PageViewProps } from "./render.tsx";
|
|
7
8
|
export type { PageSchema } from "./page.ts";
|
|
9
|
+
export { fixedSlug } from "./page.ts";
|
|
10
|
+
export type { EntryPageSchema, PairedEntryPage, EntryPageProps } from "./entry-page.tsx";
|
|
8
11
|
export { f } from "./fields.ts";
|
|
9
12
|
export { z } from "./zones.ts";
|
|
10
13
|
export { defineConfig } from "./config.ts";
|
|
@@ -14,18 +17,28 @@ export { SupabasePageStore } from "./supabase-store.ts";
|
|
|
14
17
|
export { collectEntryRefs, collectTreeRefs } from "./refs.ts";
|
|
15
18
|
export type { RefEdge, SectionLike } from "./refs.ts";
|
|
16
19
|
export { MemoryRefIndex } from "./ref-index.ts";
|
|
20
|
+
export { MemoryPathIndex } from "./path-index.ts";
|
|
21
|
+
export type { PathIndex } from "./path-index.ts";
|
|
17
22
|
export { MemoryEntryStore } from "./entry-store.ts";
|
|
18
23
|
export { SupabaseEntryStore } from "./supabase-entry-store.ts";
|
|
19
|
-
export
|
|
20
|
-
export {
|
|
21
|
-
export {
|
|
22
|
-
export
|
|
23
|
-
export type {
|
|
24
|
+
export { SupabasePathIndex } from "./supabase-path-index.ts";
|
|
25
|
+
export type { EntryStore, EntryRecord, EntryUsage, ListOptions, CreateEntryOptions, EntryStoreOptions } from "./entry-store.ts";
|
|
26
|
+
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES } from "./entry-store.ts";
|
|
27
|
+
export { affectedTargets, pageTag, entryTag } from "./revalidate.ts";
|
|
28
|
+
export type { AffectedTargets } from "./revalidate.ts";
|
|
29
|
+
export { resolveTree, resolveFields } from "./resolve.ts";
|
|
30
|
+
export type { EntryFetcher, ResolveFieldsOptions } from "./resolve.ts";
|
|
31
|
+
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, localesOf, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, } from "./paths.ts";
|
|
32
|
+
export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions } from "./paths.ts";
|
|
33
|
+
export { titleFor, localizeFields, fixedPathFor } from "./localize.ts";
|
|
34
|
+
export type { PageStore, PageRecord, PageVersion, PageStoreOptions, PageI18n, CreatePageInput } from "./store.ts";
|
|
35
|
+
export { pathOptions, pageErrors, assertPageI18n, DEFAULT_LOCALES } from "./store.ts";
|
|
24
36
|
export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
|
|
25
37
|
export type { FieldBuilder, Descriptor, ValueOf } from "./fields.ts";
|
|
26
38
|
export type { SectionSchema, ElementSchema, BuilderMap } from "./schema.ts";
|
|
27
39
|
export type { Props, PairedComponent } from "./pairing.tsx";
|
|
28
|
-
export type { CollectionSchema, Toolset } from "./collections.ts";
|
|
40
|
+
export type { CollectionSchema, Toolset, EntryOrder } from "./collections.ts";
|
|
41
|
+
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.ts";
|
|
29
42
|
export type { ZonePolicy } from "./zones.ts";
|
|
30
43
|
export declare const smoodly: {
|
|
31
44
|
schema: {
|
|
@@ -94,6 +107,7 @@ export declare const smoodly: {
|
|
|
94
107
|
section: typeof section;
|
|
95
108
|
element: typeof element;
|
|
96
109
|
page: typeof page;
|
|
110
|
+
entryPage: typeof entryPage;
|
|
97
111
|
collection: typeof collection;
|
|
98
112
|
toolset: typeof toolset;
|
|
99
113
|
Section: typeof Section;
|
|
@@ -101,3 +115,5 @@ export declare const smoodly: {
|
|
|
101
115
|
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
|
|
102
116
|
export type { SmoodlyEnv } from "./env.ts";
|
|
103
117
|
export { clientOptions, createSmoodlyClient } from "./client.ts";
|
|
118
|
+
export { createSmoodlySite, pageContextOf } from "./site.ts";
|
|
119
|
+
export type { SiteOptions, SitePage, SiteEntry, SiteHit, SmoodlySite, LoadOptions, PageTreeItem, SiteEntryItem, EntryTreeItem, EntryQuery, } from "./site.ts";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,9 @@ import { section, element } from "./pairing.js";
|
|
|
3
3
|
import { collection, toolset } from "./collections.js";
|
|
4
4
|
import { Section } from "./section-wrapper.js";
|
|
5
5
|
import { page } from "./render.js";
|
|
6
|
-
|
|
6
|
+
import { entryPage } from "./entry-page.js";
|
|
7
|
+
export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.js";
|
|
8
|
+
export { fixedSlug } from "./page.js";
|
|
7
9
|
export { f } from "./fields.js";
|
|
8
10
|
export { z } from "./zones.js";
|
|
9
11
|
export { defineConfig } from "./config.js";
|
|
@@ -12,18 +14,27 @@ export { MemoryPageStore } from "./store.js";
|
|
|
12
14
|
export { SupabasePageStore } from "./supabase-store.js";
|
|
13
15
|
export { collectEntryRefs, collectTreeRefs } from "./refs.js";
|
|
14
16
|
export { MemoryRefIndex } from "./ref-index.js";
|
|
17
|
+
export { MemoryPathIndex } from "./path-index.js";
|
|
15
18
|
export { MemoryEntryStore } from "./entry-store.js";
|
|
16
19
|
export { SupabaseEntryStore } from "./supabase-entry-store.js";
|
|
17
|
-
export {
|
|
18
|
-
export {
|
|
20
|
+
export { SupabasePathIndex } from "./supabase-path-index.js";
|
|
21
|
+
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES } from "./entry-store.js";
|
|
22
|
+
export { affectedTargets, pageTag, entryTag } from "./revalidate.js";
|
|
23
|
+
export { resolveTree, resolveFields } from "./resolve.js";
|
|
24
|
+
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, localesOf, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, } from "./paths.js";
|
|
25
|
+
export { titleFor, localizeFields, fixedPathFor } from "./localize.js";
|
|
26
|
+
export { pathOptions, pageErrors, assertPageI18n, DEFAULT_LOCALES } from "./store.js";
|
|
27
|
+
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
|
|
19
28
|
export const smoodly = {
|
|
20
29
|
schema,
|
|
21
30
|
section,
|
|
22
31
|
element,
|
|
23
32
|
page,
|
|
33
|
+
entryPage,
|
|
24
34
|
collection,
|
|
25
35
|
toolset,
|
|
26
36
|
Section,
|
|
27
37
|
};
|
|
28
38
|
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.js";
|
|
29
39
|
export { clientOptions, createSmoodlyClient } from "./client.js";
|
|
40
|
+
export { createSmoodlySite, pageContextOf } from "./site.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type LocaleSet } from "./paths.ts";
|
|
2
|
+
/** The locale's title, or the default locale's. */
|
|
3
|
+
export declare function titleFor(node: {
|
|
4
|
+
title: string;
|
|
5
|
+
i18n?: Record<string, {
|
|
6
|
+
title?: string;
|
|
7
|
+
}> | null;
|
|
8
|
+
}, locale: string, set: LocaleSet): string;
|
|
9
|
+
/** A fresh object: the default locale's fields, overlaid per field with
|
|
10
|
+
* the locale's values. An overlay value that is undefined, null or ""
|
|
11
|
+
* falls back — a translator may leave a field blank. */
|
|
12
|
+
export declare function localizeFields(fields: Record<string, unknown>, i18n: Record<string, Record<string, unknown>> | null | undefined, locale: string, set: LocaleSet): Record<string, unknown>;
|
|
13
|
+
/** The path a FIXED page registration is served at in one locale — the
|
|
14
|
+
* escape hatch's addressing (spec §5: renderSmoodlyPage). Home is judged
|
|
15
|
+
* by the default-locale segment, exactly as the stores judge the record. */
|
|
16
|
+
export declare function fixedPathFor(schema: {
|
|
17
|
+
name: string;
|
|
18
|
+
slug?: string | Record<string, string>;
|
|
19
|
+
}, locale: string, set: LocaleSet, homePageSlug: string): string;
|
package/dist/localize.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Per-field locale fallback for the two overlays the stores carry
|
|
2
|
+
// (spec 2026-09-05 §2): `pages.i18n[locale].title` and
|
|
3
|
+
// `entries.i18n[locale]`. Slugs are paths.ts's business (segmentFor);
|
|
4
|
+
// this module owns titles and entry fields. Pure and framework-free.
|
|
5
|
+
import { segmentFor } from "./paths.js";
|
|
6
|
+
const present = (v) => v !== undefined && v !== null && v !== "";
|
|
7
|
+
/** The locale's title, or the default locale's. */
|
|
8
|
+
export function titleFor(node, locale, set) {
|
|
9
|
+
if (locale === set.default)
|
|
10
|
+
return node.title;
|
|
11
|
+
const own = node.i18n?.[locale]?.title;
|
|
12
|
+
return typeof own === "string" && own.length > 0 ? own : node.title;
|
|
13
|
+
}
|
|
14
|
+
/** A fresh object: the default locale's fields, overlaid per field with
|
|
15
|
+
* the locale's values. An overlay value that is undefined, null or ""
|
|
16
|
+
* falls back — a translator may leave a field blank. */
|
|
17
|
+
export function localizeFields(fields, i18n, locale, set) {
|
|
18
|
+
const out = structuredClone(fields);
|
|
19
|
+
if (locale === set.default)
|
|
20
|
+
return out;
|
|
21
|
+
for (const [key, value] of Object.entries(i18n?.[locale] ?? {})) {
|
|
22
|
+
if (present(value))
|
|
23
|
+
out[key] = structuredClone(value);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
/** The path a FIXED page registration is served at in one locale — the
|
|
28
|
+
* escape hatch's addressing (spec §5: renderSmoodlyPage). Home is judged
|
|
29
|
+
* by the default-locale segment, exactly as the stores judge the record. */
|
|
30
|
+
export function fixedPathFor(schema, locale, set, homePageSlug) {
|
|
31
|
+
if (schema.slug === undefined) {
|
|
32
|
+
throw new Error(`smoodly: page registration "${schema.name}" has no fixed slug — open pages are served by renderSmoodlyPath.`);
|
|
33
|
+
}
|
|
34
|
+
const slug = typeof schema.slug === "string" ? schema.slug : schema.slug[set.default];
|
|
35
|
+
if (slug === undefined) {
|
|
36
|
+
throw new Error(`smoodly: page registration "${schema.name}" declares a per-locale slug without the default locale "${set.default}".`);
|
|
37
|
+
}
|
|
38
|
+
if (slug === homePageSlug)
|
|
39
|
+
return "/";
|
|
40
|
+
const i18n = typeof schema.slug === "string" ? null : Object.fromEntries(Object.entries(schema.slug).map(([l, s]) => [l, { slug: s }]));
|
|
41
|
+
return `/${segmentFor({ slug, i18n }, locale, set)}`;
|
|
42
|
+
}
|
package/dist/next/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { createPageRenderer } from "./page-renderer.tsx";
|
|
2
|
-
export type { PageRendererOptions,
|
|
3
|
-
export {
|
|
2
|
+
export type { PageRendererOptions, PathRenderOptions, PageRegistration } from "./page-renderer.tsx";
|
|
3
|
+
export { metadataFrom } from "./meta.ts";
|
package/dist/next/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { createPageRenderer } from "./page-renderer.js";
|
|
2
|
-
export {
|
|
2
|
+
export { metadataFrom } from "./meta.js";
|
package/dist/next/meta.d.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
|
-
import type { PageSchema } from "../page.ts";
|
|
3
|
-
/** A route either carries a fixed registration (slug on the schema) or
|
|
4
|
-
* passes the slug from its params. Having neither is a developer wiring
|
|
5
|
-
* error, not runtime data — so this throws rather than returning a
|
|
6
|
-
* result. */
|
|
7
|
-
export declare function pageSlugFor(schema: PageSchema<any>, explicit?: string): string;
|
|
8
2
|
/** Maps the page tree's meta bag onto Next metadata. Absent meta returns
|
|
9
3
|
* {} so the app's own layout.tsx defaults apply untouched. */
|
|
10
4
|
export declare function metadataFrom(meta: unknown): Metadata;
|
package/dist/next/meta.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/** A route either carries a fixed registration (slug on the schema) or
|
|
2
|
-
* passes the slug from its params. Having neither is a developer wiring
|
|
3
|
-
* error, not runtime data — so this throws rather than returning a
|
|
4
|
-
* result. */
|
|
5
|
-
export function pageSlugFor(schema, explicit) {
|
|
6
|
-
if (explicit)
|
|
7
|
-
return explicit;
|
|
8
|
-
if (schema.slug)
|
|
9
|
-
return schema.slug;
|
|
10
|
-
throw new Error(`smoodly: page registration "${schema.name}" has no fixed slug — pass { slug } from the route's params to renderSmoodlyPage.`);
|
|
11
|
-
}
|
|
12
1
|
/** Maps the page tree's meta bag onto Next metadata. Absent meta returns
|
|
13
2
|
* {} so the app's own layout.tsx defaults apply untouched. */
|
|
14
3
|
export function metadataFrom(meta) {
|