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.
Files changed (70) hide show
  1. package/README.md +318 -74
  2. package/dist/admin/client-ops.js +3 -2
  3. package/dist/admin/editor/EditorView.js +229 -30
  4. package/dist/admin/editor/PageSettings.d.ts +8 -4
  5. package/dist/admin/editor/PageSettings.js +25 -25
  6. package/dist/admin/fixed-nodes.d.ts +16 -10
  7. package/dist/admin/fixed-nodes.js +55 -41
  8. package/dist/admin/index.d.ts +3 -2
  9. package/dist/admin/index.js +1 -0
  10. package/dist/admin/ops-impl.js +218 -36
  11. package/dist/admin/ops.d.ts +73 -12
  12. package/dist/admin/serialize.d.ts +11 -0
  13. package/dist/admin/serialize.js +8 -0
  14. package/dist/admin/shared-items.d.ts +9 -0
  15. package/dist/admin/shared-items.js +61 -0
  16. package/dist/admin/shell/AdminApp.js +11 -34
  17. package/dist/admin/shell/EntriesList.d.ts +7 -0
  18. package/dist/admin/shell/EntriesList.js +95 -0
  19. package/dist/admin/shell/EntryForm.js +167 -49
  20. package/dist/admin/shell/PagesList.js +86 -26
  21. package/dist/admin/shell/SharedForm.d.ts +7 -0
  22. package/dist/admin/shell/SharedForm.js +146 -0
  23. package/dist/admin/shell/SharedList.d.ts +6 -0
  24. package/dist/admin/shell/SharedList.js +62 -0
  25. package/dist/admin/shell/entries-list.d.ts +21 -0
  26. package/dist/admin/shell/entries-list.js +36 -0
  27. package/dist/admin/shell/pages-tree.d.ts +30 -12
  28. package/dist/admin/shell/pages-tree.js +48 -14
  29. package/dist/admin/shell/shared-list.d.ts +14 -0
  30. package/dist/admin/shell/shared-list.js +17 -0
  31. package/dist/admin/tree-ops.d.ts +12 -5
  32. package/dist/admin/tree-ops.js +39 -8
  33. package/dist/admin/ui/LocaleSwitcher.d.ts +11 -0
  34. package/dist/admin/ui/LocaleSwitcher.js +15 -0
  35. package/dist/collections.d.ts +10 -1
  36. package/dist/collections.js +16 -0
  37. package/dist/config.d.ts +3 -0
  38. package/dist/config.js +58 -4
  39. package/dist/entry-store.d.ts +141 -55
  40. package/dist/entry-store.js +317 -87
  41. package/dist/index.d.ts +11 -7
  42. package/dist/index.js +8 -5
  43. package/dist/localize.d.ts +0 -11
  44. package/dist/localize.js +11 -29
  45. package/dist/next/page-renderer.d.ts +4 -0
  46. package/dist/next/page-renderer.js +14 -1
  47. package/dist/page.js +5 -1
  48. package/dist/paths.d.ts +46 -27
  49. package/dist/paths.js +62 -29
  50. package/dist/refs.js +8 -0
  51. package/dist/resolve.d.ts +3 -0
  52. package/dist/resolve.js +38 -0
  53. package/dist/revalidate.d.ts +4 -0
  54. package/dist/revalidate.js +6 -1
  55. package/dist/shared.d.ts +46 -0
  56. package/dist/shared.js +36 -0
  57. package/dist/site.d.ts +16 -2
  58. package/dist/site.js +92 -39
  59. package/dist/sql-space.d.ts +1 -1
  60. package/dist/sql-space.js +16 -13
  61. package/dist/sql.js +95 -70
  62. package/dist/store.d.ts +66 -32
  63. package/dist/store.js +205 -90
  64. package/dist/supabase-entry-store.d.ts +30 -9
  65. package/dist/supabase-entry-store.js +360 -178
  66. package/dist/supabase-store.d.ts +21 -9
  67. package/dist/supabase-store.js +202 -111
  68. package/dist/zones.d.ts +6 -2
  69. package/dist/zones.js +8 -2
  70. package/package.json +1 -1
@@ -1,55 +1,116 @@
1
1
  // The collection-entry persistence contract — the entries counterpart
2
- // to PageStore. Writes always flow through this API (never raw table
3
- // access), which is what lets the refs index stay correct: every save
4
- // rewrites the entry's outgoing edges (collectEntryRefs), so
5
- // referencesTo/safe-delete answer from the index alone. Status-only
6
- // versioning in v1 (DESIGN.md §3). Reorder rewrites sort 1..N in one
7
- // pass and NEVER touches updatedAt order is a property of the
8
- // collection, not an edit to its entries.
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 = () => `entry-${++counter}-${Date.now().toString(36)}`;
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 (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. */
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 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).
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`. 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.
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.seq = 0; // creation-order tiebreak (Date.now can tie)
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
- 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));
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(collection, id, fields) {
113
- const schema = schemaOf(this.collections, collection);
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 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);
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 (slugOf(e.fields) === slug)
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(schemaOf(this.collections, collection)), "the entry");
251
+ assertDepth(chainOf(parent, this.byId).length + subtreeHeight, collectionDepth(this.schema(collection)), "the entry");
252
+ return parent;
143
253
  }
144
- async create(collection, fields, options = {}) {
145
- schemaOf(this.collections, collection);
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
- this.assertParent(collection, parentId, 1);
148
- this.assertSiblingSlug(collection, parentId, fields);
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
- fields: structuredClone(fields), i18n: null,
153
- status: "draft", sort: null, locales: options.locales ?? null,
154
- createdAt: now, updatedAt: now,
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(collection, entry.id, fields);
167
- return structuredClone(entry);
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 ? structuredClone(entry) : null;
298
+ return entry ? this.snapshot(entry) : null;
172
299
  }
173
300
  async list(collection, options) {
174
- const schema = schemaOf(this.collections, collection);
301
+ const schema = this.schema(collection);
175
302
  let rows = [...this.entries.values()].filter((e) => e.collection === collection);
176
- if (options?.status)
177
- rows = rows.filter((e) => e.status === options.status);
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) => structuredClone(e));
310
+ return rows.map((e) => this.snapshot(e));
182
311
  }
183
- async getMany(collection, ids, options) {
184
- schemaOf(this.collections, collection);
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
- if (!entry)
189
- continue;
190
- if (options?.status && entry.status !== options.status)
317
+ const row = entry?.locales[locale];
318
+ if (!entry || !row)
191
319
  continue;
192
- out[id] = structuredClone(entry.fields);
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
- this.assertSiblingSlug(collection, entry.parentId, fields, id);
199
- const before = entry.fields;
200
- entry.fields = structuredClone(fields);
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
- entry.updatedAt = Date.now() > entry.updatedAt ? Date.now() : entry.updatedAt + 1;
209
- this.writeRefs(collection, id, fields);
210
- return structuredClone(entry);
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.row(collection, id);
214
- if (!entry)
215
- throw entryErrors.notFound(collection, id);
216
- entry.status = status;
217
- entry.updatedAt = Date.now() > entry.updatedAt ? Date.now() : entry.updatedAt + 1;
218
- return structuredClone(entry);
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
- if (to.parentId !== entry.parentId)
228
- this.assertSiblingSlug(collection, to.parentId, entry.fields, id);
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 structuredClone(entry);
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
- schemaOf(this.collections, collection);
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, localesOf, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, } from "./paths.ts";
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 { 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";
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, 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";
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";
@@ -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. */