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.
- package/README.md +156 -21
- 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 +5 -5
- package/dist/admin/editor/EditorView.js +140 -108
- package/dist/admin/editor/GlobalInspector.d.ts +24 -0
- package/dist/admin/editor/{SharedInspector.js → GlobalInspector.js} +4 -4
- 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/HistoryModal.js +1 -1
- package/dist/admin/ui/primitives.d.ts +3 -1
- package/dist/admin/ui/primitives.js +2 -2
- package/dist/admin/ui/theme.js +54 -14
- 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-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 +28 -18
- package/dist/index.js +10 -5
- 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 +9 -9
- 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/refs.d.ts +2 -2
- package/dist/refs.js +4 -4
- package/dist/render.d.ts +13 -1
- package/dist/render.js +10 -5
- 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 +15 -5
- package/dist/site.js +24 -10
- 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/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
package/dist/admin/tree-ops.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { GLOBAL_NODE_TYPE } from "../global.js";
|
|
2
2
|
import { setAtPath } from "./field-paths.js";
|
|
3
3
|
export function zoneOf(tree, id) {
|
|
4
4
|
for (const [zone, nodes] of Object.entries(tree.zones)) {
|
|
@@ -64,6 +64,18 @@ export function patchNode(tree, id, ns, key, value) {
|
|
|
64
64
|
return tree;
|
|
65
65
|
return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, [ns]: { ...(n[ns] ?? {}), [key]: value } } : n)));
|
|
66
66
|
}
|
|
67
|
+
/** The node's custom CSS (spec 2026-09-14 §4); undefined or "" removes the key. */
|
|
68
|
+
export function patchNodeCss(tree, id, css) {
|
|
69
|
+
const zone = zoneOf(tree, id);
|
|
70
|
+
if (!zone)
|
|
71
|
+
return tree;
|
|
72
|
+
return withZone(tree, zone, tree.zones[zone].map((n) => {
|
|
73
|
+
if (n.id !== id)
|
|
74
|
+
return n;
|
|
75
|
+
const { css: _old, ...rest } = n;
|
|
76
|
+
return css === undefined || css === "" ? rest : { ...rest, css };
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
67
79
|
/** patchNode for a dotted path inside `fields` (spec 2026-09-10 §1):
|
|
68
80
|
* `points.2.heading` sets one item's heading and copies only the spine. */
|
|
69
81
|
export function patchNodePath(tree, id, path, value) {
|
|
@@ -89,11 +101,11 @@ export function allowedSections(policy, all) {
|
|
|
89
101
|
}
|
|
90
102
|
/** The visual items the picker offers in a zone: materialized (an id is
|
|
91
103
|
* known) and rendering a section the zone's policy allows. */
|
|
92
|
-
export function
|
|
104
|
+
export function allowedGlobals(policy, globals, globalIds) {
|
|
93
105
|
if (!policy || policy.kind === "locked")
|
|
94
106
|
return [];
|
|
95
|
-
return
|
|
96
|
-
if (s.section === undefined ||
|
|
107
|
+
return globals.filter((s) => {
|
|
108
|
+
if (s.section === undefined || globalIds[s.name] === undefined)
|
|
97
109
|
return false;
|
|
98
110
|
return policy.allow === "*" || policy.allow.includes(s.section);
|
|
99
111
|
});
|
|
@@ -101,7 +113,7 @@ export function allowedShared(policy, shared, sharedIds) {
|
|
|
101
113
|
/** A page places a visual item as this node (spec 2026-09-07 §6): `item`
|
|
102
114
|
* names the registration, `ref` is the item's row id. */
|
|
103
115
|
export function placementNode(item, ref, id) {
|
|
104
|
-
return { id, type:
|
|
116
|
+
return { id, type: GLOBAL_NODE_TYPE, fields: { item: item.name, ref } };
|
|
105
117
|
}
|
|
106
118
|
/** The empty value a fresh node's field starts at, by descriptor type.
|
|
107
119
|
* Shape matters: a list-valued field seeded with "" is a type lie that
|
|
@@ -111,10 +123,10 @@ const EMPTY_BY_TYPE = { refList: [], object: {} };
|
|
|
111
123
|
* broken-image icon or a stray zero, worse than nothing at all. With no
|
|
112
124
|
* sample, leave the key out of `fields` entirely rather than seed a lie. */
|
|
113
125
|
const ABSENT_WHEN_UNSAMPLED = new Set(["image", "video", "file", "number"]);
|
|
114
|
-
/** Also fills an element node (an `AdminElement` is a section shape plus contexts). */
|
|
126
|
+
/** Also fills an element node (an `AdminElement` is a section shape plus contexts). Never seeds `styles` or `css`: Default stores nothing (spec 2026-09-14 §2). */
|
|
115
127
|
export function nodeFromSample(section, id) {
|
|
116
128
|
const fields = {};
|
|
117
|
-
const
|
|
129
|
+
const settings = {};
|
|
118
130
|
for (const [key, d] of Object.entries(section.fields)) {
|
|
119
131
|
const sampled = section.sample?.[key] ?? d.default ?? (d.type === "boolean" ? false : undefined);
|
|
120
132
|
if (sampled !== undefined)
|
|
@@ -124,20 +136,20 @@ export function nodeFromSample(section, id) {
|
|
|
124
136
|
else
|
|
125
137
|
fields[key] = EMPTY_BY_TYPE[d.type] ?? "";
|
|
126
138
|
}
|
|
127
|
-
for (const [key, d] of Object.entries(section.
|
|
139
|
+
for (const [key, d] of Object.entries(section.settings)) {
|
|
128
140
|
const v = section.sample?.[key] ?? d.default;
|
|
129
141
|
if (v !== undefined)
|
|
130
|
-
|
|
142
|
+
settings[key] = v;
|
|
131
143
|
}
|
|
132
|
-
return { id, type: section.name, fields,
|
|
144
|
+
return { id, type: section.name, fields, settings };
|
|
133
145
|
}
|
|
134
146
|
/** A locked zone holds exactly one node the editor can never insert or
|
|
135
147
|
* remove — so someone has to put it there. This does, from the component's
|
|
136
|
-
* sample (or, for a zone bound to a
|
|
148
|
+
* sample (or, for a zone bound to a global item, a placement), for every
|
|
137
149
|
* locked zone that is empty or missing. Runs when the editor loads a draft,
|
|
138
150
|
* which also covers pages that predate a locked zone being added to their
|
|
139
151
|
* schema. Returns the same tree when nothing changed. */
|
|
140
|
-
export function fillLockedZones(tree, policies, sections,
|
|
152
|
+
export function fillLockedZones(tree, policies, sections, globalIds = {}) {
|
|
141
153
|
let next = null;
|
|
142
154
|
for (const [zone, policy] of Object.entries(policies)) {
|
|
143
155
|
if (policy.kind !== "locked")
|
|
@@ -145,14 +157,14 @@ export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
|
|
|
145
157
|
if ((tree.zones[zone] ?? []).length > 0)
|
|
146
158
|
continue;
|
|
147
159
|
let node = null;
|
|
148
|
-
if (policy.
|
|
149
|
-
// Bound to a
|
|
160
|
+
if (policy.global !== undefined) {
|
|
161
|
+
// Bound to a global item (spec 2026-09-07 §4): a placement, once
|
|
150
162
|
// the editor has loaded the item's row id; until then leave the
|
|
151
163
|
// zone empty rather than seed a plain section there.
|
|
152
|
-
const ref =
|
|
164
|
+
const ref = globalIds[policy.global];
|
|
153
165
|
if (ref === undefined)
|
|
154
166
|
continue;
|
|
155
|
-
node = placementNode({ name: policy.
|
|
167
|
+
node = placementNode({ name: policy.global }, ref, newNodeId(GLOBAL_NODE_TYPE));
|
|
156
168
|
}
|
|
157
169
|
else {
|
|
158
170
|
const section = sections.find((s) => s.name === policy.component);
|
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
// the left — newest first, the unpublished draft tagged Draft and the row
|
|
5
5
|
// the site serves Live — and the selected version on the right, rendered
|
|
6
6
|
// by the screen that opened it (an iframe of the site's draft route for a
|
|
7
|
-
// page, a disabled form for an entry or a
|
|
7
|
+
// page, a disabled form for an entry or a global item). Restore confirms,
|
|
8
8
|
// hands the id to the screen, and closes once the restore landed.
|
|
9
9
|
import { useEffect, useState } from "react";
|
|
10
10
|
import { versionTime } from "./format.js";
|
|
@@ -44,11 +44,13 @@ export declare function Grip(props: ButtonHTMLAttributes<HTMLButtonElement>): im
|
|
|
44
44
|
/** Label + optional counter above a control — the handoff's Input/Textarea head.
|
|
45
45
|
* `asDiv` for groups holding more than one control: an implicit <label>
|
|
46
46
|
* activates its first labelable descendant, so wrapping several is a trap. */
|
|
47
|
-
export declare function Field({ label, mark, counter, hint, error, asDiv, children, style, className, path }: {
|
|
47
|
+
export declare function Field({ label, mark, counter, action, hint, error, asDiv, children, style, className, path }: {
|
|
48
48
|
label?: ReactNode;
|
|
49
49
|
/** A small glyph after the label — the field's "same in every language" mark. */
|
|
50
50
|
mark?: ReactNode;
|
|
51
51
|
counter?: ReactNode;
|
|
52
|
+
/** A small text action at the head's right end, after the counter (a list's Collapse all). */
|
|
53
|
+
action?: ReactNode;
|
|
52
54
|
hint?: ReactNode;
|
|
53
55
|
error?: ReactNode;
|
|
54
56
|
asDiv?: boolean;
|
|
@@ -66,9 +66,9 @@ export function Grip(props) {
|
|
|
66
66
|
/** Label + optional counter above a control — the handoff's Input/Textarea head.
|
|
67
67
|
* `asDiv` for groups holding more than one control: an implicit <label>
|
|
68
68
|
* activates its first labelable descendant, so wrapping several is a trap. */
|
|
69
|
-
export function Field({ label, mark, counter, hint, error, asDiv, children, style, className, path }) {
|
|
69
|
+
export function Field({ label, mark, counter, action, hint, error, asDiv, children, style, className, path }) {
|
|
70
70
|
const Tag = asDiv ? "div" : "label";
|
|
71
|
-
return (_jsxs(Tag, { className: cx("sm-field", className), style: style, "data-sm-path": path, children: [(label || counter) && (_jsxs("span", { className: "sm-field__head", children: [_jsxs("span", { className: "sm-label", children: [label, mark] }), counter && _jsx("span", { className: "sm-counter", children: counter })] })), children, hint && _jsx("span", { className: "sm-hint", children: hint }), error && _jsx("span", { className: "sm-error", children: error })] }));
|
|
71
|
+
return (_jsxs(Tag, { className: cx("sm-field", className), style: style, "data-sm-path": path, children: [(label || counter || action) && (_jsxs("span", { className: "sm-field__head", children: [_jsxs("span", { className: "sm-label", children: [label, mark] }), (counter || action) && (_jsxs("span", { className: "sm-field__aside", children: [counter && _jsx("span", { className: "sm-counter", children: counter }), action] }))] })), children, hint && _jsx("span", { className: "sm-hint", children: hint }), error && _jsx("span", { className: "sm-error", children: error })] }));
|
|
72
72
|
}
|
|
73
73
|
/** The mark beside a field that is NOT `.localized()`: a globe-off, so a
|
|
74
74
|
* multilingual form needs no "shared across languages" grouping. */
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -25,7 +25,7 @@ const TOKENS_LIGHT = `
|
|
|
25
25
|
--line:#E8E3DA; --line-dashed:#D8D1C4; --line-soft:#EFEBE3;
|
|
26
26
|
--text:#1F1D1A; --text-secondary:#4E4942; --text-muted:#7A746A; --text-subtle:#A39C90; --text-disabled:#C4BDB1;
|
|
27
27
|
--selection:var(--accent); --nav-active-bg:var(--accent-soft); --nav-active-fg:var(--accent);
|
|
28
|
-
--selection-
|
|
28
|
+
--selection-global:#C4620C;
|
|
29
29
|
--hover:rgba(31,29,26,.05); --press:rgba(31,29,26,.09); --control-hover:#F8F6F1;
|
|
30
30
|
--btn-secondary-bg:var(--surface); --btn-secondary-hover:#F8F6F1; --tile-hover:var(--surface);
|
|
31
31
|
--bar-bg:transparent; --bar-border:0; --menu-border:0; --card-divider:0;
|
|
@@ -63,7 +63,7 @@ const TOKENS_LIGHT = `
|
|
|
63
63
|
`;
|
|
64
64
|
const TOKENS_DARK = `
|
|
65
65
|
--accent:#6CC4A6; --accent-hover:#82D2B8; --accent-soft:rgba(108,196,166,.18); --accent-line:#6CC4A6; --on-accent:#0B1F18;
|
|
66
|
-
--selection-
|
|
66
|
+
--selection-global:#F09A5C;
|
|
67
67
|
--bg:#1C1A17; --canvas:#141210; --surface:#27241F; --surface-form:#27241F; --surface-inset:#1C1A17;
|
|
68
68
|
--input-bg:#211E1A; --input-hover-bg:#211E1A; --input-focus-bg:#211E1A;
|
|
69
69
|
--line:#3A3630; --line-dashed:#4A453D; --line-soft:#3A3630;
|
|
@@ -110,7 +110,7 @@ const SCALE = `
|
|
|
110
110
|
--table-pad:8px 12px; --table-top:80px; --canvas-pad:24px 40px 0; --card-pad:16px 18px; --card-gap:12px; --form-pad:32px 40px;
|
|
111
111
|
--border:1px solid var(--line);
|
|
112
112
|
--outline-selection:2px solid var(--selection);
|
|
113
|
-
--outline-selection-
|
|
113
|
+
--outline-selection-global:2px solid var(--selection-global);
|
|
114
114
|
`;
|
|
115
115
|
/* ── TEMPORARY: the original look, kept for side-by-side comparison while
|
|
116
116
|
the warm restyle lands. Frozen copies of the three strings above as they
|
|
@@ -133,7 +133,7 @@ const TECHNICAL_LIGHT = `
|
|
|
133
133
|
--text:var(--gray-900); --text-muted:var(--gray-600); --text-subtle:var(--gray-500);
|
|
134
134
|
--accent:var(--brand-600); --on-accent:#FFFFFF; --accent-soft:var(--brand-50);
|
|
135
135
|
--selection:var(--brand-600); --nav-active-bg:var(--brand-100); --nav-active-fg:var(--brand-600);
|
|
136
|
-
--selection-
|
|
136
|
+
--selection-global:#D2610A;
|
|
137
137
|
--hover:rgba(15,20,32,.045); --press:rgba(15,20,32,.08);
|
|
138
138
|
--control-hover:var(--gray-100);
|
|
139
139
|
|
|
@@ -162,7 +162,7 @@ const TECHNICAL_DARK = `
|
|
|
162
162
|
--line:#2A2F3B; --line-dashed:#2A2F3B;
|
|
163
163
|
--text:#EEF0F5; --text-muted:#8C94A8; --text-subtle:#6B7386;
|
|
164
164
|
--accent:var(--brand-300); --on-accent:#06122F; --accent-soft:rgba(110,150,255,.16);
|
|
165
|
-
--selection-
|
|
165
|
+
--selection-global:#F2A35F;
|
|
166
166
|
--nav-active-bg:rgba(110,150,255,.14); --nav-active-fg:var(--brand-200);
|
|
167
167
|
--hover:rgba(238,240,245,.05); --press:rgba(238,240,245,.09);
|
|
168
168
|
--control-hover:var(--input-bg);
|
|
@@ -190,7 +190,7 @@ const TECHNICAL_SCALE = `
|
|
|
190
190
|
--nav-w:72px; --inspector-w:380px;
|
|
191
191
|
--border:1px solid var(--line);
|
|
192
192
|
--outline-selection:1.5px solid var(--selection);
|
|
193
|
-
--outline-selection-
|
|
193
|
+
--outline-selection-global:1.5px solid var(--selection-global);
|
|
194
194
|
`;
|
|
195
195
|
/* The tokens the warm restyle added, with the values the technical look
|
|
196
196
|
implied, so the frozen block above renders the old shapes too. */
|
|
@@ -304,8 +304,6 @@ body:has(.sm-admin){margin:0}
|
|
|
304
304
|
.sm-rail__btn[aria-current="page"]:hover,.sm-rail__btn[aria-expanded="true"]:hover{color:var(--nav-active-fg)}
|
|
305
305
|
.sm-rail__btn[aria-current="page"] .sm-rail__tile,.sm-rail__btn[aria-expanded="true"] .sm-rail__tile{background:var(--nav-active-bg)}
|
|
306
306
|
.sm-rail__tile svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
307
|
-
.sm-rail__chev{position:absolute;right:4px;bottom:3px;width:8px;height:8px}
|
|
308
|
-
.sm-rail__chev svg{width:8px;height:8px;stroke-width:3}
|
|
309
307
|
.sm-rail__label{letter-spacing:.01em;white-space:nowrap}
|
|
310
308
|
/* Not greyed: the reference renders every inactive item at --text and marks
|
|
311
309
|
the unavailable ones only by being inert (no pointer, no hover). */
|
|
@@ -325,6 +323,11 @@ body:has(.sm-admin){margin:0}
|
|
|
325
323
|
}
|
|
326
324
|
.sm-rail__menu[data-open="true"]{display:flex}
|
|
327
325
|
.sm-rail__menu--up{top:auto;bottom:0}
|
|
326
|
+
/* The Collections flyout opens on hover, so it is roomier (the names at --text-lg) and an invisible
|
|
327
|
+
bridge spans the 6px gap so the pointer can cross from the rail without closing it. */
|
|
328
|
+
.sm-rail__menu--collections{width:208px;padding:8px;gap:2px;font-size:var(--text-lg)}
|
|
329
|
+
.sm-rail__menu--collections::before{content:"";position:absolute;left:-8px;top:0;bottom:0;width:8px}
|
|
330
|
+
.sm-rail__menu--collections .sm-rail__menu-item{padding:9px 12px}
|
|
328
331
|
.sm-rail__menu-title{font-size:9px;font-weight:600;letter-spacing:var(--tracking-label);text-transform:uppercase;color:var(--text-muted);padding:6px 8px 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
329
332
|
.sm-rail__menu-item{
|
|
330
333
|
appearance:none;background:none;border:0;width:100%;display:flex;align-items:center;justify-content:space-between;
|
|
@@ -385,7 +388,7 @@ body:has(.sm-admin){margin:0}
|
|
|
385
388
|
.sm-btn--ok{background:var(--status-published-bg);color:var(--status-published-fg);font-weight:600;padding:0 18px}
|
|
386
389
|
.sm-btn:disabled,.sm-btn[aria-disabled="true"]{opacity:.5;cursor:default;pointer-events:none}
|
|
387
390
|
.sm-btn:focus-visible,.sm-iconbtn:focus-visible,.sm-nav-item:focus-visible,
|
|
388
|
-
.sm-tab:focus-visible,.sm-seg__opt:focus-visible,.sm-row:focus-visible{
|
|
391
|
+
.sm-tab:focus-visible,.sm-seg__opt:focus-visible,.sm-pill:focus-visible,.sm-row:focus-visible{
|
|
389
392
|
outline:2px solid var(--accent);outline-offset:1px
|
|
390
393
|
}
|
|
391
394
|
|
|
@@ -413,6 +416,10 @@ body:has(.sm-admin){margin:0}
|
|
|
413
416
|
.sm-label__mark{display:inline-flex;color:var(--text-subtle);cursor:help}
|
|
414
417
|
.sm-label__mark svg{width:13px;height:13px}
|
|
415
418
|
.sm-counter{font-size:var(--text-sm);color:var(--text-subtle)}
|
|
419
|
+
.sm-field__aside{display:flex;align-items:baseline;gap:10px;flex:none}
|
|
420
|
+
/* A head's text action (a list's Collapse all / Expand all): a small accent link. */
|
|
421
|
+
.sm-field__action{background:none;border:0;padding:0;font:inherit;font-size:var(--text-sm);font-weight:600;color:var(--accent);cursor:pointer}
|
|
422
|
+
.sm-field__action:hover{color:var(--accent-hover)}
|
|
416
423
|
.sm-hint{font-size:var(--text-sm);color:var(--text-muted)}
|
|
417
424
|
.sm-input,.sm-textarea,.sm-select{
|
|
418
425
|
width:100%;min-width:0;border:1px solid var(--input-border);border-radius:var(--radius-md);
|
|
@@ -504,7 +511,8 @@ body:has(.sm-admin){margin:0}
|
|
|
504
511
|
.sm-rtcard__icon{width:18px;height:18px;flex:none;border-radius:var(--radius-xs);background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-size:11px}
|
|
505
512
|
.sm-rtcard__title{flex:1;min-width:0;font-weight:600;font-size:var(--text-sm);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
506
513
|
.sm-rtcard__body{display:flex;flex-direction:column;gap:10px;padding:10px 12px 12px;border-top:1px solid var(--line)}
|
|
507
|
-
.
|
|
514
|
+
/* The whole image, letterboxed on the inset tone, never cropped: the card is a preview, not a crop. */
|
|
515
|
+
.sm-rtcard__thumb{display:block;width:100%;height:160px;object-fit:contain;border-radius:var(--radius-sm);background:var(--surface-inset)}
|
|
508
516
|
.sm-rtcard__file{font-size:var(--text-sm);color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
509
517
|
.sm-richtext__content blockquote{padding-left:12px;border-left:2px solid var(--line);color:var(--text-muted)}
|
|
510
518
|
.sm-richtext__content a{color:var(--accent);text-decoration:underline;text-underline-offset:3px;text-decoration-color:var(--accent-line)}
|
|
@@ -533,6 +541,23 @@ body:has(.sm-admin){margin:0}
|
|
|
533
541
|
display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:var(--control-h);padding:0 6px 0 14px;
|
|
534
542
|
border-radius:var(--radius-md);background:var(--surface-inset);color:var(--text-muted);font-size:var(--text-sm);
|
|
535
543
|
}
|
|
544
|
+
/* The custom section's row cards and the width bar (spec 2026-09-14 §6). */
|
|
545
|
+
.sm-rowcards{display:flex;flex-direction:column;gap:12px;align-items:stretch}
|
|
546
|
+
.sm-rowcard{display:flex;flex-direction:column;gap:12px;padding:12px;border:1px solid var(--line);border-radius:var(--radius-md);background:var(--surface);min-width:0}
|
|
547
|
+
.sm-rowcard__head{display:flex;align-items:center;gap:4px}
|
|
548
|
+
.sm-rowcard__title{flex:1;font-weight:600}
|
|
549
|
+
.sm-rowcard__presets{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
|
|
550
|
+
/* Secondary buttons sit on the white card: an outline so they read as buttons. */
|
|
551
|
+
.sm-rowcard__presets .sm-btn{height:var(--control-h-sm);padding:0 10px;font-size:var(--text-sm);border:1px solid var(--line)}
|
|
552
|
+
.sm-rowcards > .sm-btn{align-self:flex-start;border:1px solid var(--line)}
|
|
553
|
+
.sm-stepper{display:flex;align-items:center;gap:8px}
|
|
554
|
+
.sm-stepper__value{min-width:20px;text-align:center;font-weight:600}
|
|
555
|
+
.sm-spanbar{display:flex;gap:2px;height:32px;background:var(--seg-bg);border-radius:var(--radius-md);padding:3px;user-select:none}
|
|
556
|
+
.sm-spanbar__col{position:relative;display:flex;align-items:center;justify-content:center;min-width:0;background:var(--surface);border-radius:6px;font-size:var(--text-sm);color:var(--text-muted)}
|
|
557
|
+
.sm-spanbar__handle{position:absolute;right:-6px;top:0;bottom:0;width:10px;padding:0;border:0;background:transparent;cursor:col-resize;touch-action:none;z-index:1}
|
|
558
|
+
.sm-spanbar__handle::after{content:"";position:absolute;left:4px;top:8px;bottom:8px;width:2px;border-radius:1px;background:var(--text-muted)}
|
|
559
|
+
.sm-spanbar__handle:focus-visible{outline:none}
|
|
560
|
+
.sm-spanbar__handle:focus-visible::after,.sm-spanbar__handle:hover::after{background:var(--accent)}
|
|
536
561
|
/* A field revealed from a canvas click: one pulse of the accent, then nothing. */
|
|
537
562
|
.sm-field--reveal{animation:sm-reveal .9s ease-out;border-radius:var(--radius-sm)}
|
|
538
563
|
@keyframes sm-reveal{0%{outline:2px solid var(--selection);outline-offset:4px}100%{outline:2px solid transparent;outline-offset:4px}}
|
|
@@ -656,6 +681,18 @@ body:has(.sm-admin){margin:0}
|
|
|
656
681
|
.sm-seg__opt[aria-pressed="true"]{background:var(--surface);color:var(--text);font-weight:600;box-shadow:var(--shadow-seg)}
|
|
657
682
|
/* A locale the record does not have yet: subtle, still a button (it opens the add pane). */
|
|
658
683
|
.sm-seg__opt--absent{color:var(--text-subtle)}
|
|
684
|
+
/* f.radio as a pill row (spec 2026-09-14 §5): small chips a step below the tabs and the
|
|
685
|
+
segmented control — no track, the chosen pill filled in the soft accent, the rest bare.
|
|
686
|
+
Wraps when the labels need it. */
|
|
687
|
+
.sm-pills{display:flex;flex-wrap:wrap;gap:4px;padding:2px 0}
|
|
688
|
+
.sm-pill{
|
|
689
|
+
height:var(--control-h-sm);padding:0 10px;border:0;border-radius:var(--radius-btn);background:transparent;
|
|
690
|
+
color:var(--text-muted);font:inherit;font-size:var(--text-sm);font-weight:500;line-height:1;cursor:pointer;white-space:nowrap;
|
|
691
|
+
}
|
|
692
|
+
.sm-pill:hover{color:var(--text);background:var(--surface-inset)}
|
|
693
|
+
.sm-pill[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent);font-weight:600}
|
|
694
|
+
/* The custom-CSS box: code, no wrapping, no spellcheck. */
|
|
695
|
+
.sm-css{font-family:var(--font-mono);font-size:12px;line-height:1.5;white-space:pre;overflow-x:auto}
|
|
659
696
|
/* Switch (f.boolean, the SEO noindex toggle): a pill track, knob slides right when on. */
|
|
660
697
|
.sm-switch{width:36px;height:20px;padding:2px;border:0;border-radius:var(--radius-round);background:var(--seg-bg);cursor:pointer;display:inline-flex;align-items:center;flex:none}
|
|
661
698
|
.sm-switch[aria-checked="true"]{background:var(--accent)}
|
|
@@ -687,7 +724,7 @@ body:has(.sm-admin){margin:0}
|
|
|
687
724
|
.sm-fielderr__field{background:none;border:0;padding:0;font:inherit;color:inherit;text-decoration:underline;cursor:pointer}
|
|
688
725
|
|
|
689
726
|
/* ── cards ─────────────────────────────────────────────────────────── */
|
|
690
|
-
/* The inspector column (editor, entry and
|
|
727
|
+
/* The inspector column (editor, entry and global forms) is a stack of
|
|
691
728
|
cards; the technical look draws them flush with a hairline between. */
|
|
692
729
|
.sm-inspector{
|
|
693
730
|
display:flex;flex-direction:column;gap:var(--card-gap);min-height:0;overflow:auto;
|
|
@@ -704,7 +741,10 @@ body:has(.sm-admin){margin:0}
|
|
|
704
741
|
}
|
|
705
742
|
.sm-inspector__head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex:none}
|
|
706
743
|
.sm-inspector__body{display:flex;flex-direction:column;gap:14px}
|
|
707
|
-
/*
|
|
744
|
+
/* A tab's groups (spec 2026-09-14 §5): the section's own settings untitled, then Spacing & layout and Advanced under a hairline. */
|
|
745
|
+
.sm-inspector__group{display:flex;flex-direction:column;gap:12px}
|
|
746
|
+
.sm-inspector__group+.sm-inspector__group{margin-top:8px;padding-top:12px;border-top:1px solid var(--line)}
|
|
747
|
+
/* The form card: the main column of the entry and global forms. Every
|
|
708
748
|
field (and the banner) is a centred 680px column — a reading width —
|
|
709
749
|
while the card itself still fills the main area. */
|
|
710
750
|
.sm-form{
|
|
@@ -753,13 +793,13 @@ body:has(.sm-admin){margin:0}
|
|
|
753
793
|
letter-spacing:var(--blocktag-tracking);text-transform:var(--blocktag-transform);padding:3px 10px;
|
|
754
794
|
border-radius:var(--blocktag-radius);white-space:nowrap;
|
|
755
795
|
}
|
|
756
|
-
.sm-blocktag--
|
|
796
|
+
.sm-blocktag--global{background:var(--selection-global)}
|
|
757
797
|
.sm-blocktoolbar{
|
|
758
798
|
display:flex;gap:2px;padding:3px;background:var(--toolbar-bg);border-radius:var(--radius-btn);
|
|
759
799
|
box-shadow:var(--shadow-float);
|
|
760
800
|
}
|
|
761
801
|
.sm-blocktoolbar__note{font-family:var(--font-mono);font-size:var(--text-xs);color:var(--toolbar-fg);opacity:.7;padding:4px 8px}
|
|
762
|
-
/* The insert affordance between
|
|
802
|
+
/* The insert affordance between sections: a labelled pill (the label is
|
|
763
803
|
hidden in the technical look, leaving the 24px circle). */
|
|
764
804
|
.sm-plus{
|
|
765
805
|
display:inline-flex;align-items:center;gap:8px;height:var(--plus-h);min-width:var(--plus-h);padding:var(--plus-pad);
|
package/dist/admin/validate.d.ts
CHANGED
|
@@ -10,11 +10,36 @@ type Desc = {
|
|
|
10
10
|
min?: unknown;
|
|
11
11
|
fields?: unknown;
|
|
12
12
|
item?: unknown;
|
|
13
|
+
hidden?: unknown;
|
|
14
|
+
};
|
|
15
|
+
/** What conditions read beyond the current level: the node's two namespaces. */
|
|
16
|
+
export type ValidationScope = {
|
|
17
|
+
fields?: Record<string, unknown>;
|
|
18
|
+
settings?: Record<string, unknown>;
|
|
13
19
|
};
|
|
14
20
|
/** The element schemas an element node is validated against (spec 2026-09-08 block nodes §2). */
|
|
15
21
|
export type ElementDescs = Record<string, {
|
|
16
22
|
fields: Record<string, Desc>;
|
|
17
|
-
|
|
23
|
+
settings?: Record<string, Desc>;
|
|
18
24
|
}>;
|
|
19
|
-
|
|
25
|
+
/** Validate one value map. Without `scope.fields` the values ARE the fields (an entry, a page, an object global). */
|
|
26
|
+
export declare function validateFields(values: Record<string, unknown>, descriptors: Record<string, Desc>, elements?: ElementDescs, scope?: ValidationScope): FieldError[];
|
|
27
|
+
/** A tree node (or a visual global, in node shape) against its schema: fields, settings (prefixed), css (spec 2026-09-14 §3, §4). */
|
|
28
|
+
export declare function validateNode(node: {
|
|
29
|
+
fields?: Record<string, unknown>;
|
|
30
|
+
settings?: Record<string, unknown>;
|
|
31
|
+
css?: unknown;
|
|
32
|
+
[other: string]: unknown;
|
|
33
|
+
}, schema: {
|
|
34
|
+
fields: Record<string, Desc>;
|
|
35
|
+
settings?: Record<string, Desc>;
|
|
36
|
+
}, elements?: ElementDescs): FieldError[];
|
|
37
|
+
/** A custom section node (spec 2026-09-14 §6): the columns' richtext (element nodes included), the row structure, the css.
|
|
38
|
+
* The generic walk's own words on `rows.<i>.spans` are dropped (`spans` is no descriptor type it knows) so rowsProblems
|
|
39
|
+
* is the one voice on structure; the columns' richtext errors keep their paths. */
|
|
40
|
+
export declare function validateCustomNode(node: {
|
|
41
|
+
fields?: Record<string, unknown>;
|
|
42
|
+
css?: unknown;
|
|
43
|
+
[other: string]: unknown;
|
|
44
|
+
}, fields: Record<string, Desc>, elements?: ElementDescs): FieldError[];
|
|
20
45
|
export {};
|
package/dist/admin/validate.js
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
// lives admin/server-side). Entry saves validate; page drafts do NOT
|
|
3
3
|
// (drafts must save freely) — publish validates every node. Objects and
|
|
4
4
|
// lists recurse; error paths are dotted (`cta.href`, `points.2.heading`).
|
|
5
|
+
// A field whose `.hidden()` condition holds is skipped entirely — a hidden
|
|
6
|
+
// required field is not required (spec 2026-09-14 §12); the same isHidden
|
|
7
|
+
// the form uses decides, over the same scope.
|
|
5
8
|
import { optionValues } from "../fields.js";
|
|
9
|
+
import { isHidden } from "../conditions.js";
|
|
10
|
+
import { cssProblem } from "../css.js";
|
|
6
11
|
import { hasContent } from "./richtext-doc.js";
|
|
7
12
|
import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
|
|
13
|
+
import { rowsProblems } from "../custom/rows.js";
|
|
8
14
|
const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
|
|
9
15
|
const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
|
|
10
16
|
const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
@@ -12,7 +18,7 @@ const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArr
|
|
|
12
18
|
* may be absent — may be absent altogether. */
|
|
13
19
|
const mayBeAbsent = (d) => d.optional === true || d.type === "boolean" || (d.type === "object" && Object.values(subfields(d)).every(mayBeAbsent));
|
|
14
20
|
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
15
|
-
function validateValue(path, value, d, errors, elements) {
|
|
21
|
+
function validateValue(path, value, d, errors, elements, scope) {
|
|
16
22
|
if (isEmpty(value)) {
|
|
17
23
|
if (!mayBeAbsent(d))
|
|
18
24
|
errors.push({ field: path, message: "Required." });
|
|
@@ -34,8 +40,9 @@ function validateValue(path, value, d, errors, elements) {
|
|
|
34
40
|
const el = typeof a.name === "string" ? elements[a.name] : undefined;
|
|
35
41
|
if (el) {
|
|
36
42
|
const fields = (a.fields && typeof a.fields === "object" ? a.fields : {});
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
const settings = (a.settings && typeof a.settings === "object" ? a.settings : {});
|
|
44
|
+
validateMap(`${path}.elements.${i}`, fields, el.fields, errors, elements, { self: fields, fields, settings });
|
|
45
|
+
validateMap(`${path}.elements.${i}.settings`, settings, el.settings ?? {}, errors, elements, { self: settings, fields, settings });
|
|
39
46
|
}
|
|
40
47
|
i++;
|
|
41
48
|
}
|
|
@@ -57,8 +64,9 @@ function validateValue(path, value, d, errors, elements) {
|
|
|
57
64
|
if (typeof d.max === "number" && value.length > d.max)
|
|
58
65
|
errors.push({ field: path, message: `At most ${plural(d.max, "item")}.` });
|
|
59
66
|
const item = itemOf(d);
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
// A scalar item's condition reads the list's own level; an object item reads itself (the object branch).
|
|
68
|
+
if (item && !isHidden(item, scope))
|
|
69
|
+
value.forEach((v, i) => validateValue(`${path}.${i}`, v, item, errors, elements, scope));
|
|
62
70
|
return;
|
|
63
71
|
}
|
|
64
72
|
if (d.type === "object") {
|
|
@@ -66,8 +74,7 @@ function validateValue(path, value, d, errors, elements) {
|
|
|
66
74
|
errors.push({ field: path, message: "Must be an object." });
|
|
67
75
|
return;
|
|
68
76
|
}
|
|
69
|
-
|
|
70
|
-
validateValue(`${path}.${k}`, value[k], sub, errors, elements);
|
|
77
|
+
validateMap(path, value, subfields(d), errors, elements, { ...scope, self: value });
|
|
71
78
|
return;
|
|
72
79
|
}
|
|
73
80
|
if (d.type === "boolean") {
|
|
@@ -97,9 +104,39 @@ function validateValue(path, value, d, errors, elements) {
|
|
|
97
104
|
errors.push({ field: path, message: `Must be at most ${d.max} characters.` });
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
|
-
|
|
107
|
+
/** One descriptor map at one level: hidden entries are skipped. `prefix` is the dotted path above it ("" at the top). */
|
|
108
|
+
function validateMap(prefix, values, descriptors, errors, elements, scope) {
|
|
109
|
+
for (const [key, d] of Object.entries(descriptors)) {
|
|
110
|
+
if (isHidden(d, scope))
|
|
111
|
+
continue;
|
|
112
|
+
validateValue(prefix === "" ? key : `${prefix}.${key}`, values[key], d, errors, elements, scope);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Validate one value map. Without `scope.fields` the values ARE the fields (an entry, a page, an object global). */
|
|
116
|
+
export function validateFields(values, descriptors, elements = {}, scope = {}) {
|
|
101
117
|
const errors = [];
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
validateMap("", values, descriptors, errors, elements, { self: values, fields: scope.fields ?? values, settings: scope.settings });
|
|
119
|
+
return errors;
|
|
120
|
+
}
|
|
121
|
+
/** A tree node (or a visual global, in node shape) against its schema: fields, settings (prefixed), css (spec 2026-09-14 §3, §4). */
|
|
122
|
+
export function validateNode(node, schema, elements = {}) {
|
|
123
|
+
const fields = node.fields ?? {};
|
|
124
|
+
const settings = node.settings ?? {};
|
|
125
|
+
const scope = { fields, settings };
|
|
126
|
+
const errors = validateFields(fields, schema.fields, elements, scope);
|
|
127
|
+
for (const e of validateFields(settings, schema.settings ?? {}, elements, scope))
|
|
128
|
+
errors.push({ field: `settings.${e.field}`, message: e.message });
|
|
129
|
+
if (typeof node.css === "string" && node.css !== "") {
|
|
130
|
+
const problem = cssProblem(node.css);
|
|
131
|
+
if (problem)
|
|
132
|
+
errors.push({ field: "css", message: problem });
|
|
133
|
+
}
|
|
104
134
|
return errors;
|
|
105
135
|
}
|
|
136
|
+
/** A custom section node (spec 2026-09-14 §6): the columns' richtext (element nodes included), the row structure, the css.
|
|
137
|
+
* The generic walk's own words on `rows.<i>.spans` are dropped (`spans` is no descriptor type it knows) so rowsProblems
|
|
138
|
+
* is the one voice on structure; the columns' richtext errors keep their paths. */
|
|
139
|
+
export function validateCustomNode(node, fields, elements = {}) {
|
|
140
|
+
const errors = validateNode(node, { fields }, elements).filter((e) => !e.field.startsWith("rows.") || e.field.includes(".columns."));
|
|
141
|
+
return [...rowsProblems(node.fields?.rows), ...errors];
|
|
142
|
+
}
|
package/dist/collections.d.ts
CHANGED
|
@@ -9,10 +9,10 @@ export type EntryOrder = "manual" | {
|
|
|
9
9
|
export declare const DEFAULT_ORDER: EntryOrder;
|
|
10
10
|
export declare const BUILT_IN_ORDER_FIELDS: readonly ["createdAt", "updatedAt"];
|
|
11
11
|
export type CollectionSchema<F extends BuilderMap = BuilderMap, N extends string = string> = {
|
|
12
|
-
/** "
|
|
13
|
-
* 4): a
|
|
12
|
+
/** "global" marks a code-declared singleton (spec 2026-09-07 decision
|
|
13
|
+
* 4): a global item IS a collection with one entry, so both entry
|
|
14
14
|
* stores accept it in the same list and branch on this. */
|
|
15
|
-
kind: "collection" | "
|
|
15
|
+
kind: "collection" | "global";
|
|
16
16
|
/** Literal when declared inline (spec 2026-09-11 §6), so a queries object can check names. */
|
|
17
17
|
name: N;
|
|
18
18
|
title: string;
|
package/dist/collections.js
CHANGED
|
@@ -5,7 +5,7 @@ export const collectionDepth = (c) => c.tree?.depth ?? 1;
|
|
|
5
5
|
export function collection(input) {
|
|
6
6
|
if (!input?.name)
|
|
7
7
|
throw new Error("smoodly: a collection requires a name.");
|
|
8
|
-
if (["
|
|
8
|
+
if (["global", "asset", "media"].includes(input.name))
|
|
9
9
|
throw new Error(`smoodly: "${input.name}" is a reserved name.`);
|
|
10
10
|
if (input.tree !== undefined && (!Number.isInteger(input.tree.depth) || input.tree.depth < 1)) {
|
|
11
11
|
throw new Error(`smoodly: collection "${input.name}" declares an invalid tree depth.`);
|
|
@@ -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
|
+
}
|