smoodly 0.0.13 → 0.0.14

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 (58) hide show
  1. package/README.md +75 -15
  2. package/dist/admin/editor/EditorView.js +12 -10
  3. package/dist/admin/editor/PageInspector.d.ts +21 -0
  4. package/dist/admin/editor/PageInspector.js +24 -0
  5. package/dist/admin/editor/PageSettings.d.ts +1 -3
  6. package/dist/admin/editor/PageSettings.js +7 -6
  7. package/dist/admin/editor/SeoSettings.d.ts +7 -0
  8. package/dist/admin/editor/SeoSettings.js +15 -0
  9. package/dist/admin/forms/FieldWidget.d.ts +1 -1
  10. package/dist/admin/forms/FieldWidget.js +24 -4
  11. package/dist/admin/ops-impl.js +72 -11
  12. package/dist/admin/serialize.d.ts +4 -0
  13. package/dist/admin/serialize.js +2 -0
  14. package/dist/admin/tree-ops.d.ts +2 -0
  15. package/dist/admin/tree-ops.js +5 -1
  16. package/dist/admin/ui/primitives.d.ts +9 -0
  17. package/dist/admin/ui/primitives.js +6 -0
  18. package/dist/admin/ui/resize.d.ts +1 -1
  19. package/dist/admin/ui/resize.js +1 -1
  20. package/dist/admin/ui/theme.js +16 -1
  21. package/dist/admin/validate.js +24 -3
  22. package/dist/collections.d.ts +6 -5
  23. package/dist/entry-store.d.ts +18 -1
  24. package/dist/entry-store.js +27 -2
  25. package/dist/field-defaults.d.ts +8 -0
  26. package/dist/field-defaults.js +18 -0
  27. package/dist/fields.d.ts +26 -1
  28. package/dist/fields.js +31 -1
  29. package/dist/index.d.ts +8 -6
  30. package/dist/index.js +5 -3
  31. package/dist/next/index.d.ts +2 -0
  32. package/dist/next/index.js +1 -0
  33. package/dist/next/meta.d.ts +7 -3
  34. package/dist/next/meta.js +18 -9
  35. package/dist/next/page-renderer.js +1 -1
  36. package/dist/next/queries.d.ts +36 -0
  37. package/dist/next/queries.js +87 -0
  38. package/dist/page-tree.d.ts +3 -0
  39. package/dist/page-tree.js +6 -0
  40. package/dist/page.d.ts +11 -3
  41. package/dist/page.js +26 -0
  42. package/dist/pairing.d.ts +2 -1
  43. package/dist/pairing.js +2 -9
  44. package/dist/render.d.ts +14 -7
  45. package/dist/render.js +2 -2
  46. package/dist/resolve.d.ts +4 -0
  47. package/dist/resolve.js +8 -0
  48. package/dist/revalidate.d.ts +6 -0
  49. package/dist/revalidate.js +6 -0
  50. package/dist/shared.d.ts +7 -7
  51. package/dist/site.d.ts +23 -1
  52. package/dist/site.js +83 -19
  53. package/dist/store.d.ts +4 -0
  54. package/dist/store.js +9 -0
  55. package/dist/supabase-entry-store.js +32 -10
  56. package/dist/supabase-store.d.ts +1 -0
  57. package/dist/supabase-store.js +13 -0
  58. package/package.json +1 -1
package/dist/site.js CHANGED
@@ -1,8 +1,9 @@
1
- import { entryFieldsIn, schemaOf } from "./entry-store.js";
1
+ import { ARRAY_FIELD_TYPES, entryFieldsIn, localizedKeys, schemaOf } from "./entry-store.js";
2
2
  import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
3
3
  import { resolveFields, resolveMedia, resolveMediaSets, resolveTree } from "./resolve.js";
4
4
  import { elementSchemas } from "./richtext-walk.js";
5
5
  import { STYLES_KEY } from "./shared.js";
6
+ import { withDefaults } from "./field-defaults.js";
6
7
  /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
7
8
  * bound to the render locale; the default keeps CMS paths as URLs. */
