smoodly 0.0.16 → 0.0.18

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 (142) hide show
  1. package/README.md +176 -26
  2. package/dist/admin/client-ops.js +1 -1
  3. package/dist/admin/custom-section.d.ts +10 -0
  4. package/dist/admin/custom-section.js +10 -0
  5. package/dist/admin/editor/CanvasOverlay.d.ts +4 -4
  6. package/dist/admin/editor/CanvasOverlay.js +8 -6
  7. package/dist/admin/editor/EditorView.js +140 -108
  8. package/dist/admin/editor/GlobalInspector.d.ts +27 -0
  9. package/dist/admin/editor/GlobalInspector.js +13 -0
  10. package/dist/admin/editor/InsertPicker.d.ts +6 -5
  11. package/dist/admin/editor/InsertPicker.js +6 -4
  12. package/dist/admin/editor/InspectorFields.d.ts +21 -0
  13. package/dist/admin/editor/InspectorFields.js +22 -0
  14. package/dist/admin/editor/Outline.d.ts +0 -1
  15. package/dist/admin/editor/Outline.js +3 -3
  16. package/dist/admin/editor/{shared-edit.d.ts → global-edit.d.ts} +10 -10
  17. package/dist/admin/editor/{shared-edit.js → global-edit.js} +4 -4
  18. package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
  19. package/dist/admin/editor/{useSharedEdit.js → useGlobalEdit.js} +4 -4
  20. package/dist/admin/forms/FieldWidget.d.ts +14 -1
  21. package/dist/admin/forms/FieldWidget.js +38 -11
  22. package/dist/admin/forms/ListField.d.ts +9 -1
  23. package/dist/admin/forms/ListField.js +16 -8
  24. package/dist/admin/forms/RichtextElementCard.js +11 -5
  25. package/dist/admin/forms/RowsWidget.d.ts +13 -0
  26. package/dist/admin/forms/RowsWidget.js +26 -0
  27. package/dist/admin/forms/SpanBar.d.ts +5 -0
  28. package/dist/admin/forms/SpanBar.js +37 -0
  29. package/dist/admin/forms/richtext-insert.d.ts +2 -2
  30. package/dist/admin/forms/richtext-insert.js +3 -3
  31. package/dist/admin/forms/richtext-nodes.js +1 -1
  32. package/dist/admin/forms/tabs.d.ts +15 -9
  33. package/dist/admin/forms/tabs.js +19 -23
  34. package/dist/admin/forms/useRichtextEditor.js +8 -1
  35. package/dist/admin/global-items.d.ts +9 -0
  36. package/dist/admin/{shared-items.js → global-items.js} +17 -16
  37. package/dist/admin/index.d.ts +3 -3
  38. package/dist/admin/index.js +1 -1
  39. package/dist/admin/ops-impl.js +65 -54
  40. package/dist/admin/ops.d.ts +6 -6
  41. package/dist/admin/serialize.d.ts +18 -6
  42. package/dist/admin/serialize.js +27 -4
  43. package/dist/admin/shell/AdminApp.js +6 -6
  44. package/dist/admin/shell/GlobalFields.d.ts +24 -0
  45. package/dist/admin/shell/GlobalFields.js +19 -0
  46. package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
  47. package/dist/admin/shell/{SharedForm.js → GlobalForm.js} +36 -26
  48. package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
  49. package/dist/admin/shell/{SharedList.js → GlobalList.js} +9 -9
  50. package/dist/admin/shell/IconRail.js +13 -6
  51. package/dist/admin/shell/PagesList.js +1 -6
  52. package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
  53. package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
  54. package/dist/admin/tree-ops.d.ts +10 -8
  55. package/dist/admin/tree-ops.js +28 -16
  56. package/dist/admin/ui/ActionMenu.d.ts +3 -1
  57. package/dist/admin/ui/ActionMenu.js +4 -3
  58. package/dist/admin/ui/HistoryModal.js +1 -1
  59. package/dist/admin/ui/PublishControl.d.ts +3 -1
  60. package/dist/admin/ui/PublishControl.js +2 -2
  61. package/dist/admin/ui/primitives.d.ts +4 -2
  62. package/dist/admin/ui/primitives.js +3 -3
  63. package/dist/admin/ui/theme.js +61 -18
  64. package/dist/admin/validate.d.ts +27 -2
  65. package/dist/admin/validate.js +47 -10
  66. package/dist/collections.d.ts +3 -3
  67. package/dist/collections.js +1 -1
  68. package/dist/conditions.d.ts +33 -0
  69. package/dist/conditions.js +79 -0
  70. package/dist/config.d.ts +26 -19
  71. package/dist/config.js +26 -25
  72. package/dist/css.d.ts +7 -0
  73. package/dist/css.js +57 -0
  74. package/dist/custom/CustomSection.d.ts +26 -0
  75. package/dist/custom/CustomSection.js +20 -0
  76. package/dist/custom/css.d.ts +3 -0
  77. package/dist/custom/css.js +28 -0
  78. package/dist/custom/index.d.ts +26 -0
  79. package/dist/custom/index.js +16 -0
  80. package/dist/custom/rows.d.ts +28 -0
  81. package/dist/custom/rows.js +129 -0
  82. package/dist/custom/schema.d.ts +32 -0
  83. package/dist/custom/schema.js +39 -0
  84. package/dist/entry-page.d.ts +5 -0
  85. package/dist/entry-page.js +1 -1
  86. package/dist/entry-store.d.ts +4 -4
  87. package/dist/entry-store.js +17 -17
  88. package/dist/fields.d.ts +5 -2
  89. package/dist/fields.js +4 -2
  90. package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
  91. package/dist/{shared-id.js → global-id.js} +4 -4
  92. package/dist/global.d.ts +57 -0
  93. package/dist/global.js +51 -0
  94. package/dist/index.d.ts +31 -21
  95. package/dist/index.js +11 -6
  96. package/dist/media.js +1 -1
  97. package/dist/next/index.d.ts +1 -1
  98. package/dist/next/page-renderer.d.ts +4 -2
  99. package/dist/next/page-renderer.js +16 -12
  100. package/dist/next/queries.d.ts +6 -6
  101. package/dist/next/queries.js +7 -7
  102. package/dist/page.js +2 -2
  103. package/dist/pairing.d.ts +13 -7
  104. package/dist/pairing.js +4 -4
  105. package/dist/paths.d.ts +21 -0
  106. package/dist/paths.js +25 -0
  107. package/dist/refs.d.ts +2 -2
  108. package/dist/refs.js +4 -4
  109. package/dist/render.d.ts +23 -1
  110. package/dist/render.js +12 -6
  111. package/dist/resolve.d.ts +11 -6
  112. package/dist/resolve.js +22 -12
  113. package/dist/revalidate.d.ts +4 -4
  114. package/dist/revalidate.js +4 -4
  115. package/dist/richtext-render.js +2 -2
  116. package/dist/richtext-walk.d.ts +5 -5
  117. package/dist/richtext-walk.js +4 -4
  118. package/dist/schema.d.ts +15 -10
  119. package/dist/schema.js +19 -7
  120. package/dist/section-wrapper.d.ts +18 -6
  121. package/dist/section-wrapper.js +24 -17
  122. package/dist/site.d.ts +29 -14
  123. package/dist/site.js +55 -28
  124. package/dist/sql-space.js +1 -1
  125. package/dist/sql.js +2 -2
  126. package/dist/store.d.ts +7 -0
  127. package/dist/store.js +6 -0
  128. package/dist/styles.d.ts +27 -0
  129. package/dist/styles.js +60 -0
  130. package/dist/supabase-entry-store.js +14 -14
  131. package/dist/supabase-store.js +2 -1
  132. package/dist/zones.d.ts +9 -7
  133. package/dist/zones.js +5 -5
  134. package/package.json +1 -1
  135. package/dist/admin/editor/SharedInspector.d.ts +0 -23
  136. package/dist/admin/editor/SharedInspector.js +0 -13
  137. package/dist/admin/editor/useSharedEdit.d.ts +0 -6
  138. package/dist/admin/shared-items.d.ts +0 -9
  139. package/dist/admin/shell/SharedFields.d.ts +0 -21
  140. package/dist/admin/shell/SharedFields.js +0 -15
  141. package/dist/shared.d.ts +0 -53
  142. package/dist/shared.js +0 -45
