smoodly 0.0.16 → 0.0.17

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 (133) hide show
  1. package/README.md +156 -21
  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 +5 -5
  7. package/dist/admin/editor/EditorView.js +140 -108
  8. package/dist/admin/editor/GlobalInspector.d.ts +24 -0
  9. package/dist/admin/editor/{SharedInspector.js → GlobalInspector.js} +4 -4
  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/HistoryModal.js +1 -1
  57. package/dist/admin/ui/primitives.d.ts +3 -1
  58. package/dist/admin/ui/primitives.js +2 -2
  59. package/dist/admin/ui/theme.js +54 -14
  60. package/dist/admin/validate.d.ts +27 -2
  61. package/dist/admin/validate.js +47 -10
  62. package/dist/collections.d.ts +3 -3
  63. package/dist/collections.js +1 -1
  64. package/dist/conditions.d.ts +33 -0
  65. package/dist/conditions.js +79 -0
  66. package/dist/config.d.ts +26 -19
  67. package/dist/config.js +26 -25
  68. package/dist/css.d.ts +7 -0
  69. package/dist/css.js +57 -0
  70. package/dist/custom/CustomSection.d.ts +26 -0
  71. package/dist/custom/CustomSection.js +20 -0
  72. package/dist/custom/css.d.ts +3 -0
  73. package/dist/custom/css.js +28 -0
  74. package/dist/custom/index.d.ts +26 -0
  75. package/dist/custom/index.js +16 -0
  76. package/dist/custom/rows.d.ts +28 -0
  77. package/dist/custom/rows.js +129 -0
  78. package/dist/custom/schema.d.ts +32 -0
  79. package/dist/custom/schema.js +39 -0
  80. package/dist/entry-store.d.ts +4 -4
  81. package/dist/entry-store.js +17 -17
  82. package/dist/fields.d.ts +5 -2
  83. package/dist/fields.js +4 -2
  84. package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
  85. package/dist/{shared-id.js → global-id.js} +4 -4
  86. package/dist/global.d.ts +57 -0
  87. package/dist/global.js +51 -0
  88. package/dist/index.d.ts +28 -18
  89. package/dist/index.js +10 -5
  90. package/dist/media.js +1 -1
  91. package/dist/next/index.d.ts +1 -1
  92. package/dist/next/page-renderer.d.ts +4 -2
  93. package/dist/next/page-renderer.js +9 -9
  94. package/dist/next/queries.d.ts +6 -6
  95. package/dist/next/queries.js +7 -7
  96. package/dist/page.js +2 -2
  97. package/dist/pairing.d.ts +13 -7
  98. package/dist/pairing.js +4 -4
  99. package/dist/refs.d.ts +2 -2
  100. package/dist/refs.js +4 -4
  101. package/dist/render.d.ts +13 -1
  102. package/dist/render.js +10 -5
  103. package/dist/resolve.d.ts +11 -6
  104. package/dist/resolve.js +22 -12
  105. package/dist/revalidate.d.ts +4 -4
  106. package/dist/revalidate.js +4 -4
  107. package/dist/richtext-render.js +2 -2
  108. package/dist/richtext-walk.d.ts +5 -5
  109. package/dist/richtext-walk.js +4 -4
  110. package/dist/schema.d.ts +15 -10
  111. package/dist/schema.js +19 -7
  112. package/dist/section-wrapper.d.ts +18 -6
  113. package/dist/section-wrapper.js +24 -17
  114. package/dist/site.d.ts +15 -5
  115. package/dist/site.js +24 -10
  116. package/dist/sql-space.js +1 -1
  117. package/dist/sql.js +2 -2
  118. package/dist/store.d.ts +7 -0
  119. package/dist/store.js +6 -0
  120. package/dist/styles.d.ts +27 -0
  121. package/dist/styles.js +60 -0
  122. package/dist/supabase-entry-store.js +14 -14
  123. package/dist/supabase-store.js +2 -1
  124. package/dist/zones.d.ts +9 -7
  125. package/dist/zones.js +5 -5
  126. package/package.json +1 -1
  127. package/dist/admin/editor/SharedInspector.d.ts +0 -23
  128. package/dist/admin/editor/useSharedEdit.d.ts +0 -6
  129. package/dist/admin/shared-items.d.ts +0 -9
  130. package/dist/admin/shell/SharedFields.d.ts +0 -21
  131. package/dist/admin/shell/SharedFields.js +0 -15
  132. package/dist/shared.d.ts +0 -53
  133. package/dist/shared.js +0 -45