8
9
  export function pageContextOf(hit, toUrl = (p) => p) {
@@ -12,6 +13,7 @@ export function pageContextOf(hit, toUrl = (p) => p) {
12
13
  title: hit.title,
13
14
  path: hit.path,
14
15
  url: toUrl(hit.path),
16
+ fields: hit.fields,
15
17
  ancestors: hit.ancestors.map(withUrl),
16
18
  children: hit.children.map(withUrl),
17
19
  };
@@ -46,11 +48,43 @@ export function createSmoodlySite(options) {
46
48
  const row = node.locales[locale];
47
49
  return node.template !== null && row !== undefined && (draft || row.status === "published");
48
50
  };
51
+ const pageFields = (template) => {
52
+ if (template === null)
53
+ return {};
54
+ const p = (options.pages ?? []).find((x) => x.schema.kind === "page" && x.schema.name === template);
55
+ return p?.schema.fields ?? {};
56
+ };
57
+ /** The version whose fields a link shows: the draft's in draft mode, else the published one. */
58
+ const versionFor = (node, locale, draft) => {
59
+ const row = node.locales[locale];
60
+ if (!row || node.template === null)
61
+ return null;
62
+ return draft ? row.draftVersionId : row.publishedVersionId;
63
+ };
64
+ /** Raw page fields (no asset resolution) of many nodes in ONE store read
65
+ * (spec 2026-09-10 page fields §3), defaults applied; `{}` for a folder. */
66
+ async function linkFields(nodes, locale, draft) {
67
+ const { pages } = options.stores();
68
+ const wanted = new Map(); // node id → version id
69
+ for (const n of nodes) {
70
+ const v = versionFor(n, locale, draft);
71
+ if (v)
72
+ wanted.set(n.id, v);
73
+ }
74
+ const byVersion = wanted.size > 0 ? await pages.fieldsOf([...new Set(wanted.values())]) : {};
75
+ const out = new Map();
76
+ for (const n of nodes) {
77
+ const v = wanted.get(n.id);
78
+ out.set(n.id, n.template === null ? {} : withDefaults(v ? byVersion[v] : undefined, pageFields(n.template)));
79
+ }
80
+ return out;
81
+ }
49
82
  /** Only ever called for a node PRESENT in the locale (visible() or an ancestor of one). */
50
- const link = (node, byId, locale, draft) => ({
83
+ const link = (node, byId, locale, draft, fields) => ({
51
84
  title: node.locales[locale].title,
52
85
  path: pagePath(node, byId, locale, pathOpts),
53
86
  hasPage: hasPage(node, locale, draft),
87
+ fields: fields.get(node.id) ?? {},
54
88
  });
55
89
  async function loadPage(id, locale, path, draft) {
56
90
  const { pages } = options.stores();
@@ -70,15 +104,16 @@ export function createSmoodlySite(options) {
70
104
  elements: options.elements,
71
105
  fetch: fetcher(draft, locale),
72
106
  assets: assetFetcher(),
107
+ page: { fields: pageFields(record.template) },
73
108
  });
74
109
  const all = await pages.listPages();
75
110
  const index = new Map(all.map((n) => [n.id, n]));
76
111
  const byId = (x) => index.get(x);
77
112
  const chain = chainOf(record, byId);
78
- const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft));
79
- const children = all
80
- .filter((n) => n.parentId === id && visible(n, locale, draft))
81
- .map((n) => link(n, byId, locale, draft));
113
+ const kids = all.filter((n) => n.parentId === id && visible(n, locale, draft));
114
+ const fields = await linkFields([...chain.slice(0, -1), ...kids], locale, draft);
115
+ const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft, fields));
116
+ const children = kids.map((n) => link(n, byId, locale, draft, fields));
82
117
  // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
