smoodly 0.0.6 → 0.0.7
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 +231 -73
- package/dist/admin/client-ops.js +2 -2
- package/dist/admin/editor/EditorView.js +167 -24
- 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/ops-impl.js +134 -35
- package/dist/admin/ops.d.ts +51 -11
- package/dist/admin/shell/AdminApp.js +3 -32
- 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/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/ui/LocaleSwitcher.d.ts +11 -0
- package/dist/admin/ui/LocaleSwitcher.js +15 -0
- package/dist/collections.d.ts +6 -0
- package/dist/collections.js +16 -0
- package/dist/config.js +23 -3
- package/dist/entry-store.d.ts +132 -54
- package/dist/entry-store.js +294 -86
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/localize.d.ts +0 -11
- package/dist/localize.js +11 -29
- package/dist/paths.d.ts +46 -27
- package/dist/paths.js +62 -29
- package/dist/revalidate.js +2 -1
- package/dist/site.d.ts +6 -2
- package/dist/site.js +65 -39
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +13 -5
- package/dist/sql.js +96 -53
- package/dist/store.d.ts +66 -32
- package/dist/store.js +205 -90
- package/dist/supabase-entry-store.d.ts +29 -8
- package/dist/supabase-entry-store.js +341 -177
- package/dist/supabase-store.d.ts +21 -9
- package/dist/supabase-store.js +202 -111
- package/package.json +1 -1
package/dist/entry-store.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// The collection-entry persistence contract — the entries counterpart
|
|
2
|
-
// to PageStore.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
2
|
+
// to PageStore. An entry is ONE node (identity, tree position, the
|
|
3
|
+
// SHARED fields) with one row per locale it exists in (spec 2026-09-06
|
|
4
|
+
// §7): the locale row owns its slug, its publish state and the
|
|
5
|
+
// .localized() fields, and — for a collection registered with
|
|
6
|
+
// `versions: true` — a history of merged snapshots. Nothing falls back
|
|
7
|
+
// to another locale. Writes always flow through this API (never raw
|
|
8
|
+
// table access), which is what lets the refs index stay correct: every
|
|
9
|
+
// save rewrites the entry's outgoing edges over EVERY locale's live
|
|
10
|
+
// field set. Reorder rewrites sort 1..N in one pass and NEVER touches
|
|
11
|
+
// updatedAt — order is a property of the collection, not an edit.
|
|
9
12
|
import { collectionDepth, collectionOrder } from "./collections.js";
|
|
10
13
|
import { collectEntryRefs } from "./refs.js";
|
|
11
14
|
import { MemoryRefIndex } from "./ref-index.js";
|
|
12
15
|
import { MemoryPathIndex } from "./path-index.js";
|
|
13
|
-
import { assertDepth, chainOf, entryPathRows, slugOf } from "./paths.js";
|
|
16
|
+
import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
|
|
14
17
|
export function schemaOf(collections, name) {
|
|
15
18
|
const schema = collections.find((c) => c.name === name);
|
|
16
19
|
if (!schema)
|
|
@@ -18,38 +21,89 @@ export function schemaOf(collections, name) {
|
|
|
18
21
|
return schema;
|
|
19
22
|
}
|
|
20
23
|
let counter = 0;
|
|
21
|
-
const uid = () =>
|
|
24
|
+
const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
|
|
22
25
|
export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
|
|
23
26
|
export const entryErrors = {
|
|
24
27
|
notFound: (collection, id) => new Error(`smoodly: no entry "${id}" in "${collection}".`),
|
|
25
28
|
hasChildren: (collection, id) => new Error(`smoodly: "${collection}/${id}" has child entries — move or delete them first.`),
|
|
26
29
|
cycle: () => new Error("smoodly: an entry cannot be moved under itself."),
|
|
27
30
|
foreignParent: () => new Error("smoodly: the parent of an entry must be in the same collection."),
|
|
28
|
-
/** Entry slugs are unique among SIBLINGS
|
|
29
|
-
* 2026-09-
|
|
30
|
-
*
|
|
31
|
-
* entries_sibling_slug_key 23505 onto this same error. */
|
|
31
|
+
/** Entry slugs are unique among SIBLINGS per locale — the rule pages
|
|
32
|
+
* have (spec 2026-09-06 §11): both adapters pre-check in code and the
|
|
33
|
+
* paths primary key is the invariant. ONE source for the text. */
|
|
32
34
|
slugTaken: (collection) => new Error(`smoodly: a "${collection}" entry with that slug already exists under this parent.`),
|
|
35
|
+
unsupportedLocale,
|
|
36
|
+
notInLocale: (id, locale) => new Error(`smoodly: entry "${id}" does not exist in locale "${locale}".`),
|
|
37
|
+
alreadyInLocale: (id, locale) => new Error(`smoodly: entry "${id}" already exists in locale "${locale}".`),
|
|
38
|
+
parentNotInLocale: (locale) => new Error(`smoodly: the parent entry does not exist in locale "${locale}" — add it there first.`),
|
|
39
|
+
lastLocale: (id) => new Error(`smoodly: entry "${id}" must exist in at least one locale — delete the entry instead.`),
|
|
40
|
+
localeInUse: (locale, titles) => new Error(`smoodly: child entries still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
|
|
41
|
+
moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the entry cannot move under it.`),
|
|
33
42
|
};
|
|
43
|
+
/** The keys a locale row owns: every field with .localized(), except
|
|
44
|
+
* `slug`, which is the row's own column (a segment is an address, per
|
|
45
|
+
* locale by nature). */
|
|
46
|
+
export function localizedKeys(schema) {
|
|
47
|
+
return Object.entries(schema.fields)
|
|
48
|
+
.filter(([key, d]) => key !== "slug" && d.localized === true)
|
|
49
|
+
.map(([key]) => key);
|
|
50
|
+
}
|
|
51
|
+
/** The merged form an editor submits, split the way the tables store it.
|
|
52
|
+
* Every key that is neither localized nor `slug` is shared — declared or
|
|
53
|
+
* not — so a stray key never disappears into a locale row. */
|
|
54
|
+
export function splitFields(fields, schema) {
|
|
55
|
+
const keys = new Set(localizedKeys(schema));
|
|
56
|
+
const shared = {};
|
|
57
|
+
const localized = {};
|
|
58
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
59
|
+
if (key === "slug")
|
|
60
|
+
continue;
|
|
61
|
+
(keys.has(key) ? localized : shared)[key] = value;
|
|
62
|
+
}
|
|
63
|
+
return { shared: structuredClone(shared), localized: structuredClone(localized), slug: slugOf(fields) };
|
|
64
|
+
}
|
|
65
|
+
export function entryLocaleRow(record, locale) {
|
|
66
|
+
const row = record.locales[locale];
|
|
67
|
+
if (!row)
|
|
68
|
+
throw entryErrors.notInLocale(record.id, locale);
|
|
69
|
+
return row;
|
|
70
|
+
}
|
|
71
|
+
/** The merged view of one locale: shared + that locale's fields + its
|
|
72
|
+
* slug. A fresh object; throws for an absent locale. */
|
|
73
|
+
export function entryFieldsIn(record, locale) {
|
|
74
|
+
const row = entryLocaleRow(record, locale);
|
|
75
|
+
return {
|
|
76
|
+
...structuredClone(record.fields),
|
|
77
|
+
...structuredClone(row.fields),
|
|
78
|
+
...(row.slug !== null ? { slug: row.slug } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** The refs index rows for an entry: the union over every live field set
|
|
82
|
+
* — one per present locale, plus each published snapshot the draft has
|
|
83
|
+
* moved past. Safe-delete must hold while ANY of them shows the target,
|
|
84
|
+
* since the published site still renders it (DESIGN.md §3 "Page edges
|
|
85
|
+
* in refs", the same stance). */
|
|
86
|
+
export function entryRefEdges(fieldSets, schema) {
|
|
87
|
+
const edges = [];
|
|
88
|
+
for (const fields of fieldSets)
|
|
89
|
+
edges.push(...collectEntryRefs(fields, schema));
|
|
90
|
+
return edges;
|
|
91
|
+
}
|
|
92
|
+
/** What a message calls an entry: its title field in that locale, else its id. */
|
|
93
|
+
export function entryTitleIn(record, locale, schema) {
|
|
94
|
+
const value = schema.titleField && record.locales[locale] ? entryFieldsIn(record, locale)[schema.titleField] : undefined;
|
|
95
|
+
return typeof value === "string" && value.length > 0 ? value : record.id;
|
|
96
|
+
}
|
|
34
97
|
/**
|
|
35
|
-
* Sort comparator for one order rule; `seq` breaks ties by creation.
|
|
36
|
-
*
|
|
37
|
-
* Nulls sort last in BOTH
|
|
38
|
-
* `nullsFirst: false` to match)
|
|
39
|
-
* for non-manual orders
|
|
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).
|
|
98
|
+
* Sort comparator for one order rule; `seq` breaks ties by creation. A
|
|
99
|
+
* declared order field is SHARED (collection() refuses a localized one),
|
|
100
|
+
* so `e.fields[order.by]` reads the node. Nulls sort last in BOTH
|
|
101
|
+
* directions (the Supabase adapter uses `nullsFirst: false` to match);
|
|
102
|
+
* the creation tiebreak follows `direction` for non-manual orders.
|
|
43
103
|
* Declared fields compare numerically when both values are numbers,
|
|
44
|
-
* otherwise as strings via `localeCompare
|
|
45
|
-
*
|
|
46
|
-
* for
|
|
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.
|
|
104
|
+
* otherwise as strings via `localeCompare` — the same order Postgres
|
|
105
|
+
* gives the jsonb VALUE (`fields->x`) for one type; see the README's
|
|
106
|
+
* follow-ups for the mixed-type and JSON-null edges.
|
|
53
107
|
*/
|
|
54
108
|
export function compareEntries(order, seq) {
|
|
55
109
|
return (a, b) => {
|
|
@@ -77,12 +131,18 @@ export class MemoryEntryStore {
|
|
|
77
131
|
this.collections = collections;
|
|
78
132
|
this.refs = refs;
|
|
79
133
|
this.entries = new Map();
|
|
80
|
-
this.
|
|
134
|
+
this.versions = new Map();
|
|
135
|
+
/** Insertion order per version — `createdAt` alone can tie within a millisecond. */
|
|
136
|
+
this.versionSeq = new Map();
|
|
137
|
+
this.seq = 0;
|
|
81
138
|
this.order = new Map();
|
|
82
139
|
this.byId = (id) => this.entries.get(id);
|
|
83
140
|
this.paths = options.paths ?? new MemoryPathIndex();
|
|
84
141
|
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
85
142
|
}
|
|
143
|
+
schema(collection) {
|
|
144
|
+
return schemaOf(this.collections, collection);
|
|
145
|
+
}
|
|
86
146
|
row(collection, id) {
|
|
87
147
|
const entry = this.entries.get(id);
|
|
88
148
|
return entry && entry.collection === collection ? entry : null;
|
|
@@ -93,6 +153,9 @@ export class MemoryEntryStore {
|
|
|
93
153
|
throw entryErrors.notFound(collection, id);
|
|
94
154
|
return entry;
|
|
95
155
|
}
|
|
156
|
+
snapshot(entry) {
|
|
157
|
+
return structuredClone(entry);
|
|
158
|
+
}
|
|
96
159
|
subtree(entry) {
|
|
97
160
|
const out = [entry];
|
|
98
161
|
const walk = (parentId) => {
|
|
@@ -105,54 +168,104 @@ export class MemoryEntryStore {
|
|
|
105
168
|
walk(entry.id);
|
|
106
169
|
return out;
|
|
107
170
|
}
|
|
171
|
+
/** An edit bumps the node and the edited row; `move` and `reorder` never call this. */
|
|
172
|
+
bump(entry, row) {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
entry.updatedAt = now > entry.updatedAt ? now : entry.updatedAt + 1;
|
|
175
|
+
if (row)
|
|
176
|
+
row.updatedAt = entry.updatedAt;
|
|
177
|
+
}
|
|
178
|
+
/** A versioned collection records every save as a merged snapshot and
|
|
179
|
+
* points the locale's draft at it; others keep the row as the live content. */
|
|
180
|
+
recordVersion(entry, locale) {
|
|
181
|
+
if (!this.schema(entry.collection).versions)
|
|
182
|
+
return;
|
|
183
|
+
const version = {
|
|
184
|
+
id: uid("version"), entryId: entry.id, locale, fields: entryFieldsIn(entry, locale), createdAt: Date.now(),
|
|
185
|
+
};
|
|
186
|
+
this.versions.set(version.id, version);
|
|
187
|
+
this.versionSeq.set(version.id, ++this.seq);
|
|
188
|
+
entry.locales[locale].draftVersionId = version.id;
|
|
189
|
+
}
|
|
190
|
+
dropVersions(entryId, locale) {
|
|
191
|
+
for (const [id, v] of this.versions) {
|
|
192
|
+
if (v.entryId === entryId && (locale === undefined || v.locale === locale)) {
|
|
193
|
+
this.versions.delete(id);
|
|
194
|
+
this.versionSeq.delete(id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Every field set the site can render for this entry: each present
|
|
199
|
+
* locale's live view, plus a published snapshot the draft has moved
|
|
200
|
+
* past (still rendered by the published site). */
|
|
201
|
+
liveFieldSets(entry) {
|
|
202
|
+
const sets = [];
|
|
203
|
+
for (const [locale, row] of Object.entries(entry.locales)) {
|
|
204
|
+
sets.push(entryFieldsIn(entry, locale));
|
|
205
|
+
if (row.status === "published" && row.publishedVersionId && row.publishedVersionId !== row.draftVersionId) {
|
|
206
|
+
const snapshot = this.versions.get(row.publishedVersionId);
|
|
207
|
+
if (snapshot)
|
|
208
|
+
sets.push(snapshot.fields);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return sets;
|
|
212
|
+
}
|
|
108
213
|
async writePaths(collection, nodes) {
|
|
109
|
-
|
|
110
|
-
await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byId, schema, this.locales));
|
|
214
|
+
await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byId, this.schema(collection), this.locales));
|
|
111
215
|
}
|
|
112
|
-
writeRefs(
|
|
113
|
-
|
|
114
|
-
this.refs.rewrite({ sourceKind: "entry", sourceId: id }, collectEntryRefs(fields, schema));
|
|
216
|
+
writeRefs(entry) {
|
|
217
|
+
this.refs.rewrite({ sourceKind: "entry", sourceId: entry.id }, entryRefEdges(this.liveFieldSets(entry), this.schema(entry.collection)));
|
|
115
218
|
}
|
|
116
219
|
siblings(collection, parentId, except) {
|
|
117
220
|
return [...this.entries.values()]
|
|
118
221
|
.filter((e) => e.collection === collection && e.parentId === parentId && e.id !== except)
|
|
119
222
|
.sort(compareEntries("manual", (e) => this.order.get(e.id)));
|
|
120
223
|
}
|
|
121
|
-
/** The
|
|
122
|
-
* unique among the
|
|
123
|
-
*
|
|
124
|
-
|
|
125
|
-
assertSiblingSlug(collection, parentId, fields, except) {
|
|
126
|
-
const slug = slugOf(fields);
|
|
224
|
+
/** The friendly pre-check (spec 2026-09-06 §11): a string slug must be
|
|
225
|
+
* unique among the siblings PRESENT in that locale; null never
|
|
226
|
+
* conflicts. The paths primary key remains the invariant. */
|
|
227
|
+
assertSiblingSlug(collection, parentId, locale, slug, except) {
|
|
127
228
|
if (slug === null)
|
|
128
229
|
return;
|
|
129
230
|
for (const e of this.siblings(collection, parentId, except)) {
|
|
130
|
-
if (
|
|
231
|
+
if (e.locales[locale]?.slug === slug)
|
|
131
232
|
throw entryErrors.slugTaken(collection);
|
|
132
233
|
}
|
|
133
234
|
}
|
|
235
|
+
/** Same collection, within depth. Returns the parent (null at the root). */
|
|
134
236
|
assertParent(collection, parentId, subtreeHeight) {
|
|
135
237
|
if (parentId === null)
|
|
136
|
-
return;
|
|
238
|
+
return null;
|
|
137
239
|
const parent = this.entries.get(parentId);
|
|
138
240
|
if (!parent)
|
|
139
241
|
throw entryErrors.notFound(collection, parentId);
|
|
140
242
|
if (parent.collection !== collection)
|
|
141
243
|
throw entryErrors.foreignParent();
|
|
142
|
-
assertDepth(chainOf(parent, this.byId).length + subtreeHeight, collectionDepth(
|
|
244
|
+
assertDepth(chainOf(parent, this.byId).length + subtreeHeight, collectionDepth(this.schema(collection)), "the entry");
|
|
245
|
+
return parent;
|
|
143
246
|
}
|
|
144
|
-
|
|
145
|
-
|
|
247
|
+
/** The parent rule: a locale can only be added where the parent has it. */
|
|
248
|
+
assertParentHasLocale(parent, locale) {
|
|
249
|
+
if (parent && !parent.locales[locale])
|
|
250
|
+
throw entryErrors.parentNotInLocale(locale);
|
|
251
|
+
}
|
|
252
|
+
async create(collection, fields, options) {
|
|
253
|
+
const schema = this.schema(collection);
|
|
254
|
+
assertSupportedLocale(options.locale, this.locales);
|
|
146
255
|
const parentId = options.parentId ?? null;
|
|
147
|
-
this.assertParent(collection, parentId, 1);
|
|
148
|
-
this.
|
|
256
|
+
const parent = this.assertParent(collection, parentId, 1);
|
|
257
|
+
this.assertParentHasLocale(parent, options.locale);
|
|
258
|
+
const { shared, localized, slug } = splitFields(fields, schema);
|
|
259
|
+
this.assertSiblingSlug(collection, parentId, options.locale, slug);
|
|
149
260
|
const now = Date.now();
|
|
150
261
|
const entry = {
|
|
151
|
-
id: uid(), collection, parentId,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
262
|
+
id: uid("entry"), collection, parentId, fields: shared,
|
|
263
|
+
locales: {
|
|
264
|
+
[options.locale]: { slug, status: "draft", fields: localized, draftVersionId: null, publishedVersionId: null, updatedAt: now },
|
|
265
|
+
},
|
|
266
|
+
sort: null, createdAt: now, updatedAt: now,
|
|
155
267
|
};
|
|
268
|
+
this.recordVersion(entry, options.locale);
|
|
156
269
|
this.entries.set(entry.id, entry);
|
|
157
270
|
this.order.set(entry.id, ++this.seq);
|
|
158
271
|
try {
|
|
@@ -161,61 +274,147 @@ export class MemoryEntryStore {
|
|
|
161
274
|
catch (e) {
|
|
162
275
|
this.entries.delete(entry.id);
|
|
163
276
|
this.order.delete(entry.id);
|
|
277
|
+
this.dropVersions(entry.id);
|
|
164
278
|
throw e;
|
|
165
279
|
}
|
|
166
|
-
this.writeRefs(
|
|
167
|
-
return
|
|
280
|
+
this.writeRefs(entry);
|
|
281
|
+
return this.snapshot(entry);
|
|
168
282
|
}
|
|
169
283
|
async get(collection, id) {
|
|
170
284
|
const entry = this.row(collection, id);
|
|
171
|
-
return entry ?
|
|
285
|
+
return entry ? this.snapshot(entry) : null;
|
|
172
286
|
}
|
|
173
287
|
async list(collection, options) {
|
|
174
|
-
const schema =
|
|
288
|
+
const schema = this.schema(collection);
|
|
175
289
|
let rows = [...this.entries.values()].filter((e) => e.collection === collection);
|
|
176
|
-
if (options?.status)
|
|
177
|
-
|
|
290
|
+
if (options?.status) {
|
|
291
|
+
const locale = options.locale ?? this.locales.default;
|
|
292
|
+
rows = rows.filter((e) => e.locales[locale]?.status === options.status);
|
|
293
|
+
}
|
|
178
294
|
if (options?.parent !== undefined)
|
|
179
295
|
rows = rows.filter((e) => e.parentId === options.parent);
|
|
180
296
|
rows.sort(compareEntries(options?.order ?? collectionOrder(schema), (e) => this.order.get(e.id)));
|
|
181
|
-
return rows.map((e) =>
|
|
297
|
+
return rows.map((e) => this.snapshot(e));
|
|
182
298
|
}
|
|
183
|
-
async getMany(collection, ids, options) {
|
|
184
|
-
|
|
299
|
+
async getMany(collection, ids, locale, options) {
|
|
300
|
+
this.schema(collection);
|
|
185
301
|
const out = {};
|
|
186
302
|
for (const id of ids) {
|
|
187
303
|
const entry = this.row(collection, id);
|
|
188
|
-
|
|
304
|
+
const row = entry?.locales[locale];
|
|
305
|
+
if (!entry || !row)
|
|
189
306
|
continue;
|
|
190
|
-
if (options?.status
|
|
191
|
-
|
|
192
|
-
|
|
307
|
+
if (options?.status) {
|
|
308
|
+
if (row.status !== options.status)
|
|
309
|
+
continue;
|
|
310
|
+
// A versioned collection serves what was published, not the live row.
|
|
311
|
+
const snapshot = row.publishedVersionId ? this.versions.get(row.publishedVersionId) : undefined;
|
|
312
|
+
out[id] = snapshot ? structuredClone(snapshot.fields) : entryFieldsIn(entry, locale);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
out[id] = entryFieldsIn(entry, locale);
|
|
316
|
+
}
|
|
193
317
|
}
|
|
194
318
|
return out;
|
|
195
319
|
}
|
|
196
|
-
async update(collection, id, fields) {
|
|
320
|
+
async update(collection, id, locale, fields) {
|
|
321
|
+
const schema = this.schema(collection);
|
|
197
322
|
const entry = this.must(collection, id);
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
entry.
|
|
323
|
+
const row = entryLocaleRow(entry, locale);
|
|
324
|
+
const { shared, localized, slug } = splitFields(fields, schema);
|
|
325
|
+
this.assertSiblingSlug(collection, entry.parentId, locale, slug, id);
|
|
326
|
+
const before = { fields: entry.fields, row: { ...row } };
|
|
327
|
+
entry.fields = shared;
|
|
328
|
+
row.fields = localized;
|
|
329
|
+
row.slug = slug;
|
|
201
330
|
try {
|
|
202
331
|
await this.writePaths(collection, this.subtree(entry));
|
|
203
332
|
}
|
|
204
333
|
catch (e) {
|
|
205
|
-
entry.fields = before;
|
|
334
|
+
entry.fields = before.fields;
|
|
335
|
+
Object.assign(row, before.row);
|
|
206
336
|
throw e;
|
|
207
337
|
}
|
|
208
|
-
|
|
209
|
-
this.
|
|
210
|
-
|
|
338
|
+
this.recordVersion(entry, locale);
|
|
339
|
+
this.bump(entry, row);
|
|
340
|
+
this.writeRefs(entry);
|
|
341
|
+
return this.snapshot(entry);
|
|
211
342
|
}
|
|
212
|
-
async setStatus(collection, id, status) {
|
|
213
|
-
const entry = this.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
343
|
+
async setStatus(collection, id, locale, status) {
|
|
344
|
+
const entry = this.must(collection, id);
|
|
345
|
+
const row = entryLocaleRow(entry, locale);
|
|
346
|
+
if (status === "published") {
|
|
347
|
+
// Publish points at the current draft; a versioned row always has one
|
|
348
|
+
// (create and update record it), but never trust that from here.
|
|
349
|
+
if (this.schema(collection).versions && !row.draftVersionId)
|
|
350
|
+
this.recordVersion(entry, locale);
|
|
351
|
+
row.publishedVersionId = row.draftVersionId;
|
|
352
|
+
}
|
|
353
|
+
// Unpublish keeps the pointer for a later republish (spec §2).
|
|
354
|
+
row.status = status;
|
|
355
|
+
this.bump(entry, row);
|
|
356
|
+
this.writeRefs(entry); // the published snapshot just became, or stopped being, a live set
|
|
357
|
+
return this.snapshot(entry);
|
|
358
|
+
}
|
|
359
|
+
async addLocale(collection, id, locale, options) {
|
|
360
|
+
assertSupportedLocale(locale, this.locales);
|
|
361
|
+
const entry = this.must(collection, id);
|
|
362
|
+
// The source is read FIRST: "copy from a locale that isn't there" is
|
|
363
|
+
// the more specific complaint, even when the target locale also exists.
|
|
364
|
+
const source = entryLocaleRow(entry, options.from);
|
|
365
|
+
if (entry.locales[locale])
|
|
366
|
+
throw entryErrors.alreadyInLocale(id, locale);
|
|
367
|
+
const parent = entry.parentId === null ? null : (this.entries.get(entry.parentId) ?? null);
|
|
368
|
+
this.assertParentHasLocale(parent, locale);
|
|
369
|
+
this.assertSiblingSlug(collection, entry.parentId, locale, source.slug, id);
|
|
370
|
+
const now = Date.now();
|
|
371
|
+
entry.locales[locale] = {
|
|
372
|
+
slug: source.slug, status: "draft", fields: structuredClone(source.fields),
|
|
373
|
+
draftVersionId: null, publishedVersionId: null, updatedAt: now,
|
|
374
|
+
};
|
|
375
|
+
this.recordVersion(entry, locale);
|
|
376
|
+
// Only this node's rows change: a child cannot have a locale its parent lacked.
|
|
377
|
+
try {
|
|
378
|
+
await this.writePaths(collection, [entry]);
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
delete entry.locales[locale];
|
|
382
|
+
this.dropVersions(id, locale);
|
|
383
|
+
throw e;
|
|
384
|
+
}
|
|
385
|
+
this.writeRefs(entry);
|
|
386
|
+
return this.snapshot(entry);
|
|
387
|
+
}
|
|
388
|
+
async removeLocale(collection, id, locale) {
|
|
389
|
+
const schema = this.schema(collection);
|
|
390
|
+
const entry = this.must(collection, id);
|
|
391
|
+
const row = entryLocaleRow(entry, locale);
|
|
392
|
+
if (Object.keys(entry.locales).length === 1)
|
|
393
|
+
throw entryErrors.lastLocale(id);
|
|
394
|
+
const blockers = [...this.entries.values()]
|
|
395
|
+
.filter((e) => e.parentId === id && e.locales[locale] !== undefined)
|
|
396
|
+
.map((e) => entryTitleIn(e, locale, schema));
|
|
397
|
+
if (blockers.length > 0)
|
|
398
|
+
throw entryErrors.localeInUse(locale, blockers);
|
|
399
|
+
delete entry.locales[locale];
|
|
400
|
+
try {
|
|
401
|
+
await this.writePaths(collection, [entry]);
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
entry.locales[locale] = row;
|
|
405
|
+
throw e;
|
|
406
|
+
}
|
|
407
|
+
this.dropVersions(id, locale);
|
|
408
|
+
this.bump(entry);
|
|
409
|
+
this.writeRefs(entry);
|
|
410
|
+
return this.snapshot(entry);
|
|
411
|
+
}
|
|
412
|
+
async listVersions(collection, id, locale) {
|
|
413
|
+
this.must(collection, id);
|
|
414
|
+
return [...this.versions.values()]
|
|
415
|
+
.filter((v) => v.entryId === id && v.locale === locale)
|
|
416
|
+
.sort((a, b) => b.createdAt - a.createdAt || this.versionSeq.get(b.id) - this.versionSeq.get(a.id))
|
|
417
|
+
.map((v) => structuredClone(v));
|
|
219
418
|
}
|
|
220
419
|
async move(collection, id, to) {
|
|
221
420
|
const entry = this.must(collection, id);
|
|
@@ -223,9 +422,16 @@ export class MemoryEntryStore {
|
|
|
223
422
|
if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
|
|
224
423
|
throw entryErrors.cycle();
|
|
225
424
|
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
|
-
|
|
228
|
-
|
|
425
|
+
const parent = this.assertParent(collection, to.parentId, height);
|
|
426
|
+
// The destination must have every locale the entry has, or a chain breaks there.
|
|
427
|
+
if (parent)
|
|
428
|
+
for (const locale of Object.keys(entry.locales))
|
|
429
|
+
if (!parent.locales[locale])
|
|
430
|
+
throw entryErrors.moveLocales(locale);
|
|
431
|
+
if (to.parentId !== entry.parentId) {
|
|
432
|
+
for (const [locale, row] of Object.entries(entry.locales))
|
|
433
|
+
this.assertSiblingSlug(collection, to.parentId, locale, row.slug, id);
|
|
434
|
+
}
|
|
229
435
|
const before = { parentId: entry.parentId, sort: entry.sort };
|
|
230
436
|
entry.parentId = to.parentId;
|
|
231
437
|
const siblings = this.siblings(collection, to.parentId, id);
|
|
@@ -242,7 +448,7 @@ export class MemoryEntryStore {
|
|
|
242
448
|
s.sort = previous.get(s.id) ?? null;
|
|
243
449
|
throw e;
|
|
244
450
|
}
|
|
245
|
-
return
|
|
451
|
+
return this.snapshot(entry);
|
|
246
452
|
}
|
|
247
453
|
async delete(collection, id) {
|
|
248
454
|
const entry = this.row(collection, id);
|
|
@@ -256,10 +462,12 @@ export class MemoryEntryStore {
|
|
|
256
462
|
}
|
|
257
463
|
await this.paths.remove([{ kind: "entry", id }]);
|
|
258
464
|
this.entries.delete(id);
|
|
465
|
+
this.order.delete(id);
|
|
466
|
+
this.dropVersions(id);
|
|
259
467
|
this.refs.remove({ sourceKind: "entry", sourceId: id });
|
|
260
468
|
}
|
|
261
469
|
async reorder(collection, orderedIds) {
|
|
262
|
-
|
|
470
|
+
this.schema(collection);
|
|
263
471
|
orderedIds.forEach((id, i) => {
|
|
264
472
|
const entry = this.row(collection, id);
|
|
265
473
|
if (entry)
|
package/dist/index.d.ts
CHANGED
|
@@ -22,17 +22,17 @@ export type { PathIndex } from "./path-index.ts";
|
|
|
22
22
|
export { MemoryEntryStore } from "./entry-store.ts";
|
|
23
23
|
export { SupabaseEntryStore } from "./supabase-entry-store.ts";
|
|
24
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";
|
|
25
|
+
export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, } from "./entry-store.ts";
|
|
26
|
+
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.ts";
|
|
27
27
|
export { affectedTargets, pageTag, entryTag } from "./revalidate.ts";
|
|
28
28
|
export type { AffectedTargets } from "./revalidate.ts";
|
|
29
29
|
export { resolveTree, resolveFields } from "./resolve.ts";
|
|
30
30
|
export type { EntryFetcher, ResolveFieldsOptions } from "./resolve.ts";
|
|
31
|
-
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor,
|
|
31
|
+
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.ts";
|
|
32
32
|
export type { LocaleSet, PathTarget, PathRow, PageLike, EntryLike, PageTreeNode, PagePathOptions } from "./paths.ts";
|
|
33
|
-
export {
|
|
34
|
-
export type { PageStore, PageRecord, PageVersion, PageStoreOptions,
|
|
35
|
-
export { pathOptions, pageErrors,
|
|
33
|
+
export { fixedPathFor } from "./localize.ts";
|
|
34
|
+
export type { PageStore, PageRecord, PageLocale, PageVersion, PageStoreOptions, CreatePageInput, AddLocaleOptions } from "./store.ts";
|
|
35
|
+
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.ts";
|
|
36
36
|
export type { SmoodlyConfig, ResolvedConfig } from "./config.ts";
|
|
37
37
|
export type { FieldBuilder, Descriptor, ValueOf } from "./fields.ts";
|
|
38
38
|
export type { SectionSchema, ElementSchema, BuilderMap } from "./schema.ts";
|
package/dist/index.js
CHANGED
|
@@ -18,12 +18,12 @@ export { MemoryPathIndex } from "./path-index.js";
|
|
|
18
18
|
export { MemoryEntryStore } from "./entry-store.js";
|
|
19
19
|
export { SupabaseEntryStore } from "./supabase-entry-store.js";
|
|
20
20
|
export { SupabasePathIndex } from "./supabase-path-index.js";
|
|
21
|
-
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES } from "./entry-store.js";
|
|
21
|
+
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.js";
|
|
22
22
|
export { affectedTargets, pageTag, entryTag } from "./revalidate.js";
|
|
23
23
|
export { resolveTree, resolveFields } from "./resolve.js";
|
|
24
|
-
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor,
|
|
25
|
-
export {
|
|
26
|
-
export { pathOptions, pageErrors,
|
|
24
|
+
export { SEGMENT_RE, isSegment, assertSegment, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, } from "./paths.js";
|
|
25
|
+
export { fixedPathFor } from "./localize.js";
|
|
26
|
+
export { pathOptions, pageErrors, localeRow, pageRefEdges, DEFAULT_LOCALES } from "./store.js";
|
|
27
27
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.js";
|
|
28
28
|
export const smoodly = {
|
|
29
29
|
schema,
|
package/dist/localize.d.ts
CHANGED
|
@@ -1,15 +1,4 @@
|
|
|
1
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
2
|
/** The path a FIXED page registration is served at in one locale — the
|
|
14
3
|
* escape hatch's addressing (spec §5: renderSmoodlyPage). Home is judged
|
|
15
4
|
* by the default-locale segment, exactly as the stores judge the record. */
|
package/dist/localize.js
CHANGED
|
@@ -1,29 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import {
|
|
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
|
-
}
|
|
1
|
+
// The fixed-page path derivation — the escape hatch's addressing (spec
|
|
2
|
+
// 2026-09-05 §5). Entries and pages alike have no field fallback: their
|
|
3
|
+
// locale rows own their fields, titles and slugs (spec 2026-09-06). This
|
|
4
|
+
// module owns nothing but fixedPathFor now.
|
|
5
|
+
import { fixedPageSegments } from "./paths.js";
|
|
27
6
|
/** The path a FIXED page registration is served at in one locale — the
|
|
28
7
|
* escape hatch's addressing (spec §5: renderSmoodlyPage). Home is judged
|
|
29
8
|
* by the default-locale segment, exactly as the stores judge the record. */
|
|
@@ -31,12 +10,15 @@ export function fixedPathFor(schema, locale, set, homePageSlug) {
|
|
|
31
10
|
if (schema.slug === undefined) {
|
|
32
11
|
throw new Error(`smoodly: page registration "${schema.name}" has no fixed slug — open pages are served by renderSmoodlyPath.`);
|
|
33
12
|
}
|
|
34
|
-
|
|
13
|
+
// A registration declares its slugs in code, so a locale it omits means
|
|
14
|
+
// "the same segment", not "absent" — paths.ts's fixedPageSegments fills
|
|
15
|
+
// it, the same fill the boot check and the materializer use.
|
|
16
|
+
const segments = fixedPageSegments(schema.slug, set);
|
|
17
|
+
const slug = segments[set.default];
|
|
35
18
|
if (slug === undefined) {
|
|
36
19
|
throw new Error(`smoodly: page registration "${schema.name}" declares a per-locale slug without the default locale "${set.default}".`);
|
|
37
20
|
}
|
|
38
21
|
if (slug === homePageSlug)
|
|
39
22
|
return "/";
|
|
40
|
-
|
|
41
|
-
return `/${segmentFor({ slug, i18n }, locale, set)}`;
|
|
23
|
+
return `/${segments[locale] ?? slug}`;
|
|
42
24
|
}
|