@@ -1,20 +1,27 @@
1
1
  import { createElement } from "react";
2
2
  import { NODE_ATTR } from "./marks.js";
3
- // The root wrapper every section renders through. Owns the mechanical DOM
4
- // work on the real root element (no extra div): element choice via `as`,
5
- // string-valued styles emitted as kebab-cased data attributes. Built-in
6
- // meta (anchor, visibility) and editor chrome attach here when the
7
- // renderer and admin land. Bounded magic: everything else stays the dev's
8
- // job via normal className/style props.
9
- const kebab = (s) => s.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
10
- export function Section({ as = "section", styles, className, style, children, }) {
11
- const { __smoodlyNode, ...rest } = (styles ?? {});
12
- const dataAttrs = {};
13
- if (typeof __smoodlyNode === "string")
14
- dataAttrs[NODE_ATTR] = __smoodlyNode;
15
- for (const [key, value] of Object.entries(rest)) {
16
- if (typeof value === "string")
17
- dataAttrs[`data-${kebab(key)}`] = value;
18
- }
19
- return createElement(as, { className, style, ...dataAttrs }, children);
3
+ import { DEFAULT_STYLE_VALUES, inlineStyles } from "./styles.js";
4
+ import { safeCss, scopedCss } from "./css.js";
5
+ // The root wrapper every section renders through (spec 2026-09-14 §2, §4).
6
+ // Owns the real root element, no extra div: element choice via `as`,
7
+ // Smoodly's styles set rendered INLINE (an editor's step beats the
8
+ // section's own class rule with no ordering and no !important), the
9
+ // node id as data-smd-node always (the custom-CSS scope needs it on the
10
+ // live site), the scoped <style> child, and every other prop spread onto
11
+ // the root — settings never become attributes; the view writes its own.
12
+ /** The reserved key `<Zone>` and `getGlobal` ride on the styles object. */
13
+ export const SMD_KEY = "__smd";
14
+ export function Section({ as = "section", styles, className, style, children, ...rest }) {
15
+ const { [SMD_KEY]: smd, ...steps } = (styles ?? {});
16
+ const inline = inlineStyles(steps, smd?.values ?? DEFAULT_STYLE_VALUES);
17
+ const merged = { ...(style ?? {}), ...inline };
18
+ const attrs = { ...rest };
19
+ if (smd?.node)
20
+ attrs[NODE_ATTR] = smd.node;
21
+ if (className !== undefined)
22
+ attrs.className = className;
23
+ if (Object.keys(merged).length > 0)
24
+ attrs.style = merged;
25
+ const css = smd?.node ? safeCss(smd.css) : "";
26
+ return createElement(as, attrs, smd?.layoutCss ? createElement("style", { key: "smd-layout" }, smd.layoutCss) : null, css ? createElement("style", { key: "smd-css" }, scopedCss(smd.node, css)) : null, children);
20
27
  }
package/dist/site.d.ts CHANGED
@@ -5,7 +5,9 @@ import { type EntryRecord, type EntryStore, type Where } from "./entry-store.ts"
5
5
  import { type LocaleSet, type PathTarget } from "./paths.ts";
6
6
  import type { SectionLike } from "./refs.ts";
7
7
  import type { PageContext, PageLink, PageTree } from "./render.tsx";
8
- import { type SharedSchema } from "./shared.ts";
8
+ import { type GlobalSchema } from "./global.ts";
9
+ import { type StyleValues, type StyleValuesInput } from "./styles.ts";
10
+ import { type CustomConfigInput, type ResolvedCustom } from "./custom/index.ts";
9
11
  import type { PageRecord, PageStore } from "./store.ts";
10
12
  export type SiteOptions = {
11
13
  stores: () => {
@@ -17,9 +19,13 @@ export type SiteOptions = {
17
19
  sections: SectionLike[];
18
20
  /** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
19
21
  elements?: SectionLike[];
20
- shared?: SharedSchema[];
22
+ globals?: GlobalSchema[];
21
23
  locales: LocaleSet;
22
24
  homePageSlug?: string;
25
+ /** The CSS values behind Smoodly's styles set (spec 2026-09-14 §2); the package fallbacks fill the rest. */
26
+ styles?: StyleValuesInput;
27
+ /** The custom section's options (spec 2026-09-14 §6); the defaults when absent. */
28
+ custom?: CustomConfigInput;
23
29
  /** Page shapes (entry pages are ignored): the source of each template's page-field descriptors (spec 2026-09-10 page fields §3). */
24
30
  pages?: ReadonlyArray<SitePageSchema>;
25
31
  };
@@ -90,6 +96,10 @@ export type LoadOptions = {
90
96
  export interface SmoodlySite {
91
97
  readonly locales: LocaleSet;
92
98
  readonly homePageSlug: string;
99
+ /** The resolved styles values the renderer and `getGlobal` hand the wrapper. */
100
+ readonly styles: StyleValues;
101
+ /** The custom section's resolved options the renderer hands renderPage. */
102
+ readonly custom: ResolvedCustom;
93
103
  /** One index hit: what lives at (locale, path). Uncached by design — the id it returns is the cache tag. */
94
104
  locate(locale: string, path: string): Promise<PathTarget | null>;
95
105
  /** Everything the renderer needs for one target: null when it is a folder, unpublished (outside draft), or gone. */
@@ -112,11 +122,11 @@ export interface SmoodlySite {
112
122
  locale?: string;
113
123
  draft?: boolean;
114
124
  }): Promise<EntryTreeItem[]>;
115
- /** A shared item's content in a locale (spec 2026-09-07 §4): an object
125
+ /** A global item's content in a locale (spec 2026-09-07 §4): an object
116
126
  * item's merged fields, or a visual item's section props (fields plus
117
- * `styles`), refs hydrated; null when the item has no row there or is
127
+ * `settings`), refs hydrated; null when the item has no row there or is
118
128
  * unpublished (outside draft mode). */
119
- getShared<V>(item: SharedSchema<V>, options?: {
129
+ getGlobal<V>(item: GlobalSchema<V>, options?: {
120
130
  locale?: string;
121
131
  draft?: boolean;
122
132
  }): Promise<V | null>;
package/dist/site.js CHANGED
@@ -2,7 +2,10 @@ import { ARRAY_FIELD_TYPES, entryFieldsIn, localizedKeys, schemaOf } from "./ent
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
- 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
10
  /** The `page` prop for a loaded page. `toUrl` is the app's `href` seam
8
11
  * bound to the render locale; the default keeps CMS paths as URLs. */
@@ -21,6 +24,8 @@ export function pageContextOf(hit, toUrl = (p) => p) {
21
24
  export function createSmoodlySite(options) {
22
25
  const locales = options.locales;
23
26
  const homePageSlug = options.homePageSlug ?? "home";
27
+ const styles = resolveStyleValues(options.styles);
28
+ const custom = resolveCustom(options.custom);
24
29
  const elements = elementSchemas(options.elements);
25
30
  const pathOpts = { locales, homePageSlug };
26
31
  const mounted = options.collections.filter((c) => c.path !== undefined);
@@ -33,9 +38,9 @@ export function createSmoodlySite(options) {
33
38
  const assets = options.stores().assets;
34
39
  return assets ? (ids) => assets.getMany(ids) : undefined;
35
40
  };
36
- const sectionStyles = (name) => {
41
+ const sectionSettings = (name) => {
37
42
  const s = options.sections.find((x) => ("schema" in x ? x.schema : x).name === name);
38
- return (s && ("schema" in s ? s.schema : s).styles) ?? {};
43
+ return (s && ("schema" in s ? s.schema : s).settings) ?? {};
39
44
  };
40
45
  /** Visible to the public in a locale: present there, and a folder or a
41
46
  * published page. Draft mode sees every present node. Presence is the
@@ -108,11 +113,12 @@ export function createSmoodlySite(options) {
108
113
  const tree = await resolveTree(raw, {
109
114
  sections: options.sections,
110
115
  collections: options.collections,
111
- shared: options.shared,
116
+ globals: options.globals,
112
117
  elements: options.elements,
113
118
  fetch: fetcher(draft, locale),
114
119
  assets: assetFetcher(),
115
120
  page: { fields: pageFields(record.template) },
121
+ custom,
116
122
  });
117
123
  const all = await pages.listPages();
118
124
  const index = new Map(all.map((n) => [n.id, n]));
@@ -267,7 +273,7 @@ export function createSmoodlySite(options) {
267
273
  const build = (parentId) => items.filter((i) => i.entry.parentId === parentId).map((i) => ({ ...i, children: build(i.entry.id) }));
268
274
  return build(null);
269
275
  },
270
- async getShared(item, o) {
276
+ async getGlobal(item, o) {
271
277
  const locale = localeOf(o);
272
278
  const draft = o?.draft ?? false;
273
279
  const { entries } = options.stores();
@@ -285,18 +291,26 @@ export function createSmoodlySite(options) {
285
291
  const fields = await resolveFields(raw, item.fields, { collections: options.collections, elements: options.elements, fetch: fetcher(draft, locale), assets: assetFetcher() });
286
292
  if (!item.section)
287
293
  return fields;
288
- const { [STYLES_KEY]: styles, ...rest } = fields;
294
+ const { [SETTINGS_KEY]: settings, [STYLES_KEY]: steps, [CSS_KEY]: css, ...rest } = fields;
289
295
  const fetch = assetFetcher();
290
- const own = styles !== null && typeof styles === "object" ? styles : {};
296
+ const own = settings !== null && typeof settings === "object" ? settings : {};
291
297
  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) } };
298
+ await resolveMedia(own, sectionSettings(item.section), fetch, elements);
299
+ // 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):
300
+ // the wrapper strips it and emits data-smd-node, so the canvas selects the item and the css reaches it.
301
+ const stepsObject = steps !== null && typeof steps === "object" ? steps : {};
302
+ return {
303
+ ...rest,
304
+ settings: own,
305
+ styles: { ...stepsObject, [SMD_KEY]: { node: globalNodeId(item.name), css: typeof css === "string" ? css : undefined, values: styles } },
306
+ };
295
307
  },
296
308
  };
297
309
  const site = {
298
310
  locales,
299
311
  homePageSlug,
312
+ styles,
313
+ custom,
300
314
  locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
301
315
  load(target, locale, path, opts = {}) {
302
316
  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.17",
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,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;
@@ -1,15 +0,0 @@
1
- "use client";
2
- import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { STYLES_KEY } from "../../shared.js";
4
- import { FieldWidget } from "../forms/FieldWidget.js";
5
- import { UnlocalizedMark } from "../ui/primitives.js";
6
- export function SharedFields({ item, entries, fields, errors, refOptions, multilingual, inlinePath, onEditHere, onChange }) {
7
- const styles = (fields[STYLES_KEY] ?? {});
8
- return (_jsx(_Fragment, { children: entries.map(({ key, ns, descriptor }) => {
9
- if (ns === "meta")
10
- return null; // a shared item has no meta namespace
11
- const target = (descriptor.type === "ref" || descriptor.type === "refList") && typeof descriptor.target === "string" ? descriptor.target : null;
12
- const localized = ns === "fields" && item.fields[key]?.localized === true;
13
- return (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: ns === "fields" ? fields[key] : styles[key], errors: errors, refOptions: target ? refOptions[target] ?? [] : undefined, mark: ns === "fields" && multilingual && !localized ? _jsx(UnlocalizedMark, {}) : undefined, inlinePath: ns === "fields" ? inlinePath : undefined, onEditHere: onEditHere, onChange: (value) => onChange(ns, key, value) }, `${ns}.${key}`));
14
- }) }));
15
- }