83
118
  const paths = Object.fromEntries(Object.entries(await pages.pathsOf(id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
84
119
  return {
@@ -88,6 +123,7 @@ export function createSmoodlySite(options) {
88
123
  tree,
89
124
  title: row.title,
90
125
  path,
126
+ fields: withDefaults(tree.fields, pageFields(record.template)),
91
127
  ancestors,
92
128
  children,
93
129
  paths,
@@ -131,28 +167,55 @@ export function createSmoodlySite(options) {
131
167
  path: entryPathRows([record], byId, schema, locales).find((r) => r.locale === locale)?.path ?? null,
132
168
  }));
133
169
  }
134
- /** Public in a locale: its row exists there and is published. */
135
- const publicEntry = (e, locale) => e.locales[locale]?.status === "published";
136
170
  async function getEntries(collection, o = {}) {
137
171
  const schema = schemaOf(options.collections, collection);
138
172
  const locale = localeOf(o);
173
+ const draft = o.draft ?? false;
139
174
  const { entries } = options.stores();
140
- const all = await entries.list(collection, { order: o.order });
141
- const wanted = all.filter((e) => publicEntry(e, locale) && (o.parent === undefined || e.parentId === o.parent));
142
- const published = await entries.getMany(collection, wanted.map((e) => e.id), locale, { status: "published" });
175
+ // A filter that silently matched nothing would be worse than an error (spec 2026-09-11 §3).
176
+ for (const key of Object.keys(o.where ?? {})) {
177
+ if (key === "slug" || localizedKeys(schema).includes(key)) {
178
+ throw new Error(`smoodly: getEntries("${collection}"): "${key}" is localized — where filters shared fields only (spec 2026-09-11 §3).`);
179
+ }
180
+ if (!(key in schema.fields))
181
+ throw new Error(`smoodly: getEntries("${collection}"): "${key}" is not a field of the collection.`);
182
+ // Any-of reads the text form of the whole field (PostgREST's `->>`), which for
183
+ // an array is its JSON — `["a"]` never matches "a" — so refuse rather than match nothing.
184
+ if (Array.isArray(o.where[key]) && ARRAY_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
185
+ throw new Error(`smoodly: getEntries("${collection}"): "${key}" is array-valued — any-of is for scalar fields; pass one value to match entries containing it.`);
186
+ }
187
+ }
188
+ if (o.limit !== undefined && (!Number.isInteger(o.limit) || o.limit < 0)) {
189
+ throw new Error(`smoodly: getEntries("${collection}"): limit must be a non-negative integer.`);
190
+ }
191
+ // The store filters, orders and limits (spec 2026-09-11 §3): the locale
192
+ // gate — presence in draft mode, published otherwise — is part of the
193
+ // query, so `limit` counts rows that are really there.
194
+ const inLocale = await entries.list(collection, {
195
+ order: o.order, where: o.where, limit: o.limit,
196
+ ...(o.parent !== undefined ? { parent: o.parent } : {}),
197
+ ...(draft ? { locale } : { status: "published", locale }),
198
+ });
199
+ // Paths need every ancestor: a tree collection reads the whole collection for that, a flat one does not.
200
+ const all = schema.tree ? await entries.list(collection) : inLocale;
201
+ const fieldsById = draft
202
+ ? Object.fromEntries(inLocale.map((e) => [e.id, entryFieldsIn(e, locale)]))
203
+ : await entries.getMany(collection, inLocale.map((e) => e.id), locale, { status: "published" });
143
204
  // Refs stay ids on the content API; media resolves (one asset round for
144
205
  // the whole list), because a list page's cards need their image URLs.
145
206
  const fetchAssets = assetFetcher();
146
207
  if (fetchAssets)
147
- await resolveMediaSets(Object.values(published).map((values) => ({ values, descriptors: schema.fields })), fetchAssets, elements);
148
- return entryItems(schema, wanted, all, locale, published);
208
+ await resolveMediaSets(Object.values(fieldsById).map((values) => ({ values, descriptors: schema.fields })), fetchAssets, elements);
209
+ return entryItems(schema, inLocale, all, locale, fieldsById);
149
210
  }
150
211
  const api = {
151
212
  async getPagesTree(o) {
152
213
  const locale = localeOf(o);
214
+ const draft = o?.draft ?? false;
153
215
  const all = await options.stores().pages.listPages();
154
- const shown = all.filter((n) => visible(n, locale, false));
216
+ const shown = all.filter((n) => visible(n, locale, draft));
155
217
  const index = new Map(shown.map((n) => [n.id, n]));
218
+ const fields = await linkFields(shown, locale, draft);
156
219
  const toItem = (node) => ({
157
220
  id: node.record.id,
158
221
  title: node.record.locales[locale].title,
@@ -160,7 +223,8 @@ export function createSmoodlySite(options) {
160
223
  // buildPageTree's nullable path is never null here; pagePath is
161
224
  // the same derivation and keeps the type honest.
162
225
  path: node.path ?? pagePath(node.record, (x) => index.get(x), locale, pathOpts),
163
- hasPage: hasPage(node.record, locale, false),
226
+ hasPage: hasPage(node.record, locale, draft),
227
+ fields: fields.get(node.record.id) ?? {},
164
228
  children: node.children.map(toItem),
165
229
  });
166
230
  return buildPageTree(shown, locale, pathOpts).map(toItem);
@@ -170,7 +234,7 @@ export function createSmoodlySite(options) {
170
234
  const path = pathOrId.startsWith("/") ? pathOrId : (await options.stores().pages.pathsOf(pathOrId))[locale];
171
235
  if (!path)
172
236
  return null;
173
- const hit = await site.resolve(locale, path);
237
+ const hit = await site.resolve(locale, path, { draft: o?.draft ?? false });
174
238
  return hit?.kind === "page" ? hit : null;
175
239
  },
176
240
  getEntries,
@@ -178,10 +242,10 @@ export function createSmoodlySite(options) {
178
242
  schemaOf(options.collections, collection); // an unknown collection is a wiring error on every branch
179
243
  const locale = localeOf(o);
180
244
  if (idOrPath.startsWith("/")) {
181
- const hit = await site.resolve(locale, idOrPath);
245
+ const hit = await site.resolve(locale, idOrPath, { draft: o?.draft ?? false });
182
246
  return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path } : null;
183
247
  }
184
- const items = await getEntries(collection, { locale });
248
+ const items = await getEntries(collection, { locale, draft: o?.draft ?? false });
185
249
  const byId = items.find((i) => i.entry.id === idOrPath);
186
250
  if (byId)
187
251
  return byId;
@@ -191,7 +255,7 @@ export function createSmoodlySite(options) {
191
255
  return items.find((i) => i.entry.parentId === null && entrySegment(i.entry, locale) === idOrPath) ?? null;
192
256
  },
193
257
  async getEntriesTree(collection, o) {
194
- const items = await getEntries(collection, { locale: localeOf(o) });
258
+ const items = await getEntries(collection, { locale: localeOf(o), draft: o?.draft ?? false });
195
259
  const build = (parentId) => items.filter((i) => i.entry.parentId === parentId).map((i) => ({ ...i, children: build(i.entry.id) }));
196
260
  return build(null);
197
261
  },
package/dist/store.d.ts CHANGED
@@ -78,6 +78,9 @@ export interface PageStore {
78
78
  publish(id: string, locale: string): Promise<PageRecord>;
79
79
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
80
80
  getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
81
+ /** The page fields of many versions in ONE read (spec 2026-09-10 page fields §3):
82
+ * versionId → `tree.fields ?? {}`; ids that do not exist are absent. */
83
+ fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
81
84
  listVersions(id: string, locale: string): Promise<PageVersion[]>;
82
85
  deletePage(id: string): Promise<void>;
83
86
  }
@@ -179,6 +182,7 @@ export declare class MemoryPageStore implements PageStore {
179
182
  saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
180
183
  publish(id: string, locale: string): Promise<PageRecord>;
181
184
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
185
+ fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
182
186
  getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
183
187
  listVersions(id: string, locale: string): Promise<PageVersion[]>;
184
188
  deletePage(id: string): Promise<void>;
package/dist/store.js CHANGED
@@ -410,6 +410,15 @@ export class MemoryPageStore {
410
410
  return null;
411
411
  return structuredClone(this.versions.get(row.draftVersionId)?.tree ?? null);
412
412
  }
413
+ async fieldsOf(versionIds) {
414
+ const out = {};
415
+ for (const id of versionIds) {
416
+ const v = this.versions.get(id);
417
+ if (v)
418
+ out[id] = structuredClone(v.tree.fields ?? {});
419
+ }
420
+ return out;
421
+ }
413
422
  async getPublishedTree(id, locale) {
414
423
  const row = this.pages.get(id)?.locales[locale];
415
424
  if (!row?.publishedVersionId)
@@ -7,7 +7,7 @@
7
7
  // calls the smoodly_reorder SQL function: one statement, updated_at
8
8
  // untouched.
9
9
  import { collectionDepth, collectionOrder } from "./collections.js";
10
- import { DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isSharedSchema, schemaOf, splitFields, } from "./entry-store.js";
10
+ import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isSharedSchema, schemaOf, splitFields, } from "./entry-store.js";
11
11
  import { sharedEntryId } from "./shared-id.js";
12
12
  import { SupabasePathIndex } from "./supabase-path-index.js";
13
13
  import { assertDepth, assertSupportedLocale, entryPathRows, slugOf } from "./paths.js";
@@ -316,10 +316,37 @@ export class SupabaseEntryStore {
316
316
  }
317
317
  async list(collection, options) {
318
318
  const schema = this.schema(collection);
319
- let query = this.db.from("entries").select(SELECT).eq("collection", collection);
319
+ // A second, aliased embed of entry_locales carries the locale gate as an
320
+ // inner join — presence in the locale, and its status when asked — so
321
+ // `limit` counts rows that are really there; the first embed still returns
322
+ // EVERY locale row of each record (spec 2026-09-11 §3), ordered by locale
323
+ // so a record's key order never depends on physical row order.
324
+ const gate = options?.status !== undefined || options?.locale !== undefined;
325
+ let query = this.db
326
+ .from("entries")
327
+ .select(gate ? `${SELECT}, gate:entry_locales!inner(locale)` : SELECT)
328
+ .eq("collection", collection)
329
+ .order("locale", { referencedTable: "entry_locales", ascending: true });
330
+ if (gate) {
331
+ query = query.eq("gate.locale", options.locale ?? this.locales.default);
332
+ if (options.status !== undefined)
333
+ query = query.eq("gate.status", options.status);
334
+ }
320
335
  if (options?.parent !== undefined) {
321
336
  query = options.parent === null ? query.is("parent_id", null) : query.eq("parent_id", options.parent);
322
337
  }
338
+ for (const [key, want] of Object.entries(options?.where ?? {})) {
339
+ // Any-of compares the text form (`->>`). A scalar is JSONB containment;
340
+ // an array-valued field (checkboxes, refList, list) contains `[value]`,
341
+ // a scalar field `value` — the memory adapter's matchesWhere agrees.
342
+ if (Array.isArray(want)) {
343
+ query = query.in(`fields->>${key}`, want.map(String));
344
+ }
345
+ else {
346
+ const inArray = ARRAY_FIELD_TYPES.has(schema.fields[key]?.type ?? "");
347
+ query = query.contains("fields", { [key]: inArray ? [want] : want });
348
+ }
349
+ }
323
350
  const order = options?.order ?? collectionOrder(schema);
324
351
  if (order === "manual") {
325
352
  query = query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true });
@@ -330,17 +357,12 @@ export class SupabaseEntryStore {
330
357
  const column = order.by === "createdAt" ? "created_at" : order.by === "updatedAt" ? "updated_at" : `fields->${order.by}`;
331
358
  query = query.order(column, { ascending: order.direction === "asc", nullsFirst: false }).order("id", { ascending: true });
332
359
  }
360
+ if (typeof options?.limit === "number")
361
+ query = query.limit(options.limit);
333
362
  const res = await query;
334
363
  if (res.error)
335
364
  this.fail(res.error.message);
336
- let rows = res.data.map(toRecord);
337
- // The status filter reads the locale row; collections are small enough
338
- // to filter here rather than lose the other rows to an !inner embed.
339
- if (options?.status) {
340
- const locale = options.locale ?? this.locales.default;
341
- rows = rows.filter((e) => e.locales[locale]?.status === options.status);
342
- }
343
- return rows;
365
+ return res.data.map(({ gate: _gate, ...row }) => toRecord(row));
344
366
  }
345
367
  async getMany(collection, ids, locale, options) {
346
368
  this.schema(collection);
@@ -62,6 +62,7 @@ export declare class SupabasePageStore implements PageStore {
62
62
  saveDraft(id: string, locale: string, tree: PageTree): Promise<PageVersion>;
63
63
  publish(id: string, locale: string): Promise<PageRecord>;
64
64
  getDraftTree(id: string, locale: string): Promise<PageTree | null>;
65
+ fieldsOf(versionIds: string[]): Promise<Record<string, Record<string, unknown>>>;
65
66
  getPublishedTree(id: string, locale: string): Promise<PageTree | null>;
66
67
  listVersions(id: string, locale: string): Promise<PageVersion[]>;
67
68
  deletePage(id: string): Promise<void>;
@@ -453,6 +453,19 @@ export class SupabasePageStore {
453
453
  const page = await this.row(id);
454
454
  return page ? this.versionTree(page.locales[locale]?.draftVersionId ?? null) : null;
455
455
  }
456
+ async fieldsOf(versionIds) {
457
+ const ids = versionIds.filter((id) => UUID_RE.test(id));
458
+ if (ids.length === 0)
459
+ return {};
460
+ // PostgREST's JSON arrow selects the `fields` key alone — the rest of the tree never leaves the database.
461
+ const res = await this.db.from("page_versions").select("id, fields:tree->fields").in("id", ids);
462
+ if (res.error)
463
+ this.fail(res.error.message);
464
+ const out = {};
465
+ for (const row of res.data)
466
+ out[row.id] = row.fields ?? {};
467
+ return out;
468
+ }
456
469
  async getPublishedTree(id, locale) {
457
470
  const page = await this.row(id);
458
471
  return page ? this.versionTree(page.locales[locale]?.publishedVersionId ?? null) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {