smoodly 0.0.4 → 0.0.6

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