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