package/dist/site.js CHANGED
@@ -1,26 +1,33 @@
1
1
  import { ARRAY_FIELD_TYPES, entryFieldsIn, localizedKeys, schemaOf } from "./entry-store.js";
2
- import { buildPageTree, chainOf, entryPathRows, entrySegment, pagePath, } from "./paths.js";
2
+ import { buildPageTree, chainOf, entryAlternates, entryPathRows, entrySegment, pageAlternates, pagePath, } from "./paths.js";
3
3
  import { resolveFields, resolveMedia, resolveMediaSets, resolveTree } from "./resolve.js";
4
4
  import { elementSchemas } from "./richtext-walk.js";
5
- import { sharedNodeId, STYLES_KEY } from "./shared.js";
5
+ import { globalNodeId, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
6
+ import { SMD_KEY } from "./section-wrapper.js";
7
+ import { resolveStyleValues } from "./styles.js";
8
+ import { resolveCustom } from "./custom/index.js";
6
9
  import { withDefaults } from "./field-defaults.js";
7
- /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
8
- * bound to the render locale; the default keeps CMS paths as URLs. */
9
- export function pageContextOf(hit, toUrl = (p) => p) {
10
- const withUrl = (l) => ({ ...l, url: toUrl(l.path) });
10
+ /** The `page` prop for a loaded page. `href` is the app's seam from a
11
+ * locale-relative path to a site URL; the default keeps CMS paths as URLs. */
12
+ export function pageContextOf(hit, href = (p) => p) {
13
+ const withUrl = (l) => ({ ...l, url: href(l.path, hit.locale) });
11
14
  return {
12
15
  id: hit.record.id,
13
16
  title: hit.title,
14
17
  path: hit.path,
15
- url: toUrl(hit.path),
18
+ url: href(hit.path, hit.locale),
19
+ locale: hit.locale,
16
20
  fields: hit.fields,
17
21
  ancestors: hit.ancestors.map(withUrl),
18
22
  children: hit.children.map(withUrl),
23
+ locales: Object.fromEntries(Object.entries(hit.locales).map(([code, alt]) => [code, alt && { ...alt, url: href(alt.path, code) }])),
19
24
  };
20
25
  }
21
26
  export function createSmoodlySite(options) {
22
27
  const locales = options.locales;
23
28
  const homePageSlug = options.homePageSlug ?? "home";
29
+ const styles = resolveStyleValues(options.styles);
30
+ const custom = resolveCustom(options.custom);
24
31
  const elements = elementSchemas(options.elements);
25
32
  const pathOpts = { locales, homePageSlug };
26
33
  const mounted = options.collections.filter((c) => c.path !== undefined);
@@ -33,9 +40,9 @@ export function createSmoodlySite(options) {
33
40
  const assets = options.stores().assets;
34
41
  return assets ? (ids) => assets.getMany(ids) : undefined;
35
42
  };
36
- const sectionStyles = (name) => {
43
+ const sectionSettings = (name) => {
37
44
  const s = options.sections.find((x) => ("schema" in x ? x.schema : x).name === name);
38
- return (s && ("schema" in s ? s.schema : s).styles) ?? {};
45
+ return (s && ("schema" in s ? s.schema : s).settings) ?? {};
39
46
  };
40
47
  /** Visible to the public in a locale: present there, and a folder or a
41
48
  * published page. Draft mode sees every present node. Presence is the
@@ -108,11 +115,12 @@ export function createSmoodlySite(options) {
108
115
  const tree = await resolveTree(raw, {
109
116
  sections: options.sections,
110
117
  collections: options.collections,
111
- shared: options.shared,
118
+ globals: options.globals,
112
119
  elements: options.elements,
113
120
  fetch: fetcher(draft, locale),
114
121
  assets: assetFetcher(),
115
122
  page: { fields: pageFields(record.template) },
123
+ custom,
116
124
  });
117
125
  const all = await pages.listPages();
118
126
  const index = new Map(all.map((n) => [n.id, n]));
@@ -122,10 +130,12 @@ export function createSmoodlySite(options) {
122
130
  const fields = await linkFields([...chain.slice(0, -1), ...kids], locale, draft);
123
131
  const ancestors = chain.slice(0, -1).map((n) => link(n, byId, locale, draft, fields));
124
132
  const children = kids.map((n) => link(n, byId, locale, draft, fields));
125
- // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
126
- const paths = Object.fromEntries(Object.entries(await pages.pathsOf(id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
133
+ // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present
134
+ // one. Derived from the tree already listed — no index lookup (spec 2026-09-15 §2).
135
+ const alternates = pageAlternates(record, byId, pathOpts, (l) => draft || record.locales[l]?.status === "published");
127
136
  return {
128
137
  kind: "page",
138
+ locale,
129
139
  record,
130
140
  template: record.template,
131
141
  tree,
@@ -134,7 +144,7 @@ export function createSmoodlySite(options) {
134
144
  fields: withDefaults(tree.fields, pageFields(record.template)),
135
145
  ancestors,
136
146
  children,
137
- paths,
147
+ locales: alternates,
138
148
  };
139
149
  }
140
150
  async function loadEntry(id, locale, path, draft) {
@@ -156,8 +166,11 @@ export function createSmoodlySite(options) {
156
166
  return null;
157
167
  const fields = await resolveFields(raw, schema.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), assets: assetFetcher() });
158
168
  // Alternates advertise PUBLISHED locales only; the editor (draft) sees every present one.
159
- const paths = Object.fromEntries(Object.entries(await entries.pathsOf(schema.name, id)).filter(([l]) => draft || record.locales[l]?.status === "published"));
160
- return { kind: "entry", collection: schema.name, entry: { ...record, fields }, path, paths };
169
+ // One record is in memory (a nested entry's parents are not), so the `paths` index
170
+ // supplies the paths — the same single lookup as before (spec 2026-09-15 §2).
171
+ const known = await entries.pathsOf(schema.name, id);
172
+ const alternates = entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", (l) => known[l] ?? null);
173
+ return { kind: "entry", locale, collection: schema.name, entry: { ...record, fields }, path, locales: alternates };
161
174
  }
162
175
  return null;
163
176
  }
@@ -165,15 +178,21 @@ export function createSmoodlySite(options) {
165
178
  /** One locale's published fields and path for entries of one collection;
166
179
  * `all` is the whole collection (a draft ancestor still contributes
167
180
  * its segment). `fieldsById` is the published read for the locale. */
168
- function entryItems(schema, wanted, all, locale, fieldsById) {
181
+ function entryItems(schema, wanted, all, locale, draft, fieldsById) {
169
182
  const index = new Map(all.map((e) => [e.id, e]));
170
183
  const byId = (x) => index.get(x);
171
184
  return wanted
172
185
  .filter((record) => fieldsById[record.id] !== undefined)
173
- .map((record) => ({
174
- entry: { ...record, fields: fieldsById[record.id] },
175
- path: entryPathRows([record], byId, schema, locales).find((r) => r.locale === locale)?.path ?? null,
176
- }));
186
+ .map((record) => {
187
+ const rows = entryPathRows([record], byId, schema, locales);
188
+ const pathIn = (l) => rows.find((r) => r.locale === l)?.path ?? null;
189
+ return {
190
+ entry: { ...record, fields: fieldsById[record.id] },
191
+ path: pathIn(locale),
192
+ locale,
193
+ locales: entryAlternates(record, locales, (l) => draft || record.locales[l]?.status === "published", pathIn),
194
+ };
195
+ });
177
196
  }
178
197
  async function getEntries(collection, o = {}) {
179
198
  const schema = schemaOf(options.collections, collection);
@@ -214,7 +233,7 @@ export function createSmoodlySite(options) {
214
233
  const fetchAssets = assetFetcher();
215
234
  if (fetchAssets)
216
235
  await resolveMediaSets(Object.values(fieldsById).map((values) => ({ values, descriptors: schema.fields })), fetchAssets, elements);
217
- return entryItems(schema, inLocale, all, locale, fieldsById);
236
+ return entryItems(schema, inLocale, all, locale, draft, fieldsById);
218
237
  }
219
238
  const api = {
220
239
  async getPagesTree(o) {
@@ -251,7 +270,7 @@ export function createSmoodlySite(options) {
251
270
  const locale = localeOf(o);
252
271
  if (idOrPath.startsWith("/")) {
253
272
  const hit = await site.resolve(locale, idOrPath, { draft: o?.draft ?? false });
254
- return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path } : null;
273
+ return hit?.kind === "entry" && hit.collection === collection ? { entry: hit.entry, path: hit.path, locale: hit.locale, locales: hit.locales } : null;
255
274
  }
256
275
  const items = await getEntries(collection, { locale, draft: o?.draft ?? false });
257
276
  const byId = items.find((i) => i.entry.id === idOrPath);
@@ -267,7 +286,7 @@ export function createSmoodlySite(options) {
267
286
  const build = (parentId) => items.filter((i) => i.entry.parentId === parentId).map((i) => ({ ...i, children: build(i.entry.id) }));
268
287
  return build(null);
269
288
  },
270
- async getShared(item, o) {
289
+ async getGlobal(item, o) {
271
290
  const locale = localeOf(o);
272
291
  const draft = o?.draft ?? false;
273
292
  const { entries } = options.stores();
@@ -285,18 +304,26 @@ export function createSmoodlySite(options) {
285
304
  const fields = await resolveFields(raw, item.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), assets: assetFetcher() });
286
305
  if (!item.section)
287
306
  return fields;
288
- const { [STYLES_KEY]: styles, ...rest } = fields;
307
+ const { [SETTINGS_KEY]: settings, [STYLES_KEY]: steps, [CSS_KEY]: css, ...rest } = fields;
289
308
  const fetch = assetFetcher();
290
- const own = styles !== null && typeof styles === "object" ? styles : {};
309
+ const own = settings !== null && typeof settings === "object" ? settings : {};
291
310
  if (fetch)
292
- await resolveMedia(own, sectionStyles(item.section), fetch, elements);
293
- // The layout node id rides the styles: the wrapper strips it and emits data-smd-node (spec 2026-09-13 §1).
294
- return { ...rest, styles: { ...own, __smoodlyNode: sharedNodeId(item.name) } };
311
+ await resolveMedia(own, sectionSettings(item.section), fetch, elements);
312
+ // The layout node id, the css and the values ride the styles under the reserved key (spec 2026-09-13 §1, 2026-09-14 §2, §4):
313
+ // the wrapper strips it and emits data-smd-node, so the canvas selects the item and the css reaches it.
314
+ const stepsObject = steps !== null && typeof steps === "object" ? steps : {};
315
+ return {
316
+ ...rest,
317
+ settings: own,
318
+ styles: { ...stepsObject, [SMD_KEY]: { node: globalNodeId(item.name), css: typeof css === "string" ? css : undefined, values: styles } },
319
+ };
295
320
  },
296
321
  };
297
322
  const site = {
298
323
  locales,
299
324
  homePageSlug,
325
+ styles,
326
+ custom,
300
327
  locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
301
328
  load(target, locale, path, opts = {}) {
302
329
  const draft = opts.draft ?? false;
package/dist/sql-space.js CHANGED
@@ -31,7 +31,7 @@ $$;`.trim();
31
31
  }
32
32
  /** Which rows a read key (or the anon key, which has no claim) may see.
33
33
  * null = write keys only — refs is editor tooling; editors is admission
34
- * data; nothing a browser needs. Shared content (spec 2026-09-07) rides
34
+ * data; nothing a browser needs. Global content (spec 2026-09-07) rides
35
35
  * the three entry predicates: it is stored as entries. */
36
36
  const PUBLISHED_READ = {
37
37
  pages: `exists (select 1 from "page_locales" l where l."page_id" = "pages"."id" and l."status" = 'published')`,
package/dist/sql.js CHANGED
@@ -31,7 +31,7 @@ function fieldExpr(key, d, source) {
31
31
  case "date":
32
32
  return `(${source}->>'${key}')::timestamptz`;
33
33
  default:
34
- return `${source}->'${key}'`; // image, video, file, object, list, richtext, dataAttributes, refList
34
+ return `${source}->'${key}'`; // image, video, file, object, list, richtext, refList
35
35
  }
36
36
  }
37
37
  export function collectionSQL(collection) {
@@ -179,7 +179,7 @@ create index if not exists "entry_locales_status_idx"
179
179
  -- A version belongs to ONE locale of one entry and stores the MERGED
180
180
  -- fields (shared + localized + slug) as they were at that save, so
181
181
  -- restoring an old Finnish version restores the shared fields too.
182
- -- Rows for every collection and every shared item (spec 2026-09-14 §1).
182
+ -- Rows for every collection and every global item (spec 2026-09-14 §1).
183
183
  -- One row per publish plus the live draft (spec 2026-09-13 history §1).
184
184
  create table if not exists "entry_versions" (
185
185
  "id" uuid primary key default gen_random_uuid(),
package/dist/store.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { PageTree } from "./render.tsx";
2
2
  import type { EntryStore } from "./entry-store.ts";
3
3
  import { type RefEdge, type SectionLike } from "./refs.ts";
4
+ import type { Toolset } from "./toolset.ts";
4
5
  import type { MemoryRefIndex } from "./ref-index.ts";
5
6
  import { type PathIndex } from "./path-index.ts";
6
7
  import { type LocaleSet, type PagePathOptions, type PathTarget } from "./paths.ts";
@@ -107,6 +108,10 @@ export type PageStoreOptions = {
107
108
  sections?: SectionLike[];
108
109
  /** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
109
110
  elements?: SectionLike[];
111
+ /** The custom section's columns toolset (spec 2026-09-14 §6); with `sections` given, the custom schema always joins the ref walk. */
112
+ custom?: {
113
+ toolset?: Toolset;
114
+ };
110
115
  entries?: EntryStore;
111
116
  /** The shared URL index (one instance for pages AND entries). */
112
117
  paths?: PathIndex;
@@ -117,6 +122,8 @@ export type PageStoreOptions = {
117
122
  };
118
123
  };
119
124
  export declare const DEFAULT_LOCALES: LocaleSet;
125
+ /** The section list every adapter walks refs with: the registered ones plus the custom section's schema (spec 2026-09-14 §6). */
126
+ export declare function refSections(options: PageStoreOptions): SectionLike[] | undefined;
120
127
  /** The options every adapter resolves the same way. */
121
128
  export declare function pathOptions(options: PageStoreOptions): PagePathOptions & {
122
129
  depth: number;
package/dist/store.js CHANGED
@@ -11,9 +11,14 @@
11
11
  // locale — presence IS the row.
12
12
  import { ASSET_COLLECTION } from "./media.js";
13
13
  import { collectTreeRefs } from "./refs.js";
14
+ import { customToolset, withCustomSchema } from "./custom/index.js";
14
15
  import { MemoryPathIndex } from "./path-index.js";
15
16
  import { assertDepth, assertSegment, assertSupportedLocale, chainOf, isHomeNode, pagePathRows, unsupportedLocale, } from "./paths.js";
16
17
  export const DEFAULT_LOCALES = { default: "en", supported: ["en"] };
18
+ /** The section list every adapter walks refs with: the registered ones plus the custom section's schema (spec 2026-09-14 §6). */
19
+ export function refSections(options) {
20
+ return options.sections ? withCustomSchema(options.sections, customToolset(options.custom?.toolset)) : undefined;
21
+ }
17
22
  /** The options every adapter resolves the same way. */
18
23
  export function pathOptions(options) {
19
24
  return {
@@ -98,6 +103,7 @@ export class MemoryPageStore {
98
103
  /** Append-only creation-order tie-break; `size + 1` stays fresh only because entries are never deleted. */
99
104
  this.seq = new Map();
100
105
  this.byId = (id) => this.pages.get(id);
106
+ this.options = { ...options, sections: refSections(options) };
101
107
  this.paths = options.paths ?? new MemoryPathIndex();
102
108
  this.opts = pathOptions(options);
103
109
  }
@@ -0,0 +1,27 @@
1
+ import type { Descriptor } from "./fields.ts";
2
+ export type StyleValues = {
3
+ spacing: Record<string, string>;
4
+ width: Record<string, string>;
5
+ };
6
+ export type StyleValuesInput = {
7
+ spacing?: Record<string, string>;
8
+ width?: Record<string, string>;
9
+ };
10
+ export declare const DEFAULT_STYLE_VALUES: StyleValues;
11
+ /** The "0" step: always present, always `0`, never a config key. */
12
+ export declare const SPACING_NONE = "none";
13
+ export declare const ALIGN_STEPS: readonly ["left", "center", "right"];
14
+ export declare const STYLE_KEYS: readonly ["spacingTop", "spacingBottom", "width", "align"];
15
+ export type StyleKey = (typeof STYLE_KEYS)[number];
16
+ /** What a view receives: steps, not CSS. A config may add steps, so the strings stay open. */
17
+ export type StyleProps = {
18
+ spacingTop?: string;
19
+ spacingBottom?: string;
20
+ width?: string;
21
+ align?: (typeof ALIGN_STEPS)[number];
22
+ };
23
+ export declare function resolveStyleValues(input?: StyleValuesInput): StyleValues;
24
+ /** The set as admin descriptors: optional radios (the empty pill is Default), product labels. */
25
+ export declare function styleDescriptors(values: StyleValues, omit?: readonly string[]): Record<string, Descriptor>;
26
+ /** The inline CSS for a node's steps. Unknown steps and non-strings render nothing. */
27
+ export declare function inlineStyles(steps: Record<string, unknown> | undefined, values: StyleValues): Record<string, string | number>;
package/dist/styles.js ADDED
@@ -0,0 +1,60 @@
1
+ export const DEFAULT_STYLE_VALUES = {
2
+ spacing: { sm: "1.5rem", md: "3.5rem", lg: "5.5rem" },
3
+ width: { narrow: "40rem", wide: "90rem", full: "none" },
4
+ };
5
+ /** The "0" step: always present, always `0`, never a config key. */
6
+ export const SPACING_NONE = "none";
7
+ export const ALIGN_STEPS = ["left", "center", "right"];
8
+ export const STYLE_KEYS = ["spacingTop", "spacingBottom", "width", "align"];
9
+ export function resolveStyleValues(input) {
10
+ const spacing = { ...DEFAULT_STYLE_VALUES.spacing, ...(input?.spacing ?? {}) };
11
+ if (SPACING_NONE in spacing)
12
+ throw new Error(`smoodly: styles.spacing cannot define "${SPACING_NONE}" — the 0 step is always 0.`);
13
+ const width = { ...DEFAULT_STYLE_VALUES.width, ...(input?.width ?? {}) };
14
+ for (const [group, map] of Object.entries({ spacing, width })) {
15
+ for (const [k, v] of Object.entries(map)) {
16
+ if (typeof v !== "string" || v === "")
17
+ throw new Error(`smoodly: styles.${group}.${k} must be a CSS value.`);
18
+ }
19
+ }
20
+ return { spacing, width };
21
+ }
22
+ const SPACING_LABELS = { [SPACING_NONE]: "0", sm: "S", md: "M", lg: "L" };
23
+ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
24
+ const radio = (options) => ({ type: "radio", optional: true, options });
25
+ /** The set as admin descriptors: optional radios (the empty pill is Default), product labels. */
26
+ export function styleDescriptors(values, omit = []) {
27
+ const spacingOptions = () => [
28
+ { value: SPACING_NONE, label: "0" },
29
+ ...Object.keys(values.spacing).map((k) => ({ value: k, label: SPACING_LABELS[k] ?? k.toUpperCase() })),
30
+ ];
31
+ const all = {
32
+ spacingTop: radio(spacingOptions()),
33
+ spacingBottom: radio(spacingOptions()),
34
+ width: radio(Object.keys(values.width).map((k) => ({ value: k, label: capitalize(k) }))),
35
+ align: radio(ALIGN_STEPS.map((k) => ({ value: k, label: capitalize(k) }))),
36
+ };
37
+ const out = {};
38
+ for (const key of STYLE_KEYS)
39
+ if (!omit.includes(key))
40
+ out[key] = all[key];
41
+ return out;
42
+ }
43
+ /** The inline CSS for a node's steps. Unknown steps and non-strings render nothing. */
44
+ export function inlineStyles(steps, values) {
45
+ const out = {};
46
+ if (!steps)
47
+ return out;
48
+ const spacing = (v) => v === SPACING_NONE ? 0 : typeof v === "string" && v in values.spacing ? values.spacing[v] : undefined;
49
+ const top = spacing(steps.spacingTop);
50
+ if (top !== undefined)
51
+ out.paddingTop = top;
52
+ const bottom = spacing(steps.spacingBottom);
53
+ if (bottom !== undefined)
54
+ out.paddingBottom = bottom;
55
+ if (typeof steps.width === "string" && steps.width in values.width)
56
+ out["--smd-width"] = values.width[steps.width];
57
+ if (typeof steps.align === "string" && ALIGN_STEPS.includes(steps.align))
58
+ out.textAlign = steps.align;
59
+ return out;
60
+ }
@@ -7,8 +7,8 @@
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 { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isSharedSchema, schemaOf, splitFields, } from "./entry-store.js";
11
- import { sharedEntryId } from "./shared-id.js";
10
+ import { ARRAY_FIELD_TYPES, DEFAULT_ENTRY_LOCALES, entryErrors, entryFieldsIn, entryLocaleRow, entryRefEdges, entryTitleIn, isGlobalSchema, schemaOf, splitFields, } from "./entry-store.js";
11
+ import { globalEntryId } from "./global-id.js";
12
12
  import { SupabasePathIndex } from "./supabase-path-index.js";
13
13
  import { assertDepth, assertSupportedLocale, entryPathRows, slugOf } from "./paths.js";
14
14
  // entries.id is a Postgres uuid column: filtering on a non-UUID string
@@ -48,7 +48,7 @@ export class SupabaseEntryStore {
48
48
  this.db = db;
49
49
  this.paths = options.paths ?? new SupabasePathIndex(db);
50
50
  this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
51
- this.schemas = [...collections, ...(options.shared ?? [])];
51
+ this.schemas = [...collections, ...(options.globals ?? [])];
52
52
  this.elements = options.elements;
53
53
  }
54
54
  fail(message) {
@@ -293,7 +293,7 @@ export class SupabaseEntryStore {
293
293
  const schema = this.schema(collection);
294
294
  assertSupportedLocale(options.locale, this.locales);
295
295
  const parentId = options.parentId ?? null;
296
- if (isSharedSchema(schema)) {
296
+ if (isGlobalSchema(schema)) {
297
297
  const existing = await this.db.from("entries").select("id").eq("collection", collection).limit(1);
298
298
  if (existing.error)
299
299
  this.fail(existing.error.message);
@@ -306,17 +306,17 @@ export class SupabaseEntryStore {
306
306
  this.assertParentHasLocale(parent, options.locale);
307
307
  const { shared, localized, slug } = splitFields(fields, schema);
308
308
  await this.assertSiblingSlug(collection, parentId, options.locale, slug);
309
- // A shared item's row id is DERIVED from (space, name), not generated
309
+ // A global item's row id is DERIVED from (space, name), not generated
310
310
  // (spec 2026-09-07 §10): the SELECT above is a fast path two racing
311
311
  // materializations can both pass, so the primary key is the backstop
312
312
  // — a duplicate would be unremovable, since `delete` refuses every
313
- // shared row.
313
+ // global row.
314
314
  const row = { collection, parent_id: parentId, fields: shared };
315
- if (isSharedSchema(schema))
316
- row.id = await sharedEntryId(await this.space(), collection);
315
+ if (isGlobalSchema(schema))
316
+ row.id = await globalEntryId(await this.space(), collection);
317
317
  const inserted = await this.db.from("entries").insert(row).select("id").single();
318
318
  if (inserted.error) {
319
- if (isSharedSchema(schema) && inserted.error.code === UNIQUE_VIOLATION)
319
+ if (isGlobalSchema(schema) && inserted.error.code === UNIQUE_VIOLATION)
320
320
  throw entryErrors.singleton(collection);
321
321
  this.fail(inserted.error.message);
322
322
  }
@@ -423,7 +423,7 @@ export class SupabaseEntryStore {
423
423
  const row = entryLocaleRow(before, locale);
424
424
  await this.ensureLiveSnapshot(before, locale);
425
425
  const { shared, localized, slug } = splitFields(fields, schema);
426
- if (isSharedSchema(schema) && slug !== null)
426
+ if (isGlobalSchema(schema) && slug !== null)
427
427
  throw entryErrors.noSlug(collection);
428
428
  await this.assertSiblingSlug(collection, before.parentId, locale, slug, id);
429
429
  await this.updateNode(id, { fields: shared });
@@ -511,7 +511,7 @@ export class SupabaseEntryStore {
511
511
  }
512
512
  async removeLocale(collection, id, locale) {
513
513
  const schema = this.schema(collection);
514
- if (isSharedSchema(schema))
514
+ if (isGlobalSchema(schema))
515
515
  throw entryErrors.everyLocale(collection);
516
516
  const entry = await this.must(collection, id);
517
517
  entryLocaleRow(entry, locale);
@@ -575,7 +575,7 @@ export class SupabaseEntryStore {
575
575
  return res.data ? toVersion(res.data) : null;
576
576
  }
577
577
  async move(collection, id, to) {
578
- if (isSharedSchema(this.schema(collection)))
578
+ if (isGlobalSchema(this.schema(collection)))
579
579
  throw entryErrors.owned(collection, "it has no tree position");
580
580
  const entry = await this.must(collection, id);
581
581
  const subtree = await this.subtree(entry);
@@ -622,7 +622,7 @@ export class SupabaseEntryStore {
622
622
  const entry = await this.fetch(collection, id);
623
623
  if (!entry)
624
624
  return;
625
- if (isSharedSchema(this.schema(collection)))
625
+ if (isGlobalSchema(this.schema(collection)))
626
626
  throw entryErrors.owned(collection, "remove the registration to remove it");
627
627
  const children = await this.db.from("entries").select("id").eq("parent_id", id).limit(1);
628
628
  if (children.error)
@@ -649,7 +649,7 @@ export class SupabaseEntryStore {
649
649
  return (await this.fetch(collection, id)) ? this.paths.pathsOf({ kind: "entry", id }) : {};
650
650
  }
651
651
  async reorder(collection, orderedIds) {
652
- if (isSharedSchema(this.schema(collection)))
652
+ if (isGlobalSchema(this.schema(collection)))
653
653
  throw entryErrors.owned(collection, "it has no order");
654
654
  const res = await this.db.rpc("smoodly_reorder", { p_collection: collection, p_ids: orderedIds });
655
655
  if (res.error)
@@ -6,7 +6,7 @@
6
6
  // are two indexed lookups. Every read embeds the locale rows
7
7
  // (`*, page_locales(*)`) so a record arrives whole. Path rows are
8
8
  // rewritten through ONE RPC per write, so a collision rolls back.
9
- import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, } from "./store.js";
9
+ import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, refSections, } from "./store.js";
10
10
  import { SupabasePathIndex } from "./supabase-path-index.js";
11
11
  import { assertDepth, assertSegment, isHomeNode, pagePathRows } from "./paths.js";
12
12
  const SELECT = "*, page_locales(*)";
@@ -40,6 +40,7 @@ export class SupabasePageStore {
40
40
  constructor(db, options = {}) {
41
41
  this.db = db;
42
42
  this.options = options;
43
+ this.options = { ...options, sections: refSections(options) };
43
44
  this.paths = options.paths ?? new SupabasePathIndex(db);
44
45
  this.opts = pathOptions(options);
45
46
  }
package/dist/zones.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SharedSchema } from "./shared.ts";
1
+ import type { GlobalSchema } from "./global.ts";
2
2
  type WithSchema = {
3
3
  schema: {
4
4
  name: string;
@@ -8,16 +8,18 @@ export type ZonePolicy = {
8
8
  kind: "sections";
9
9
  allow: string[];
10
10
  max?: number;
11
- } | {
11
+ }
12
+ /** `custom`: whether editors may add a custom section here (spec 2026-09-14 §6). */
13
+ | {
12
14
  kind: "freeform";
13
15
  allow: string[] | "*";
14
- rows: boolean;
16
+ custom: boolean;
15
17
  }
16
- /** `shared` names the item a bound zone shows (spec 2026-09-07 §4); `component` is its section either way. */
18
+ /** `global` names the item a bound zone shows (spec 2026-09-07 §4); `component` is its section either way. */
17
19
  | {
18
20
  kind: "locked";
19
21
  component: string;
20
- shared?: string;
22
+ global?: string;
21
23
  };
22
24
  export declare const z: {
23
25
  sections(components: WithSchema[]): {
@@ -28,8 +30,8 @@ export declare const z: {
28
30
  };
29
31
  freeform(opts: {
30
32
  sections: WithSchema[] | "*";
31
- rows?: boolean;
33
+ custom?: boolean;
32
34
  }): ZonePolicy;
33
- locked(target: WithSchema | SharedSchema): ZonePolicy;
35
+ locked(target: WithSchema | GlobalSchema): ZonePolicy;
34
36
  };
35
37
  export {};
package/dist/zones.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Zone policies — the freedom dial. Policies reference paired components
2
2
  // directly; only their registered names are stored (plain data).
3
- const isShared = (target) => "kind" in target && target.kind === "shared";
3
+ const isGlobal = (target) => "kind" in target && target.kind === "global";
4
4
  const names = (components) => components.map((c) => c.schema.name);
5
5
  export const z = {
6
6
  sections(components) {
@@ -15,14 +15,14 @@ export const z = {
15
15
  return {
16
16
  kind: "freeform",
17
17
  allow: opts.sections === "*" ? "*" : names(opts.sections),
18
- rows: opts.rows ?? false,
18
+ custom: opts.custom ?? false,
19
19
  };
20
20
  },
21
21
  locked(target) {
22
- if (isShared(target)) {
22
+ if (isGlobal(target)) {
23
23
  if (!target.section)
24
- throw new Error(`smoodly: shared item "${target.name}" has no section — a locked zone needs a visual item.`);
25
- return { kind: "locked", component: target.section, shared: target.name };
24
+ throw new Error(`smoodly: global item "${target.name}" has no section — a locked zone needs a visual item.`);
25
+ return { kind: "locked", component: target.section, global: target.name };
26
26
  }
27
27
  return { kind: "locked", component: target.schema.name };
28
28
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
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": {
@@ -1,23 +0,0 @@
1
- import type { AdminShared } from "../serialize.ts";
2
- import type { TabEntry } from "../forms/tabs.ts";
3
- import type { SharedEditItem } from "./shared-edit.ts";
4
- import { type RefOption } from "../shell/SharedFields.tsx";
5
- import { type PublishBusy } from "../ui/PublishControl.tsx";
6
- export declare function SharedInspector({ item, edit, locale, entries, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }: {
7
- item: AdminShared;
8
- /** null while the item loads. */
9
- edit: SharedEditItem | null;
10
- locale: string;
11
- entries: TabEntry[];
12
- multilingual: boolean;
13
- refOptions: Record<string, RefOption[]>;
14
- inlinePath?: string;
15
- onEditHere?(): void;
16
- onChange(ns: "fields" | "styles", key: string, value: unknown): void;
17
- /** An edit the store has not confirmed (dirty, or the item's autosave not in `saved`). */
18
- pending: boolean;
19
- busy: PublishBusy;
20
- onPublish(): void;
21
- onDiscard(): void;
22
- onUnpublish(): void;
23
- }): import("react").JSX.Element;
@@ -1,13 +0,0 @@
1
- "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { BASE } from "../shell/base.js";
4
- import { localeStatusOf } from "../shell/pages-tree.js";
5
- import { SharedFields } from "../shell/SharedFields.js";
6
- import { PublishControl } from "../ui/PublishControl.js";
7
- export function SharedInspector({ item, edit, locale, entries, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }) {
8
- const row = edit?.record.locales[locale];
9
- // The same three states the shared list shows; the control decides what applies.
10
- const status = row ? localeStatusOf(row) : "draft";
11
- const pages = edit ? [...new Set(edit.usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId))] : [];
12
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsx("p", { className: "sm-hint", children: "Shared content \u2014 changes every page it is used on." }), edit && (_jsxs("p", { className: "sm-hint", children: [pages.length === 0 ? "Not placed on any page." : `Used on ${pages.length} page${pages.length === 1 ? "" : "s"}: `, pages.map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), _jsx("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }, children: _jsx(PublishControl, { status: row ? status : null, pending: pending, busy: busy, disabled: !edit, onPublish: onPublish, onDiscard: onDiscard, onUnpublish: onUnpublish }) }), edit ? (_jsx(SharedFields, { item: item, entries: entries, fields: edit.fields, errors: edit.errors, refOptions: refOptions, multilingual: multilingual, inlinePath: inlinePath, onEditHere: onEditHere, onChange: onChange })) : (_jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" }))] }));
13
- }
@@ -1,6 +0,0 @@
1
- import type { AdminOps } from "../ops.ts";
2
- import { type SharedEdit, type SharedEditState } from "./shared-edit.ts";
3
- export declare function useSharedEdit({ ops, onSaved }: {
4
- ops: Pick<AdminOps, "shared">;
5
- onSaved?: () => void;
6
- }): SharedEditState & Pick<SharedEdit, "load" | "patch" | "flush" | "setStatus" | "reset">;
@@ -1,9 +0,0 @@
1
- import type { ResolvedConfig } from "../config.ts";
2
- import type { Descriptor } from "../fields.ts";
3
- import type { EntryRecord, EntryStore } from "../entry-store.ts";
4
- import { type SharedSchema } from "../shared.ts";
5
- /** The merged fields a fresh item starts from: sampled field values, and
6
- * for a visual item `$styles` = the resolved style defaults with the
7
- * sample's style values on top. Unsampled fields stay absent. */
8
- export declare function initialSharedFields(item: SharedSchema, resolvedStyles: Record<string, Descriptor>): Record<string, unknown>;
9
- export declare function ensureSharedItems(entries: EntryStore, config: ResolvedConfig): Promise<Record<string, EntryRecord>>;
@@ -1,21 +0,0 @@
1
- import type { AdminShared } from "../serialize.ts";
2
- import type { TabEntry } from "../forms/tabs.ts";
3
- export type RefOption = {
4
- id: string;
5
- title: string;
6
- status: string;
7
- };
8
- export declare function SharedFields({ item, entries, fields, errors, refOptions, multilingual, inlinePath, onEditHere, onChange }: {
9
- item: AdminShared;
10
- entries: TabEntry[];
11
- /** The merged draft fields; a visual item's styles under `$styles`. */
12
- fields: Record<string, unknown>;
13
- errors?: Record<string, string>;
14
- /** Loaded ref options by target collection. */
15
- refOptions: Record<string, RefOption[]>;
16
- multilingual: boolean;
17
- /** The content field with an active inline session: its richtext widget yields to a note. */
18
- inlinePath?: string;
19
- onEditHere?(): void;
20
- onChange(ns: "fields" | "styles", key: string, value: unknown): void;
21
- }): import("react").JSX.Element;