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.
- package/README.md +176 -26
- package/dist/admin/client-ops.js +1 -1
- package/dist/admin/custom-section.d.ts +10 -0
- package/dist/admin/custom-section.js +10 -0
- package/dist/admin/editor/CanvasOverlay.d.ts +4 -4
- package/dist/admin/editor/CanvasOverlay.js +8 -6
- package/dist/admin/editor/EditorView.js +140 -108
- package/dist/admin/editor/GlobalInspector.d.ts +27 -0
- package/dist/admin/editor/GlobalInspector.js +13 -0
- package/dist/admin/editor/InsertPicker.d.ts +6 -5
- package/dist/admin/editor/InsertPicker.js +6 -4
- package/dist/admin/editor/InspectorFields.d.ts +21 -0
- package/dist/admin/editor/InspectorFields.js +22 -0
- package/dist/admin/editor/Outline.d.ts +0 -1
- package/dist/admin/editor/Outline.js +3 -3
- package/dist/admin/editor/{shared-edit.d.ts → global-edit.d.ts} +10 -10
- package/dist/admin/editor/{shared-edit.js → global-edit.js} +4 -4
- package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
- package/dist/admin/editor/{useSharedEdit.js → useGlobalEdit.js} +4 -4
- package/dist/admin/forms/FieldWidget.d.ts +14 -1
- package/dist/admin/forms/FieldWidget.js +38 -11
- package/dist/admin/forms/ListField.d.ts +9 -1
- package/dist/admin/forms/ListField.js +16 -8
- package/dist/admin/forms/RichtextElementCard.js +11 -5
- package/dist/admin/forms/RowsWidget.d.ts +13 -0
- package/dist/admin/forms/RowsWidget.js +26 -0
- package/dist/admin/forms/SpanBar.d.ts +5 -0
- package/dist/admin/forms/SpanBar.js +37 -0
- package/dist/admin/forms/richtext-insert.d.ts +2 -2
- package/dist/admin/forms/richtext-insert.js +3 -3
- package/dist/admin/forms/richtext-nodes.js +1 -1
- package/dist/admin/forms/tabs.d.ts +15 -9
- package/dist/admin/forms/tabs.js +19 -23
- package/dist/admin/forms/useRichtextEditor.js +8 -1
- package/dist/admin/global-items.d.ts +9 -0
- package/dist/admin/{shared-items.js → global-items.js} +17 -16
- package/dist/admin/index.d.ts +3 -3
- package/dist/admin/index.js +1 -1
- package/dist/admin/ops-impl.js +65 -54
- package/dist/admin/ops.d.ts +6 -6
- package/dist/admin/serialize.d.ts +18 -6
- package/dist/admin/serialize.js +27 -4
- package/dist/admin/shell/AdminApp.js +6 -6
- package/dist/admin/shell/GlobalFields.d.ts +24 -0
- package/dist/admin/shell/GlobalFields.js +19 -0
- package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
- package/dist/admin/shell/{SharedForm.js → GlobalForm.js} +36 -26
- package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
- package/dist/admin/shell/{SharedList.js → GlobalList.js} +9 -9
- package/dist/admin/shell/IconRail.js +13 -6
- package/dist/admin/shell/PagesList.js +1 -6
- package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
- package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
- package/dist/admin/tree-ops.d.ts +10 -8
- package/dist/admin/tree-ops.js +28 -16
- package/dist/admin/ui/ActionMenu.d.ts +3 -1
- package/dist/admin/ui/ActionMenu.js +4 -3
- package/dist/admin/ui/HistoryModal.js +1 -1
- package/dist/admin/ui/PublishControl.d.ts +3 -1
- package/dist/admin/ui/PublishControl.js +2 -2
- package/dist/admin/ui/primitives.d.ts +4 -2
- package/dist/admin/ui/primitives.js +3 -3
- package/dist/admin/ui/theme.js +61 -18
- package/dist/admin/validate.d.ts +27 -2
- package/dist/admin/validate.js +47 -10
- package/dist/collections.d.ts +3 -3
- package/dist/collections.js +1 -1
- package/dist/conditions.d.ts +33 -0
- package/dist/conditions.js +79 -0
- package/dist/config.d.ts +26 -19
- package/dist/config.js +26 -25
- package/dist/css.d.ts +7 -0
- package/dist/css.js +57 -0
- package/dist/custom/CustomSection.d.ts +26 -0
- package/dist/custom/CustomSection.js +20 -0
- package/dist/custom/css.d.ts +3 -0
- package/dist/custom/css.js +28 -0
- package/dist/custom/index.d.ts +26 -0
- package/dist/custom/index.js +16 -0
- package/dist/custom/rows.d.ts +28 -0
- package/dist/custom/rows.js +129 -0
- package/dist/custom/schema.d.ts +32 -0
- package/dist/custom/schema.js +39 -0
- package/dist/entry-page.d.ts +5 -0
- package/dist/entry-page.js +1 -1
- package/dist/entry-store.d.ts +4 -4
- package/dist/entry-store.js +17 -17
- package/dist/fields.d.ts +5 -2
- package/dist/fields.js +4 -2
- package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
- package/dist/{shared-id.js → global-id.js} +4 -4
- package/dist/global.d.ts +57 -0
- package/dist/global.js +51 -0
- package/dist/index.d.ts +31 -21
- package/dist/index.js +11 -6
- package/dist/media.js +1 -1
- package/dist/next/index.d.ts +1 -1
- package/dist/next/page-renderer.d.ts +4 -2
- package/dist/next/page-renderer.js +16 -12
- package/dist/next/queries.d.ts +6 -6
- package/dist/next/queries.js +7 -7
- package/dist/page.js +2 -2
- package/dist/pairing.d.ts +13 -7
- package/dist/pairing.js +4 -4
- package/dist/paths.d.ts +21 -0
- package/dist/paths.js +25 -0
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +4 -4
- package/dist/render.d.ts +23 -1
- package/dist/render.js +12 -6
- package/dist/resolve.d.ts +11 -6
- package/dist/resolve.js +22 -12
- package/dist/revalidate.d.ts +4 -4
- package/dist/revalidate.js +4 -4
- package/dist/richtext-render.js +2 -2
- package/dist/richtext-walk.d.ts +5 -5
- package/dist/richtext-walk.js +4 -4
- package/dist/schema.d.ts +15 -10
- package/dist/schema.js +19 -7
- package/dist/section-wrapper.d.ts +18 -6
- package/dist/section-wrapper.js +24 -17
- package/dist/site.d.ts +29 -14
- package/dist/site.js +55 -28
- package/dist/sql-space.js +1 -1
- package/dist/sql.js +2 -2
- package/dist/store.d.ts +7 -0
- package/dist/store.js +6 -0
- package/dist/styles.d.ts +27 -0
- package/dist/styles.js +60 -0
- package/dist/supabase-entry-store.js +14 -14
- package/dist/supabase-store.js +2 -1
- package/dist/zones.d.ts +9 -7
- package/dist/zones.js +5 -5
- package/package.json +1 -1
- package/dist/admin/editor/SharedInspector.d.ts +0 -23
- package/dist/admin/editor/SharedInspector.js +0 -13
- package/dist/admin/editor/useSharedEdit.d.ts +0 -6
- package/dist/admin/shared-items.d.ts +0 -9
- package/dist/admin/shell/SharedFields.d.ts +0 -21
- package/dist/admin/shell/SharedFields.js +0 -15
- package/dist/shared.d.ts +0 -53
- 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
|
|
1
|
+
import type { Descriptor } from "./fields.ts";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
-
import type {
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
/** `
|
|
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
|
-
|
|
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 = ["
|
|
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
|
-
//
|
|
25
|
-
//
|
|
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
|
|
33
|
-
const
|
|
34
|
-
for (const item of config.registry.
|
|
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 (
|
|
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
|
-
|
|
43
|
+
globalSeen.add(item.name);
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
/** A visual item renders through a registered section. */
|
|
45
|
-
function
|
|
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.
|
|
49
|
+
for (const item of config.registry.globals ?? []) {
|
|
48
50
|
if (item.section !== undefined && !sections.has(item.section)) {
|
|
49
|
-
throw new Error(`smoodly:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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,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;
|