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
@@ -0,0 +1,33 @@
1
+ import type { Descriptor } from "./fields.ts";
2
+ export type ConditionOp = "is" | "isNot" | "in" | "isEmpty" | "isNotEmpty";
3
+ export type Condition = {
4
+ path: string;
5
+ op: ConditionOp;
6
+ value?: unknown;
7
+ };
8
+ export declare function when(path: string): {
9
+ is: (value: unknown) => Condition;
10
+ isNot: (value: unknown) => Condition;
11
+ in: (values: readonly unknown[]) => Condition;
12
+ isEmpty: () => Condition;
13
+ isNotEmpty: () => Condition;
14
+ };
15
+ /** Where a condition reads: the current level's values, and the two namespaces. */
16
+ export type ConditionScope = {
17
+ self: Record<string, unknown> | undefined;
18
+ fields?: Record<string, unknown>;
19
+ settings?: Record<string, unknown>;
20
+ };
21
+ /** `["settings", "mediaType"]` for a prefixed path, `[null, "kind"]` for a bare one. */
22
+ export declare function splitConditionPath(path: string): [("fields" | "settings") | null, string];
23
+ export declare function evaluate(c: Condition, scope: ConditionScope): boolean;
24
+ /** True when the descriptor's condition holds; false without one. */
25
+ export declare function isHidden(d: object, scope: ConditionScope): boolean;
26
+ /** Every condition in both namespaces names a field that exists: a typo
27
+ * fails at registration, not in the form. Walks objects (siblings are the
28
+ * object's keys) and lists (an object item's keys; a scalar item reads the
29
+ * list's own level). */
30
+ export declare function assertConditions(owner: string, maps: {
31
+ fields: Record<string, Descriptor>;
32
+ settings: Record<string, Descriptor>;
33
+ }): void;
@@ -0,0 +1,79 @@
1
+ export function when(path) {
2
+ if (typeof path !== "string" || path === "")
3
+ throw new Error("smoodly: f.when() needs a field path.");
4
+ return {
5
+ is: (value) => ({ path, op: "is", value }),
6
+ isNot: (value) => ({ path, op: "isNot", value }),
7
+ in: (values) => ({ path, op: "in", value: [...values] }),
8
+ isEmpty: () => ({ path, op: "isEmpty" }),
9
+ isNotEmpty: () => ({ path, op: "isNotEmpty" }),
10
+ };
11
+ }
12
+ const PREFIXES = ["fields", "settings"];
13
+ /** `["settings", "mediaType"]` for a prefixed path, `[null, "kind"]` for a bare one. */
14
+ export function splitConditionPath(path) {
15
+ for (const p of PREFIXES)
16
+ if (path.startsWith(`${p}.`))
17
+ return [p, path.slice(p.length + 1)];
18
+ return [null, path];
19
+ }
20
+ function lookup(path, scope) {
21
+ const [ns, key] = splitConditionPath(path);
22
+ const holder = ns === null ? scope.self : scope[ns];
23
+ return holder?.[key];
24
+ }
25
+ /** Empty as the validator sees it, plus a media value with no asset. */
26
+ const isEmptyValue = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0) ||
27
+ (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0) ||
28
+ (typeof v === "object" && !Array.isArray(v) && "asset" in v && typeof v.asset !== "string");
29
+ export function evaluate(c, scope) {
30
+ const v = lookup(c.path, scope);
31
+ switch (c.op) {
32
+ case "is": return v === c.value;
33
+ case "isNot": return v !== c.value;
34
+ case "in": return Array.isArray(c.value) && c.value.includes(v);
35
+ case "isEmpty": return isEmptyValue(v);
36
+ case "isNotEmpty": return !isEmptyValue(v);
37
+ default: return false;
38
+ }
39
+ }
40
+ const isCondition = (c) => typeof c === "object" && c !== null && typeof c.path === "string" && typeof c.op === "string";
41
+ /** True when the descriptor's condition holds; false without one. */
42
+ export function isHidden(d, scope) {
43
+ const { hidden } = d;
44
+ return isCondition(hidden) ? evaluate(hidden, scope) : false;
45
+ }
46
+ /** Every condition in both namespaces names a field that exists: a typo
47
+ * fails at registration, not in the form. Walks objects (siblings are the
48
+ * object's keys) and lists (an object item's keys; a scalar item reads the
49
+ * list's own level). */
50
+ export function assertConditions(owner, maps) {
51
+ const top = { fields: new Set(Object.keys(maps.fields)), settings: new Set(Object.keys(maps.settings)) };
52
+ const check = (key, d, siblings) => {
53
+ if (d.hidden === undefined)
54
+ return;
55
+ if (!isCondition(d.hidden))
56
+ throw new Error(`smoodly: ${owner}: "${key}" has a malformed hidden condition — use f.when(path).`);
57
+ const [ns, name] = splitConditionPath(d.hidden.path);
58
+ const known = ns === null ? siblings : top[ns];
59
+ if (!known.has(name))
60
+ throw new Error(`smoodly: ${owner}: "${key}" is hidden by "${d.hidden.path}", which is not a field.`);
61
+ };
62
+ const walk = (map) => {
63
+ const siblings = new Set(Object.keys(map));
64
+ for (const [key, d] of Object.entries(map)) {
65
+ check(key, d, siblings);
66
+ if (d.type === "object")
67
+ walk(d.fields);
68
+ if (d.type === "list") {
69
+ const item = d.item;
70
+ if (item.type === "object")
71
+ walk(item.fields);
72
+ else
73
+ check(key, item, siblings);
74
+ }
75
+ }
76
+ };
77
+ walk(maps.fields);
78
+ walk(maps.settings);
79
+ }
package/dist/config.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- import type { Descriptor, FieldBuilder } from "./fields.ts";
1
+ import type { Descriptor } from "./fields.ts";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
- import type { SharedSchema } from "./shared.ts";
3
+ import type { GlobalSchema } from "./global.ts";
4
+ import { type StyleValues, type StyleValuesInput } from "./styles.ts";
5
+ import { type CustomConfigInput, type ResolvedCustom } from "./custom/index.ts";
4
6
  type Registered = {
5
7
  schema: {
6
8
  kind: string;
@@ -8,10 +10,12 @@ type Registered = {
8
10
  slug?: string | Record<string, string>;
9
11
  collection?: string;
10
12
  fields?: Record<string, Descriptor>;
11
- styles?: Record<string, Descriptor>;
12
- styleDefaults?: false | {
13
+ settings?: Record<string, Descriptor>;
14
+ /** The section's toggles on Smoodly's styles set and the CSS box (spec 2026-09-14 §2, §4). */
15
+ styles?: false | {
13
16
  omit: string[];
14
17
  };
18
+ css?: false;
15
19
  };
16
20
  };
17
21
  export type SmoodlyConfig = {
@@ -21,8 +25,8 @@ export type SmoodlyConfig = {
21
25
  collections?: CollectionSchema<any>[];
22
26
  /** Page shapes and entry pages alike — everything that renders a URL. */
23
27
  pages?: Registered[];
24
- /** Shared content: code-declared singletons (spec 2026-09-07). */
25
- shared?: SharedSchema[];
28
+ /** Global content: code-declared singletons (spec 2026-09-07). */
29
+ globals?: GlobalSchema[];
26
30
  };
27
31
  locales: {
28
32
  default: string;
@@ -34,20 +38,17 @@ export type SmoodlyConfig = {
34
38
  depth: number;
35
39
  };
36
40
  };
37
- styles?: {
38
- sections?: Record<string, FieldBuilder<any, any>>;
39
- elements?: Record<string, FieldBuilder<any, any>>;
40
- };
41
- rows?: {
42
- splits: string[];
43
- };
41
+ /** The CSS behind Smoodly's styles set (spec 2026-09-14 §2): a value per
42
+ * spacing and width step, merged over the package fallbacks. */
43
+ styles?: StyleValuesInput;
44
+ /** The custom-CSS box (spec 2026-09-14 §4): off unless true. */
45
+ css?: boolean;
46
+ /** The custom section (spec 2026-09-14 §6): the columns' toolset, the stacking breakpoint, the project's bound RichText. */
47
+ custom?: CustomConfigInput;
44
48
  /** Slug of the page record the site root serves. The admin maps this
45
49
  * slug to "/" (canvas URL, Pages list) and every other slug to
46
50
  * "/<slug>"; the route file serving "/" must declare the same slug. */
47
51
  homePageSlug?: string;
48
- meta?: {
49
- dataAttributes?: boolean;
50
- };
51
52
  database?: {
52
53
  provider: string;
53
54
  };
@@ -57,18 +58,24 @@ export type SmoodlyConfig = {
57
58
  accept?: string[];
58
59
  };
59
60
  };
60
- export type ResolvedConfig = SmoodlyConfig & {
61
+ export type ResolvedConfig = Omit<SmoodlyConfig, "styles" | "custom"> & {
61
62
  homePageSlug: string;
62
63
  /** How deep editors may nest pages in the tree. Default 1 (flat). */
63
64
  pageDepth: number;
64
- /** The section's styles with the config standard set merged in (section keys win). */
65
+ /** The styles set's CSS values, resolved over the fallbacks. */
66
+ styles: StyleValues;
67
+ /** Whether the custom-CSS box exists. */
68
+ css: boolean;
69
+ /** The custom section's resolved options. */
70
+ custom: ResolvedCustom;
71
+ /** Smoodly's styles set as descriptors for a section, after its `styles` toggle: `{}` for `false`. */
65
72
  resolvedStyles(sectionName: string): Record<string, Descriptor>;
66
73
  media: {
67
74
  maxSize: number;
68
75
  accept: string[];
69
76
  };
70
77
  };
71
- /** `shared`: the placement node type and an admin route; `asset`: the refs
78
+ /** `global`: the placement node type and an admin route; `asset`: the refs
72
79
  * target of every media edge; `media`: the admin's Media screen route. */
73
80
  export declare const RESERVED_NAMES: readonly string[];
74
81
  export declare function defineConfig(config: SmoodlyConfig): ResolvedConfig;
package/dist/config.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
2
2
  import { DEFAULT_MEDIA } from "./media.js";
3
- /** `shared`: the placement node type and an admin route; `asset`: the refs
3
+ import { resolveStyleValues, styleDescriptors } from "./styles.js";
4
+ import { resolveCustom } from "./custom/index.js";
5
+ /** `global`: the placement node type and an admin route; `asset`: the refs
4
6
  * target of every media edge; `media`: the admin's Media screen route. */
5
- export const RESERVED_NAMES = ["shared", "asset", "media"];
7
+ export const RESERVED_NAMES = ["global", "asset", "media"];
6
8
  const isReserved = (name) => RESERVED_NAMES.includes(name);
7
9
  function assertUniqueNames(config) {
8
10
  const seen = new Set();
@@ -21,32 +23,32 @@ function assertUniqueNames(config) {
21
23
  throw new Error(`smoodly: duplicate registered name "${name}".`);
22
24
  seen.add(name);
23
25
  }
24
- // Shared items get their own pass, after every other kind is settled: a
25
- // shared item's name must be unique among shared items, and must not
26
+ // Global items get their own pass, after every other kind is settled: a
27
+ // global item's name must be unique among global items, and must not
26
28
  // collide with any collection/element/page/section name above — EXCEPT a
27
29
  // visual item may reuse the name of the registered section it renders
28
30
  // (spec 2026-09-07 §2's example pairs a "footer" section with a "footer"
29
31
  // item). That exemption is narrow: it only excuses a match against the
30
32
  // item's own, actually-registered section — never against a collection,
31
33
  // element, or page that happens to share the string, and never against a
32
- // second shared item of the same name.
33
- const sharedSeen = new Set();
34
- for (const item of config.registry.shared ?? []) {
34
+ // second global item of the same name.
35
+ const globalSeen = new Set();
36
+ for (const item of config.registry.globals ?? []) {
35
37
  const ownSection = item.name === item.section && sectionNames.has(item.name);
36
38
  if (isReserved(item.name))
37
39
  throw new Error(`smoodly: "${item.name}" is a reserved name.`);
38
- if (sharedSeen.has(item.name) || (seen.has(item.name) && !ownSection)) {
40
+ if (globalSeen.has(item.name) || (seen.has(item.name) && !ownSection)) {
39
41
  throw new Error(`smoodly: duplicate registered name "${item.name}".`);
40
42
  }
41
- sharedSeen.add(item.name);
43
+ globalSeen.add(item.name);
42
44
  }
43
45
  }
44
46
  /** A visual item renders through a registered section. */
45
- function assertSharedSections(config) {
47
+ function assertGlobalSections(config) {
46
48
  const sections = new Set((config.registry.sections ?? []).map((s) => s.schema.name));
47
- for (const item of config.registry.shared ?? []) {
49
+ for (const item of config.registry.globals ?? []) {
48
50
  if (item.section !== undefined && !sections.has(item.section)) {
49
- throw new Error(`smoodly: shared item "${item.name}" renders section "${item.section}", which is not registered.`);
51
+ throw new Error(`smoodly: global item "${item.name}" renders section "${item.section}", which is not registered.`);
50
52
  }
51
53
  }
52
54
  }
@@ -153,8 +155,10 @@ function assertToolsetElements(config) {
153
155
  toolsetElementNames(e.schema.fields, wanted);
154
156
  for (const c of config.registry.collections ?? [])
155
157
  toolsetElementNames(c.fields, wanted);
156
- for (const s of config.registry.shared ?? [])
158
+ for (const s of config.registry.globals ?? [])
157
159
  toolsetElementNames(s.fields, wanted);
160
+ for (const n of config.custom?.toolset?.elements ?? [])
161
+ wanted.add(n);
158
162
  for (const name of wanted) {
159
163
  if (!registered.has(name))
160
164
  throw new Error(`smoodly: a toolset embeds element "${name}", which is not in registry.elements.`);
@@ -163,7 +167,7 @@ function assertToolsetElements(config) {
163
167
  export function defineConfig(config) {
164
168
  assertLocales(config);
165
169
  assertUniqueNames(config);
166
- assertSharedSections(config);
170
+ assertGlobalSections(config);
167
171
  assertToolsetElements(config);
168
172
  assertRootSegments(config);
169
173
  const depth = config.pages?.tree?.depth ?? 1;
@@ -181,7 +185,8 @@ export function defineConfig(config) {
181
185
  throw new Error(`smoodly: homePageSlug "${homePageSlug}" is also the path of collection "${c.name}".`);
182
186
  }
183
187
  }
184
- const standard = Object.fromEntries(Object.entries(config.styles?.sections ?? {}).map(([k, b]) => [k, b.descriptor]));
188
+ const styles = resolveStyleValues(config.styles);
189
+ const custom = resolveCustom(config.custom);
185
190
  const byName = new Map((config.registry.sections ?? []).map((s) => [s.schema.name, s.schema]));
186
191
  const media = {
187
192
  maxSize: config.media?.maxSize ?? DEFAULT_MEDIA.maxSize,
@@ -195,20 +200,16 @@ export function defineConfig(config) {
195
200
  homePageSlug,
196
201
  pageDepth: depth,
197
202
  media,
203
+ styles,
204
+ css: config.css === true,
205
+ custom,
198
206
  resolvedStyles(sectionName) {
199
207
  const schema = byName.get(sectionName);
200
208
  if (!schema)
201
209
  throw new Error(`smoodly: unknown section "${sectionName}".`);
202
- const own = schema.styles ?? {};
203
- if (schema.styleDefaults === false)
204
- return { ...own };
205
- const omitted = new Set(schema.styleDefaults ? schema.styleDefaults.omit : []);
206
- const merged = {};
207
- for (const [k, d] of Object.entries(standard)) {
208
- if (!omitted.has(k))
209
- merged[k] = d;
210
- }
211
- return { ...merged, ...own };
210
+ if (schema.styles === false)
211
+ return {};
212
+ return styleDescriptors(styles, schema.styles ? schema.styles.omit : []);
212
213
  },
213
214
  };
214
215
  }
package/dist/css.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export declare const CSS_MAX = 4000;
2
+ /** The first refusal, or null for a snippet the wrapper will render. */
3
+ export declare function cssProblem(css: string): string | null;
4
+ /** The snippet when it is a clean string, else "". */
5
+ export declare function safeCss(css: unknown): string;
6
+ /** Nested under the node's selector — every rule inside reaches the section only. */
7
+ export declare function scopedCss(nodeId: string, css: string): string;
package/dist/css.js ADDED
@@ -0,0 +1,57 @@
1
+ // The custom-CSS box (spec 2026-09-14 §4): a per-section snippet the
2
+ // wrapper nests under the section's own [data-smd-node] selector. CSS
3
+ // can do two things beyond styling — pull a remote stylesheet and beacon
4
+ // to a host — so those, the two legacy code hooks and the tag-closing
5
+ // sequence are refused at save and stripped at render, in that order.
6
+ // The checks run on the snippet with its CSS escapes decoded, so
7
+ // `\75 rl(` cannot spell `url(` past them; and the braces must balance,
8
+ // so a `}` cannot step out of the node's selector block.
9
+ export const CSS_MAX = 4000;
10
+ const REFUSED = [
11
+ [/@import/i, "@import is not allowed."],
12
+ [/url\s*\(/i, "url() is not allowed."],
13
+ [/expression\s*\(/i, "expression() is not allowed."],
14
+ [/behavior\s*:/i, "behavior: is not allowed."],
15
+ [/<\//, '"</" is not allowed.'],
16
+ ];
17
+ /** The snippet as the CSS tokenizer reads it: `\HHHHHH ` and `\c` escapes replaced by their characters. */
18
+ function unescapeCss(css) {
19
+ return css
20
+ .replace(/\\([0-9a-fA-F]{1,6})[ \t\n\r\f]?/g, (_, hex) => {
21
+ const code = parseInt(hex, 16);
22
+ return code === 0 || code > 0x10ffff ? "\ufffd" : String.fromCodePoint(code);
23
+ })
24
+ .replace(/\\(.)/gs, "$1");
25
+ }
26
+ /** Every `{` closed by its own `}`, never a `}` first — the snippet stays inside the node's block. */
27
+ function bracesBalance(css) {
28
+ let depth = 0;
29
+ for (const ch of css) {
30
+ if (ch === "{")
31
+ depth++;
32
+ else if (ch === "}" && --depth < 0)
33
+ return false;
34
+ }
35
+ return depth === 0;
36
+ }
37
+ /** The first refusal, or null for a snippet the wrapper will render. */
38
+ export function cssProblem(css) {
39
+ if (css.length > CSS_MAX)
40
+ return `At most ${CSS_MAX} characters.`;
41
+ const decoded = unescapeCss(css);
42
+ for (const [re, message] of REFUSED)
43
+ if (re.test(css) || re.test(decoded))
44
+ return message;
45
+ if (!bracesBalance(css))
46
+ return "Braces must balance.";
47
+ return null;
48
+ }
49
+ /** The snippet when it is a clean string, else "". */
50
+ export function safeCss(css) {
51
+ return typeof css === "string" && css !== "" && cssProblem(css) === null ? css : "";
52
+ }
53
+ /** Nested under the node's selector — every rule inside reaches the section only. */
54
+ export function scopedCss(nodeId, css) {
55
+ const id = nodeId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
56
+ return `[data-smd-node="${id}"]{${css}}`;
57
+ }
@@ -0,0 +1,26 @@
1
+ import type { ComponentType, ReactElement } from "react";
2
+ import { SMD_KEY, type SmdInternal } from "../section-wrapper.tsx";
3
+ import type { StyleProps } from "../styles.ts";
4
+ import { type RichTextElement } from "../richtext-render.tsx";
5
+ export type CustomOptions = {
6
+ /** The project's bound RichText (elements and <SmoodlyImage>); absent, the root renderer over `elements`. */
7
+ RichText?: ComponentType<{
8
+ doc: unknown;
9
+ }>;
10
+ breakpoint?: string;
11
+ };
12
+ export type CustomSectionProps = {
13
+ rows?: unknown;
14
+ settings?: Record<string, unknown>;
15
+ styles: StyleProps & {
16
+ [SMD_KEY]?: SmdInternal;
17
+ };
18
+ };
19
+ export type CustomSectionComponent = ((props: CustomSectionProps) => ReactElement) & {
20
+ schema: {
21
+ name: string;
22
+ };
23
+ };
24
+ export declare function createCustomSection({ RichText: Bound, breakpoint, elements }: CustomOptions & {
25
+ elements?: RichTextElement[];
26
+ }): CustomSectionComponent;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Section, SMD_KEY } from "../section-wrapper.js";
3
+ import { RichText } from "../richtext-render.js";
4
+ import { FIELD_ATTR, ITEM_ATTR } from "../marks.js";
5
+ import { CUSTOM_NODE_TYPE, DEFAULT_BREAKPOINT } from "./schema.js";
6
+ import { customCss } from "./css.js";
7
+ const isRow = (r) => typeof r === "object" && r !== null && Array.isArray(r.columns);
8
+ export function createCustomSection({ RichText: Bound, breakpoint = DEFAULT_BREAKPOINT, elements = [] }) {
9
+ const Body = Bound ?? (({ doc }) => _jsx(RichText, { doc: doc, elements: elements }));
10
+ function CustomSection({ rows, styles }) {
11
+ const list = Array.isArray(rows) ? rows.filter(isRow) : [];
12
+ const smd = styles?.[SMD_KEY];
13
+ const withLayout = smd?.node && list.length > 0
14
+ ? { ...styles, [SMD_KEY]: { ...smd, layoutCss: customCss(smd.node, list, breakpoint) } }
15
+ : styles;
16
+ return (_jsx(Section, { styles: withLayout, "data-custom": "", children: list.map((row, i) => (_jsx("div", { className: row.reverse ? "smd-row smd-row--reverse" : "smd-row", "data-row": i, [ITEM_ATTR]: `rows.${i}`, children: row.columns.map((doc, j) => (_jsx("div", { className: "smd-col", [FIELD_ATTR]: `columns.${j}`, children: _jsx(Body, { doc: doc }) }, j))) }, i))) }));
17
+ }
18
+ CustomSection.schema = { name: CUSTOM_NODE_TYPE };
19
+ return CustomSection;
20
+ }
@@ -0,0 +1,3 @@
1
+ import { type CustomRow } from "./schema.ts";
2
+ export declare const isCssLength: (s: string) => boolean;
3
+ export declare function customCss(nodeId: string, rows: CustomRow[], breakpoint: string): string;
@@ -0,0 +1,28 @@
1
+ // The custom section's layout CSS (spec 2026-09-14 §6): emitted by
2
+ // Smoodly, scoped under the node like the custom-CSS box, computed from
3
+ // the stored NUMBERS and whitelisted keywords only — no editor string
4
+ // reaches it. One rule per row by data-row (nth-child would count the
5
+ // <style> children), one breakpoint that stacks every row.
6
+ import { ALIGN_VALUES, GAP_VALUES, SPAN_TOTAL } from "./schema.js";
7
+ import { renormalize } from "./rows.js";
8
+ const LENGTH_RE = /^\d+(?:\.\d+)?(?:px|rem|em)$/;
9
+ export const isCssLength = (s) => LENGTH_RE.test(s);
10
+ const escapeId = (id) => id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
11
+ export function customCss(nodeId, rows, breakpoint) {
12
+ const s = `[data-smd-node="${escapeId(nodeId)}"]`;
13
+ let css = `${s} .smd-row{display:grid;max-width:var(--smd-width,var(--container,none));margin-inline:auto;padding-inline:var(--smd-gutter,1rem);box-sizing:border-box}` +
14
+ `${s} .smd-row+.smd-row{margin-top:2rem}`;
15
+ rows.forEach((row, i) => {
16
+ const n = Array.isArray(row.columns) && row.columns.length > 0 ? row.columns.length : 1;
17
+ const spans = Array.isArray(row.spans) && row.spans.length === n && row.spans.every((x) => Number.isInteger(x) && x >= 1) && row.spans.reduce((a, b) => a + b, 0) === SPAN_TOTAL
18
+ ? row.spans
19
+ : renormalize(Array(n).fill(1));
20
+ const gap = GAP_VALUES[row.gap] ?? GAP_VALUES.md;
21
+ const align = ALIGN_VALUES[row.align] ?? ALIGN_VALUES.top;
22
+ css += `${s} [data-row="${i}"]{grid-template-columns:${spans.map((x) => `${x}fr`).join(" ")};gap:${gap};align-items:${align}}`;
23
+ });
24
+ // The reverse stack is a column flex box, whose cross axis is HORIZONTAL: the row's own align-items (Top → start)
25
+ // would shrink an image-only column to zero width, so the stack resets it (last in the sheet, equal specificity, wins).
26
+ css += `@media (max-width:${breakpoint}){${s} .smd-row{grid-template-columns:1fr}${s} .smd-row--reverse{display:flex;flex-direction:column-reverse;align-items:stretch}}`;
27
+ return css;
28
+ }
@@ -0,0 +1,26 @@
1
+ import type { ComponentType } from "react";
2
+ import type { Toolset } from "../toolset.ts";
3
+ import { customSchema } from "./schema.ts";
4
+ export * from "./schema.ts";
5
+ export * from "./rows.ts";
6
+ export * from "./css.ts";
7
+ export { createCustomSection } from "./CustomSection.tsx";
8
+ export type { CustomOptions, CustomSectionProps, CustomSectionComponent } from "./CustomSection.tsx";
9
+ /** `config.custom` / `site.custom` as written (spec 2026-09-14 §6). */
10
+ export type CustomConfigInput = {
11
+ breakpoint?: string;
12
+ toolset?: Toolset;
13
+ RichText?: ComponentType<{
14
+ doc: unknown;
15
+ }>;
16
+ };
17
+ export type ResolvedCustom = {
18
+ breakpoint: string;
19
+ toolset: Toolset;
20
+ RichText?: ComponentType<{
21
+ doc: unknown;
22
+ }>;
23
+ };
24
+ /** Every section map the walks build gets the custom schema too (spec 2026-09-14 §6), so a column's images resolve and count as usage. */
25
+ export declare function withCustomSchema<T>(sections: readonly T[], toolset: Toolset): (T | ReturnType<typeof customSchema>)[];
26
+ export declare function resolveCustom(input?: CustomConfigInput): ResolvedCustom;
@@ -0,0 +1,16 @@
1
+ import { DEFAULT_BREAKPOINT, customSchema, customToolset } from "./schema.js";
2
+ import { isCssLength } from "./css.js";
3
+ export * from "./schema.js";
4
+ export * from "./rows.js";
5
+ export * from "./css.js";
6
+ export { createCustomSection } from "./CustomSection.js";
7
+ /** Every section map the walks build gets the custom schema too (spec 2026-09-14 §6), so a column's images resolve and count as usage. */
8
+ export function withCustomSchema(sections, toolset) {
9
+ return [...sections, customSchema(toolset)];
10
+ }
11
+ export function resolveCustom(input) {
12
+ const breakpoint = input?.breakpoint ?? DEFAULT_BREAKPOINT;
13
+ if (!isCssLength(breakpoint))
14
+ throw new Error(`smoodly: custom.breakpoint "${breakpoint}" must be a length like "48rem" or "768px".`);
15
+ return { breakpoint, toolset: customToolset(input?.toolset), ...(input?.RichText ? { RichText: input.RichText } : {}) };
16
+ }
@@ -0,0 +1,28 @@
1
+ import { type CustomRow } from "./schema.ts";
2
+ export declare const EMPTY_DOC: {
3
+ type: string;
4
+ content: {
5
+ type: string;
6
+ }[];
7
+ };
8
+ export declare function defaultRow(columns?: number): CustomRow;
9
+ /** Integers ≥ 1 summing to 12, proportions kept: scale, floor, hand the
10
+ * remainder out by largest fraction (ties to the earlier column), then
11
+ * lift any 0 to 1 from the largest column. */
12
+ export declare function renormalize(spans: number[]): number[];
13
+ export declare function setColumnCount(row: CustomRow, count: number): CustomRow;
14
+ /** The boundary between columns `handle` and `handle + 1` moved so the left side is `left` twelfths; both sides keep ≥ 1. */
15
+ export declare function setSpan(spans: number[], handle: number, left: number): number[];
16
+ /** The left span a pointer at `x` over a bar `width` px wide asks for, for the handle between `handle` and `handle + 1`. */
17
+ export declare function spanAtX(x: number, width: number, spans: number[], handle: number): number;
18
+ export declare function presetsFor(n: number): {
19
+ label: string;
20
+ spans: number[];
21
+ }[];
22
+ export declare function concatDocs(a: unknown, b: unknown): unknown;
23
+ /** The structural problems the publish validator reports, by dotted path. */
24
+ export declare function rowsProblems(rows: unknown): {
25
+ field: string;
26
+ message: string;
27
+ }[];
28
+ export declare function columnsLabel(rows: unknown): string;