smoodly 0.0.12 → 0.0.14
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 +294 -40
- package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
- package/dist/admin/editor/CanvasOverlay.js +9 -11
- package/dist/admin/editor/EditorView.js +135 -28
- package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
- package/dist/admin/editor/FloatingToolbar.js +14 -0
- package/dist/admin/editor/InlineRichtext.d.ts +9 -0
- package/dist/admin/editor/InlineRichtext.js +50 -0
- package/dist/admin/editor/Outline.d.ts +16 -10
- package/dist/admin/editor/Outline.js +13 -10
- package/dist/admin/editor/PageInspector.d.ts +21 -0
- package/dist/admin/editor/PageInspector.js +24 -0
- package/dist/admin/editor/PageSettings.d.ts +1 -3
- package/dist/admin/editor/PageSettings.js +7 -6
- package/dist/admin/editor/SeoSettings.d.ts +7 -0
- package/dist/admin/editor/SeoSettings.js +15 -0
- package/dist/admin/editor/inline-dom.d.ts +27 -0
- package/dist/admin/editor/inline-dom.js +43 -0
- package/dist/admin/editor/inline-session.d.ts +30 -0
- package/dist/admin/editor/inline-session.js +19 -0
- package/dist/admin/editor/protocol.d.ts +22 -1
- package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
- package/dist/admin/editor/useCanvasBridge.js +12 -7
- package/dist/admin/editor/useInlineSession.d.ts +46 -0
- package/dist/admin/editor/useInlineSession.js +244 -0
- package/dist/admin/field-paths.d.ts +13 -0
- package/dist/admin/field-paths.js +49 -0
- package/dist/admin/forms/FieldWidget.d.ts +7 -2
- package/dist/admin/forms/FieldWidget.js +35 -43
- package/dist/admin/forms/ListField.d.ts +10 -0
- package/dist/admin/forms/ListField.js +42 -0
- package/dist/admin/forms/MediaWidget.js +10 -3
- package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
- package/dist/admin/forms/RichtextElementCard.js +37 -0
- package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
- package/dist/admin/forms/RichtextImageCard.js +42 -0
- package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
- package/dist/admin/forms/RichtextInsertMenu.js +6 -0
- package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
- package/dist/admin/forms/RichtextPopovers.js +25 -0
- package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
- package/dist/admin/forms/RichtextToolbar.js +4 -2
- package/dist/admin/forms/RichtextWidget.d.ts +4 -1
- package/dist/admin/forms/RichtextWidget.js +23 -120
- package/dist/admin/forms/elements-context.d.ts +7 -0
- package/dist/admin/forms/elements-context.js +13 -0
- package/dist/admin/forms/reveal.d.ts +5 -0
- package/dist/admin/forms/reveal.js +47 -0
- package/dist/admin/forms/richtext-extensions.d.ts +8 -0
- package/dist/admin/forms/richtext-extensions.js +11 -0
- package/dist/admin/forms/richtext-form-context.d.ts +16 -0
- package/dist/admin/forms/richtext-form-context.js +18 -0
- package/dist/admin/forms/richtext-insert.d.ts +38 -0
- package/dist/admin/forms/richtext-insert.js +37 -0
- package/dist/admin/forms/richtext-nodes.d.ts +37 -0
- package/dist/admin/forms/richtext-nodes.js +85 -0
- package/dist/admin/forms/richtext-paste.js +5 -0
- package/dist/admin/forms/tabs.d.ts +3 -0
- package/dist/admin/forms/tabs.js +6 -0
- package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
- package/dist/admin/forms/useRichtextEditor.js +281 -0
- package/dist/admin/next/bridge.js +51 -20
- package/dist/admin/ops-impl.js +85 -19
- package/dist/admin/richtext-doc.d.ts +3 -0
- package/dist/admin/richtext-doc.js +12 -0
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +18 -1
- package/dist/admin/shell/AdminApp.js +2 -1
- package/dist/admin/shell/EntriesList.js +34 -12
- package/dist/admin/shell/EntryForm.js +22 -15
- package/dist/admin/shell/IconRail.js +6 -5
- package/dist/admin/shell/ListView.d.ts +9 -2
- package/dist/admin/shell/ListView.js +7 -6
- package/dist/admin/shell/LoginView.js +2 -2
- package/dist/admin/shell/PagesList.js +53 -24
- package/dist/admin/shell/SharedForm.js +17 -11
- package/dist/admin/shell/SharedList.js +5 -3
- package/dist/admin/shell/entries-list.d.ts +4 -0
- package/dist/admin/shell/entries-list.js +6 -0
- package/dist/admin/shell/pages-tree.d.ts +24 -0
- package/dist/admin/shell/pages-tree.js +59 -1
- package/dist/admin/tree-ops.d.ts +11 -1
- package/dist/admin/tree-ops.js +32 -1
- package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
- package/dist/admin/ui/LocaleSwitcher.js +28 -8
- package/dist/admin/ui/Modal.js +14 -2
- package/dist/admin/ui/ResizeHandle.d.ts +1 -0
- package/dist/admin/ui/ResizeHandle.js +127 -0
- package/dist/admin/ui/primitives.d.ts +38 -4
- package/dist/admin/ui/primitives.js +35 -10
- package/dist/admin/ui/resize.d.ts +16 -0
- package/dist/admin/ui/resize.js +23 -0
- package/dist/admin/ui/sortable.d.ts +23 -0
- package/dist/admin/ui/sortable.js +57 -0
- package/dist/admin/ui/theme.d.ts +3 -1
- package/dist/admin/ui/theme.js +527 -165
- package/dist/admin/ui/useSortable.d.ts +35 -0
- package/dist/admin/ui/useSortable.js +220 -0
- package/dist/admin/validate.d.ts +6 -1
- package/dist/admin/validate.js +52 -12
- package/dist/collections.d.ts +6 -5
- package/dist/config.d.ts +1 -0
- package/dist/config.js +36 -0
- package/dist/entry-store.d.ts +23 -3
- package/dist/entry-store.js +31 -5
- package/dist/field-defaults.d.ts +8 -0
- package/dist/field-defaults.js +18 -0
- package/dist/fields.d.ts +30 -1
- package/dist/fields.js +33 -3
- package/dist/image/SmoodlyImage.d.ts +4 -1
- package/dist/image/SmoodlyImage.js +2 -2
- package/dist/index.d.ts +16 -7
- package/dist/index.js +10 -4
- package/dist/marks.d.ts +53 -0
- package/dist/marks.js +43 -0
- package/dist/media.d.ts +3 -2
- package/dist/media.js +48 -6
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +1 -0
- package/dist/next/meta.d.ts +7 -3
- package/dist/next/meta.js +18 -9
- package/dist/next/page-renderer.js +1 -1
- package/dist/next/queries.d.ts +36 -0
- package/dist/next/queries.js +87 -0
- package/dist/page-tree.d.ts +3 -0
- package/dist/page-tree.js +6 -0
- package/dist/page.d.ts +11 -3
- package/dist/page.js +26 -0
- package/dist/pairing.d.ts +2 -1
- package/dist/pairing.js +2 -9
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +33 -12
- package/dist/render.d.ts +14 -7
- package/dist/render.js +4 -3
- package/dist/resolve.d.ts +12 -2
- package/dist/resolve.js +16 -7
- package/dist/revalidate.d.ts +6 -0
- package/dist/revalidate.js +6 -0
- package/dist/richtext/index.d.ts +12 -0
- package/dist/richtext/index.js +9 -0
- package/dist/richtext-render.d.ts +22 -2
- package/dist/richtext-render.js +40 -13
- package/dist/richtext-walk.d.ts +44 -0
- package/dist/richtext-walk.js +46 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +13 -1
- package/dist/section-wrapper.js +2 -1
- package/dist/shared.d.ts +7 -7
- package/dist/site.d.ts +25 -1
- package/dist/site.js +89 -22
- package/dist/store.d.ts +7 -1
- package/dist/store.js +13 -4
- package/dist/supabase-entry-store.d.ts +1 -0
- package/dist/supabase-entry-store.js +34 -11
- package/dist/supabase-store.d.ts +1 -0
- package/dist/supabase-store.js +14 -1
- package/dist/toolset.d.ts +7 -1
- package/dist/toolset.js +13 -4
- package/package.json +6 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
|
|
2
|
+
/** A tree surface's reading of a slot; `index` undefined = append (last child). */
|
|
3
|
+
export type Projection = {
|
|
4
|
+
depth: number;
|
|
5
|
+
parentId: string | null;
|
|
6
|
+
index: number | undefined;
|
|
7
|
+
};
|
|
8
|
+
export type SortableOptions = {
|
|
9
|
+
/** How many rows the container's first children are. */
|
|
10
|
+
count: number;
|
|
11
|
+
/** `to` is the index in the RESULTING order. Return a promise to hold the rows in place until it settles. */
|
|
12
|
+
onDrop(from: number, to: number, projection: Projection | null): void | Promise<unknown>;
|
|
13
|
+
/** Tree surfaces: refine the slot from the pointer's x drift (px); null = invalid slot. */
|
|
14
|
+
project?(slot: number, from: number, xDrift: number): Projection | null;
|
|
15
|
+
/** Fires once when the threshold is crossed, BEFORE the rows are measured. */
|
|
16
|
+
onDragStart?(from: number): void;
|
|
17
|
+
};
|
|
18
|
+
export type SortableActive = {
|
|
19
|
+
from: number;
|
|
20
|
+
to: number;
|
|
21
|
+
projection: Projection | null;
|
|
22
|
+
valid: boolean;
|
|
23
|
+
};
|
|
24
|
+
export type Sortable = {
|
|
25
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
26
|
+
handleProps(index: number): {
|
|
27
|
+
onPointerDown(e: ReactPointerEvent<HTMLElement>): void;
|
|
28
|
+
onKeyDown(e: KeyboardEvent<HTMLElement>): void;
|
|
29
|
+
onClick(e: MouseEvent<HTMLElement>): void;
|
|
30
|
+
};
|
|
31
|
+
rowStyle(index: number): CSSProperties;
|
|
32
|
+
rowClass(index: number): string | undefined;
|
|
33
|
+
active: SortableActive | null;
|
|
34
|
+
};
|
|
35
|
+
export declare function useSortable(options: SortableOptions): Sortable;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// One drag gesture for every ordered row list in the admin (spec
|
|
3
|
+
// 2026-09-09 §2–§3, §5): the maths lives in ./sortable.ts; this hook
|
|
4
|
+
// owns the pointer, the one-time row measurement, autoscroll, the
|
|
5
|
+
// keyboard step and the pending hold. Rows are never reordered in React
|
|
6
|
+
// state during a drag — only `onDrop` changes data.
|
|
7
|
+
import { useEffect, useLayoutEffect, useRef, useState, } from "react";
|
|
8
|
+
import { clampOffset, gapTransforms, restingOffset, slotAt, stepIndex } from "./sortable.js";
|
|
9
|
+
const THRESHOLD = 4;
|
|
10
|
+
const EDGE = 32;
|
|
11
|
+
const MAX_SPEED = 14;
|
|
12
|
+
const TRANSITION = "transform 120ms ease";
|
|
13
|
+
const scrollParent = (el) => {
|
|
14
|
+
for (let node = el; node; node = node.parentElement) {
|
|
15
|
+
const overflow = window.getComputedStyle(node).overflowY;
|
|
16
|
+
if (overflow === "auto" || overflow === "scroll")
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
};
|
|
21
|
+
const sameProjection = (a, b) => a === b || (a !== null && b !== null && a.depth === b.depth && a.parentId === b.parentId && a.index === b.index);
|
|
22
|
+
/** The click that follows a real drag lands on the row (or its table) and
|
|
23
|
+
* would open it — eat exactly one click, and give up if none comes. */
|
|
24
|
+
const suppressNextClick = () => {
|
|
25
|
+
const eat = (e) => { e.stopPropagation(); e.preventDefault(); };
|
|
26
|
+
window.addEventListener("click", eat, { capture: true, once: true });
|
|
27
|
+
window.setTimeout(() => window.removeEventListener("click", eat, { capture: true }), 0);
|
|
28
|
+
};
|
|
29
|
+
const measureRows = (container, count) => {
|
|
30
|
+
const top = container.getBoundingClientRect().top;
|
|
31
|
+
return Array.from(container.children).slice(0, count).map((child) => {
|
|
32
|
+
const r = child.getBoundingClientRect();
|
|
33
|
+
return { top: r.top - top, height: r.height };
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
export function useSortable(options) {
|
|
37
|
+
const containerRef = useRef(null);
|
|
38
|
+
const latest = useRef(options);
|
|
39
|
+
latest.current = options;
|
|
40
|
+
const pointer = useRef(null);
|
|
41
|
+
const [active, setActive] = useState(null);
|
|
42
|
+
const [hold, setHold] = useState(null);
|
|
43
|
+
const holdRef = useRef(null);
|
|
44
|
+
holdRef.current = hold;
|
|
45
|
+
// Measure once the threshold render has committed: onDragStart may have
|
|
46
|
+
// collapsed rows (the pages tree), and those must be gone first.
|
|
47
|
+
useLayoutEffect(() => {
|
|
48
|
+
const p = pointer.current;
|
|
49
|
+
const container = containerRef.current;
|
|
50
|
+
if (!active || !p || p.rects || !container)
|
|
51
|
+
return;
|
|
52
|
+
p.rects = measureRows(container, latest.current.count);
|
|
53
|
+
p.scroller = scrollParent(container);
|
|
54
|
+
}, [active !== null]);
|
|
55
|
+
useEffect(() => () => pointer.current?.detach(), []);
|
|
56
|
+
const commit = (from, to, projection, rects) => {
|
|
57
|
+
const result = latest.current.onDrop(from, to, projection);
|
|
58
|
+
if (result && typeof result.then === "function") {
|
|
59
|
+
const next = { from, to, rects };
|
|
60
|
+
setHold(next);
|
|
61
|
+
const clear = () => setHold((h) => (h === next ? null : h));
|
|
62
|
+
result.then(clear, clear);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const update = (clientX, clientY) => {
|
|
66
|
+
const p = pointer.current;
|
|
67
|
+
const container = containerRef.current;
|
|
68
|
+
if (!p || !p.started || !p.rects || !container)
|
|
69
|
+
return;
|
|
70
|
+
const { project } = latest.current;
|
|
71
|
+
const raw = clientY - container.getBoundingClientRect().top - p.startCY;
|
|
72
|
+
const own = p.rects[p.from];
|
|
73
|
+
// The slot follows the pointer; the row's transform is held inside the
|
|
74
|
+
// rows' extent, since past it the scroller grows and the edge
|
|
75
|
+
// autoscroll never reaches an end.
|
|
76
|
+
const to = slotAt(p.rects, p.from, own.top + raw + own.height / 2);
|
|
77
|
+
const dy = clampOffset(p.rects, p.from, raw);
|
|
78
|
+
const projection = project ? project(to, p.from, clientX - p.startX) : null;
|
|
79
|
+
const valid = !project || projection !== null;
|
|
80
|
+
p.dy = dy;
|
|
81
|
+
const row = container.children[p.from];
|
|
82
|
+
if (row)
|
|
83
|
+
row.style.transform = `translateY(${dy}px)`;
|
|
84
|
+
if (to !== p.to || valid !== p.valid || !sameProjection(projection, p.projection)) {
|
|
85
|
+
p.to = to;
|
|
86
|
+
p.valid = valid;
|
|
87
|
+
p.projection = projection;
|
|
88
|
+
setActive({ from: p.from, to, projection, valid });
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const tick = () => {
|
|
92
|
+
const p = pointer.current;
|
|
93
|
+
if (!p || !p.started)
|
|
94
|
+
return;
|
|
95
|
+
const s = p.scroller;
|
|
96
|
+
if (s) {
|
|
97
|
+
const r = s.getBoundingClientRect();
|
|
98
|
+
let delta = 0;
|
|
99
|
+
if (p.lastY < r.top + EDGE)
|
|
100
|
+
delta = -Math.min(MAX_SPEED, (r.top + EDGE - p.lastY) / 2);
|
|
101
|
+
else if (p.lastY > r.bottom - EDGE)
|
|
102
|
+
delta = Math.min(MAX_SPEED, (p.lastY - (r.bottom - EDGE)) / 2);
|
|
103
|
+
if (delta !== 0) {
|
|
104
|
+
s.scrollTop += delta;
|
|
105
|
+
update(p.lastX, p.lastY);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
p.raf = window.requestAnimationFrame(tick);
|
|
109
|
+
};
|
|
110
|
+
const finish = (drop) => {
|
|
111
|
+
const p = pointer.current;
|
|
112
|
+
if (!p)
|
|
113
|
+
return;
|
|
114
|
+
p.detach();
|
|
115
|
+
window.cancelAnimationFrame(p.raf);
|
|
116
|
+
document.body.style.userSelect = "";
|
|
117
|
+
pointer.current = null;
|
|
118
|
+
if (!p.started)
|
|
119
|
+
return;
|
|
120
|
+
suppressNextClick();
|
|
121
|
+
const changed = p.to !== p.from || (p.projection !== null);
|
|
122
|
+
if (drop && p.valid && p.rects && changed)
|
|
123
|
+
commit(p.from, p.to, p.projection, p.rects);
|
|
124
|
+
setActive(null);
|
|
125
|
+
};
|
|
126
|
+
const onPointerDown = (index) => (e) => {
|
|
127
|
+
if (e.button !== 0 || pointer.current || holdRef.current)
|
|
128
|
+
return;
|
|
129
|
+
const container = containerRef.current;
|
|
130
|
+
if (!container)
|
|
131
|
+
return;
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
const move = (ev) => {
|
|
134
|
+
const p = pointer.current;
|
|
135
|
+
if (!p)
|
|
136
|
+
return;
|
|
137
|
+
p.lastX = ev.clientX;
|
|
138
|
+
p.lastY = ev.clientY;
|
|
139
|
+
if (!p.started) {
|
|
140
|
+
if (Math.abs(ev.clientX - p.startX) + Math.abs(ev.clientY - p.startY) < THRESHOLD)
|
|
141
|
+
return;
|
|
142
|
+
p.started = true;
|
|
143
|
+
document.body.style.userSelect = "none";
|
|
144
|
+
latest.current.onDragStart?.(p.from);
|
|
145
|
+
setActive({ from: p.from, to: p.from, projection: null, valid: true });
|
|
146
|
+
p.raf = window.requestAnimationFrame(tick);
|
|
147
|
+
return; // measured in the layout effect; the next move positions the row
|
|
148
|
+
}
|
|
149
|
+
ev.preventDefault();
|
|
150
|
+
update(ev.clientX, ev.clientY);
|
|
151
|
+
};
|
|
152
|
+
const up = () => finish(true);
|
|
153
|
+
const cancel = () => finish(false);
|
|
154
|
+
const key = (ev) => { if (ev.key === "Escape")
|
|
155
|
+
finish(false); };
|
|
156
|
+
pointer.current = {
|
|
157
|
+
from: index, startX: e.clientX, startY: e.clientY,
|
|
158
|
+
startCY: e.clientY - container.getBoundingClientRect().top,
|
|
159
|
+
lastX: e.clientX, lastY: e.clientY, started: false,
|
|
160
|
+
rects: null, scroller: null, raf: 0,
|
|
161
|
+
to: index, dy: 0, projection: null, valid: true,
|
|
162
|
+
detach() {
|
|
163
|
+
window.removeEventListener("pointermove", move);
|
|
164
|
+
window.removeEventListener("pointerup", up);
|
|
165
|
+
window.removeEventListener("pointercancel", cancel);
|
|
166
|
+
window.removeEventListener("keydown", key);
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
window.addEventListener("pointermove", move);
|
|
170
|
+
window.addEventListener("pointerup", up);
|
|
171
|
+
window.addEventListener("pointercancel", cancel);
|
|
172
|
+
window.addEventListener("keydown", key);
|
|
173
|
+
};
|
|
174
|
+
const onKeyDown = (index) => (e) => {
|
|
175
|
+
e.stopPropagation(); // the row's own Enter/Space handler must not see the grip's keys
|
|
176
|
+
if (e.key !== "ArrowUp" && e.key !== "ArrowDown")
|
|
177
|
+
return;
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
if (pointer.current || holdRef.current)
|
|
180
|
+
return;
|
|
181
|
+
const container = containerRef.current;
|
|
182
|
+
const { count, project } = latest.current;
|
|
183
|
+
const to = stepIndex(count, index, e.key === "ArrowUp" ? -1 : 1);
|
|
184
|
+
if (to === null || !container)
|
|
185
|
+
return;
|
|
186
|
+
const projection = project ? project(to, index, 0) : null;
|
|
187
|
+
if (project && projection === null)
|
|
188
|
+
return;
|
|
189
|
+
commit(index, to, projection, measureRows(container, count));
|
|
190
|
+
};
|
|
191
|
+
const onClick = (e) => { e.stopPropagation(); e.preventDefault(); };
|
|
192
|
+
const rowStyle = (index) => {
|
|
193
|
+
const p = pointer.current;
|
|
194
|
+
if (active && p?.rects) {
|
|
195
|
+
if (index === active.from)
|
|
196
|
+
return { transform: `translateY(${p.dy}px)`, transition: "none" };
|
|
197
|
+
const shift = gapTransforms(p.rects, active.from, active.to)[index] ?? 0;
|
|
198
|
+
return { transform: `translateY(${shift}px)`, transition: TRANSITION };
|
|
199
|
+
}
|
|
200
|
+
if (hold) {
|
|
201
|
+
const y = index === hold.from
|
|
202
|
+
? restingOffset(hold.rects, hold.from, hold.to)
|
|
203
|
+
: gapTransforms(hold.rects, hold.from, hold.to)[index] ?? 0;
|
|
204
|
+
return { transform: `translateY(${y}px)`, transition: TRANSITION };
|
|
205
|
+
}
|
|
206
|
+
return {};
|
|
207
|
+
};
|
|
208
|
+
const rowClass = (index) => {
|
|
209
|
+
if (!active || index !== active.from)
|
|
210
|
+
return undefined;
|
|
211
|
+
return active.valid ? "sm-drag--lifted" : "sm-drag--lifted sm-drag--invalid";
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
containerRef,
|
|
215
|
+
handleProps: (index) => ({ onPointerDown: onPointerDown(index), onKeyDown: onKeyDown(index), onClick }),
|
|
216
|
+
rowStyle,
|
|
217
|
+
rowClass,
|
|
218
|
+
active,
|
|
219
|
+
};
|
|
220
|
+
}
|
package/dist/admin/validate.d.ts
CHANGED
|
@@ -11,5 +11,10 @@ type Desc = {
|
|
|
11
11
|
fields?: unknown;
|
|
12
12
|
item?: unknown;
|
|
13
13
|
};
|
|
14
|
-
|
|
14
|
+
/** The element schemas an element node is validated against (spec 2026-09-08 block nodes §2). */
|
|
15
|
+
export type ElementDescs = Record<string, {
|
|
16
|
+
fields: Record<string, Desc>;
|
|
17
|
+
styles?: Record<string, Desc>;
|
|
18
|
+
}>;
|
|
19
|
+
export declare function validateFields(values: Record<string, unknown>, descriptors: Record<string, Desc>, elements?: ElementDescs): FieldError[];
|
|
15
20
|
export {};
|
package/dist/admin/validate.js
CHANGED
|
@@ -2,23 +2,43 @@
|
|
|
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
|
-
import {
|
|
5
|
+
import { optionValues } from "../fields.js";
|
|
6
|
+
import { hasContent } from "./richtext-doc.js";
|
|
7
|
+
import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
|
|
6
8
|
const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
|
|
7
9
|
const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
|
|
8
10
|
const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
9
11
|
/** An object whose every subfield is optional — or is itself an object that
|
|
10
12
|
* may be absent — may be absent altogether. */
|
|
11
|
-
const mayBeAbsent = (d) => d.optional === true || (d.type === "object" && Object.values(subfields(d)).every(mayBeAbsent));
|
|
13
|
+
const mayBeAbsent = (d) => d.optional === true || d.type === "boolean" || (d.type === "object" && Object.values(subfields(d)).every(mayBeAbsent));
|
|
12
14
|
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
13
|
-
function validateValue(path, value, d, errors) {
|
|
15
|
+
function validateValue(path, value, d, errors, elements) {
|
|
14
16
|
if (isEmpty(value)) {
|
|
15
17
|
if (!mayBeAbsent(d))
|
|
16
18
|
errors.push({ field: path, message: "Required." });
|
|
17
19
|
return;
|
|
18
20
|
}
|
|
19
|
-
if (d.type === "richtext"
|
|
20
|
-
if (!
|
|
21
|
-
|
|
21
|
+
if (d.type === "richtext") {
|
|
22
|
+
if (!hasContent(value)) {
|
|
23
|
+
if (!mayBeAbsent(d))
|
|
24
|
+
errors.push({ field: path, message: "Required." });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// Element nodes (spec 2026-09-08 block nodes §2): fields validated against
|
|
28
|
+
// the element's descriptors, indexed among the document's element nodes.
|
|
29
|
+
let i = 0;
|
|
30
|
+
for (const node of richtextBlocks(value)) {
|
|
31
|
+
if (node.type !== ELEMENT_NODE)
|
|
32
|
+
continue;
|
|
33
|
+
const a = node.attrs;
|
|
34
|
+
const el = typeof a.name === "string" ? elements[a.name] : undefined;
|
|
35
|
+
if (el) {
|
|
36
|
+
const fields = (a.fields && typeof a.fields === "object" ? a.fields : {});
|
|
37
|
+
for (const [k, sub] of Object.entries(el.fields))
|
|
38
|
+
validateValue(`${path}.elements.${i}.${k}`, fields[k], sub, errors, elements);
|
|
39
|
+
}
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
22
42
|
return;
|
|
23
43
|
}
|
|
24
44
|
if (d.type === "image" || d.type === "video" || d.type === "file") {
|
|
@@ -38,7 +58,7 @@ function validateValue(path, value, d, errors) {
|
|
|
38
58
|
errors.push({ field: path, message: `At most ${plural(d.max, "item")}.` });
|
|
39
59
|
const item = itemOf(d);
|
|
40
60
|
if (item)
|
|
41
|
-
value.forEach((v, i) => validateValue(`${path}.${i}`, v, item, errors));
|
|
61
|
+
value.forEach((v, i) => validateValue(`${path}.${i}`, v, item, errors, elements));
|
|
42
62
|
return;
|
|
43
63
|
}
|
|
44
64
|
if (d.type === "object") {
|
|
@@ -47,19 +67,39 @@ function validateValue(path, value, d, errors) {
|
|
|
47
67
|
return;
|
|
48
68
|
}
|
|
49
69
|
for (const [k, sub] of Object.entries(subfields(d)))
|
|
50
|
-
validateValue(`${path}.${k}`, value[k], sub, errors);
|
|
70
|
+
validateValue(`${path}.${k}`, value[k], sub, errors, elements);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (d.type === "boolean") {
|
|
74
|
+
if (typeof value !== "boolean")
|
|
75
|
+
errors.push({ field: path, message: "Must be on or off." });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (d.type === "select" || d.type === "radio") {
|
|
79
|
+
const allowed = optionValues(d.options);
|
|
80
|
+
if (allowed.length > 0 && !allowed.includes(value)) {
|
|
81
|
+
errors.push({ field: path, message: `Must be one of: ${allowed.join(", ")}.` });
|
|
82
|
+
}
|
|
51
83
|
return;
|
|
52
84
|
}
|
|
53
|
-
if (d.type === "
|
|
54
|
-
|
|
85
|
+
if (d.type === "checkboxes") {
|
|
86
|
+
if (!Array.isArray(value)) {
|
|
87
|
+
errors.push({ field: path, message: "Must be a list." });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const allowed = optionValues(d.options);
|
|
91
|
+
if (value.some((v) => !allowed.includes(v))) {
|
|
92
|
+
errors.push({ field: path, message: `Must be one of: ${allowed.join(", ")}.` });
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
55
95
|
}
|
|
56
96
|
if (typeof d.max === "number" && typeof value === "string" && value.length > d.max) {
|
|
57
97
|
errors.push({ field: path, message: `Must be at most ${d.max} characters.` });
|
|
58
98
|
}
|
|
59
99
|
}
|
|
60
|
-
export function validateFields(values, descriptors) {
|
|
100
|
+
export function validateFields(values, descriptors, elements = {}) {
|
|
61
101
|
const errors = [];
|
|
62
102
|
for (const [field, d] of Object.entries(descriptors))
|
|
63
|
-
validateValue(field, values[field], d, errors);
|
|
103
|
+
validateValue(field, values[field], d, errors, elements);
|
|
64
104
|
return errors;
|
|
65
105
|
}
|
package/dist/collections.d.ts
CHANGED
|
@@ -8,12 +8,13 @@ export type EntryOrder = "manual" | {
|
|
|
8
8
|
};
|
|
9
9
|
export declare const DEFAULT_ORDER: EntryOrder;
|
|
10
10
|
export declare const BUILT_IN_ORDER_FIELDS: readonly ["createdAt", "updatedAt"];
|
|
11
|
-
export type CollectionSchema<F extends BuilderMap = BuilderMap> = {
|
|
11
|
+
export type CollectionSchema<F extends BuilderMap = BuilderMap, N extends string = string> = {
|
|
12
12
|
/** "shared" marks a code-declared singleton (spec 2026-09-07 decision
|
|
13
13
|
* 4): a shared item IS a collection with one entry, so both entry
|
|
14
14
|
* stores accept it in the same list and branch on this. */
|
|
15
15
|
kind: "collection" | "shared";
|
|
16
|
-
|
|
16
|
+
/** Literal when declared inline (spec 2026-09-11 §6), so a queries object can check names. */
|
|
17
|
+
name: N;
|
|
17
18
|
title: string;
|
|
18
19
|
titleField?: string;
|
|
19
20
|
fields: Record<string, Descriptor>;
|
|
@@ -38,8 +39,8 @@ export type CollectionSchema<F extends BuilderMap = BuilderMap> = {
|
|
|
38
39
|
};
|
|
39
40
|
export declare const collectionOrder: (c: CollectionSchema<any>) => EntryOrder;
|
|
40
41
|
export declare const collectionDepth: (c: CollectionSchema<any>) => number;
|
|
41
|
-
export declare function collection<F extends BuilderMap>(input: {
|
|
42
|
-
name:
|
|
42
|
+
export declare function collection<F extends BuilderMap, const N extends string = string>(input: {
|
|
43
|
+
name: N;
|
|
43
44
|
title: string;
|
|
44
45
|
titleField?: string;
|
|
45
46
|
fields: F;
|
|
@@ -52,4 +53,4 @@ export declare function collection<F extends BuilderMap>(input: {
|
|
|
52
53
|
listColumns?: string[];
|
|
53
54
|
};
|
|
54
55
|
versions?: boolean;
|
|
55
|
-
}): CollectionSchema<F>;
|
|
56
|
+
}): CollectionSchema<F, N>;
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -125,10 +125,46 @@ function assertLocales(config) {
|
|
|
125
125
|
if (!supported.includes(def))
|
|
126
126
|
throw new Error(`smoodly: the default locale "${def}" is not in the supported list.`);
|
|
127
127
|
}
|
|
128
|
+
/** Every element name a richtext toolset embeds, walking objects and lists. */
|
|
129
|
+
function toolsetElementNames(descriptors, out) {
|
|
130
|
+
for (const d of Object.values(descriptors ?? {})) {
|
|
131
|
+
if (d.type === "richtext") {
|
|
132
|
+
const t = d.toolset;
|
|
133
|
+
if (Array.isArray(t?.elements))
|
|
134
|
+
for (const n of t.elements)
|
|
135
|
+
if (typeof n === "string")
|
|
136
|
+
out.add(n);
|
|
137
|
+
}
|
|
138
|
+
else if (d.type === "object") {
|
|
139
|
+
toolsetElementNames(d.fields, out);
|
|
140
|
+
}
|
|
141
|
+
else if (d.type === "list") {
|
|
142
|
+
toolsetElementNames({ item: d.item }, out);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** A field can never offer an element the admin cannot serialize (spec 2026-09-08 block nodes §1). */
|
|
147
|
+
function assertToolsetElements(config) {
|
|
148
|
+
const registered = new Set((config.registry.elements ?? []).map((e) => e.schema.name));
|
|
149
|
+
const wanted = new Set();
|
|
150
|
+
for (const s of config.registry.sections ?? [])
|
|
151
|
+
toolsetElementNames(s.schema.fields, wanted);
|
|
152
|
+
for (const e of config.registry.elements ?? [])
|
|
153
|
+
toolsetElementNames(e.schema.fields, wanted);
|
|
154
|
+
for (const c of config.registry.collections ?? [])
|
|
155
|
+
toolsetElementNames(c.fields, wanted);
|
|
156
|
+
for (const s of config.registry.shared ?? [])
|
|
157
|
+
toolsetElementNames(s.fields, wanted);
|
|
158
|
+
for (const name of wanted) {
|
|
159
|
+
if (!registered.has(name))
|
|
160
|
+
throw new Error(`smoodly: a toolset embeds element "${name}", which is not in registry.elements.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
128
163
|
export function defineConfig(config) {
|
|
129
164
|
assertLocales(config);
|
|
130
165
|
assertUniqueNames(config);
|
|
131
166
|
assertSharedSections(config);
|
|
167
|
+
assertToolsetElements(config);
|
|
132
168
|
assertRootSegments(config);
|
|
133
169
|
const depth = config.pages?.tree?.depth ?? 1;
|
|
134
170
|
if (!Number.isInteger(depth) || depth < 1)
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
2
2
|
import type { SharedSchema } from "./shared.ts";
|
|
3
|
-
import { type RefEdge } from "./refs.ts";
|
|
3
|
+
import { type RefEdge, type SectionLike } from "./refs.ts";
|
|
4
4
|
import { MemoryRefIndex } from "./ref-index.ts";
|
|
5
5
|
import { type PathIndex } from "./path-index.ts";
|
|
6
6
|
import { type LocaleSet, type PathTarget } from "./paths.ts";
|
|
@@ -39,10 +39,27 @@ export type EntryUsage = {
|
|
|
39
39
|
sourceId: string;
|
|
40
40
|
sourceField: string;
|
|
41
41
|
};
|
|
42
|
+
export type WhereValue = string | number | boolean;
|
|
43
|
+
/** Equality (scalar) or any-of (array) on top-level SHARED fields (spec 2026-09-11 §3). */
|
|
44
|
+
export type Where = Record<string, WhereValue | WhereValue[]>;
|
|
45
|
+
/** The field types whose value is an array: a scalar `where` on one is
|
|
46
|
+
* containment, and any-of on one is refused by the site layer (both
|
|
47
|
+
* adapters read the text form of the whole array, never its elements). */
|
|
48
|
+
export declare const ARRAY_FIELD_TYPES: ReadonlySet<string>;
|
|
49
|
+
/** The memory adapter's and the tests' reading of `where`: a scalar matches an
|
|
50
|
+
* equal value or an array holding it (JSONB containment); an array is any-of,
|
|
51
|
+
* compared as text like PostgREST's `->>`. */
|
|
52
|
+
export declare function matchesWhere(fields: Record<string, unknown>, where: Where): boolean;
|
|
42
53
|
export type ListOptions = {
|
|
54
|
+
/** Equality / any-of on shared fields, executed by the store (spec 2026-09-11 §3). */
|
|
55
|
+
where?: Where;
|
|
56
|
+
/** Applied after order and after the `status` filter. */
|
|
57
|
+
limit?: number;
|
|
43
58
|
/** Absent = the collection's configured order (config); `"manual"` sorts by `sort` asc. */
|
|
44
59
|
order?: EntryOrder;
|
|
45
|
-
/**
|
|
60
|
+
/** Alone: only entries PRESENT in this locale (spec 2026-09-11 §3, so `limit`
|
|
61
|
+
* counts rows that are there). With `status`: the locale whose row is
|
|
62
|
+
* inspected; the default locale when only `status` is given. */
|
|
46
63
|
locale?: string;
|
|
47
64
|
/** Only entries whose row in `locale` is published. */
|
|
48
65
|
status?: "published";
|
|
@@ -63,6 +80,8 @@ export type EntryStoreOptions = {
|
|
|
63
80
|
paths?: PathIndex;
|
|
64
81
|
locales?: LocaleSet;
|
|
65
82
|
shared?: SharedSchema[];
|
|
83
|
+
/** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
|
|
84
|
+
elements?: SectionLike[];
|
|
66
85
|
};
|
|
67
86
|
export interface EntryStore {
|
|
68
87
|
/** Creates the node and ONE locale row from the merged `fields`: shared
|
|
@@ -166,7 +185,7 @@ export declare function entryFieldsIn(record: EntryRecord, locale: string): Reco
|
|
|
166
185
|
* moved past. Safe-delete must hold while ANY of them shows the target,
|
|
167
186
|
* since the published site still renders it (DESIGN.md §3 "Page edges
|
|
168
187
|
* in refs", the same stance). */
|
|
169
|
-
export declare function entryRefEdges(fieldSets: Record<string, unknown>[], schema: CollectionSchema<any
|
|
188
|
+
export declare function entryRefEdges(fieldSets: Record<string, unknown>[], schema: CollectionSchema<any>, elements?: SectionLike[]): RefEdge[];
|
|
170
189
|
/** What a message calls an entry: its title field in that locale, else its id. */
|
|
171
190
|
export declare function entryTitleIn(record: EntryRecord, locale: string, schema: CollectionSchema<any>): string;
|
|
172
191
|
/**
|
|
@@ -192,6 +211,7 @@ export declare class MemoryEntryStore implements EntryStore {
|
|
|
192
211
|
private paths;
|
|
193
212
|
private locales;
|
|
194
213
|
private schemas;
|
|
214
|
+
private elements;
|
|
195
215
|
constructor(collections: CollectionSchema<any>[], refs?: MemoryRefIndex, options?: EntryStoreOptions);
|
|
196
216
|
private schema;
|
|
197
217
|
private row;
|
package/dist/entry-store.js
CHANGED
|
@@ -16,6 +16,24 @@ import { MemoryPathIndex } from "./path-index.js";
|
|
|
16
16
|
import { sharedEntryId } from "./shared-id.js";
|
|
17
17
|
import { DEFAULT_SPACE_ID } from "./sql-space.js";
|
|
18
18
|
import { assertDepth, assertSupportedLocale, chainOf, entryPathRows, slugOf, unsupportedLocale, } from "./paths.js";
|
|
19
|
+
/** The field types whose value is an array: a scalar `where` on one is
|
|
20
|
+
* containment, and any-of on one is refused by the site layer (both
|
|
21
|
+
* adapters read the text form of the whole array, never its elements). */
|
|
22
|
+
export const ARRAY_FIELD_TYPES = new Set(["checkboxes", "refList", "list"]);
|
|
23
|
+
/** PostgREST's `->>`: a scalar's text, an array's or object's JSON text (Postgres
|
|
24
|
+
* spaces an object's text differently, but no scalar equals either form). */
|
|
25
|
+
const textForm = (value) => (value !== null && typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
26
|
+
/** The memory adapter's and the tests' reading of `where`: a scalar matches an
|
|
27
|
+
* equal value or an array holding it (JSONB containment); an array is any-of,
|
|
28
|
+
* compared as text like PostgREST's `->>`. */
|
|
29
|
+
export function matchesWhere(fields, where) {
|
|
30
|
+
return Object.entries(where).every(([key, want]) => {
|
|
31
|
+
const have = fields[key];
|
|
32
|
+
if (Array.isArray(want))
|
|
33
|
+
return want.some((w) => String(w) === textForm(have));
|
|
34
|
+
return Array.isArray(have) ? have.includes(want) : have === want;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
19
37
|
export function schemaOf(collections, name) {
|
|
20
38
|
const schema = collections.find((c) => c.name === name);
|
|
21
39
|
if (!schema)
|
|
@@ -92,10 +110,10 @@ export function entryFieldsIn(record, locale) {
|
|
|
92
110
|
* moved past. Safe-delete must hold while ANY of them shows the target,
|
|
93
111
|
* since the published site still renders it (DESIGN.md §3 "Page edges
|
|
94
112
|
* in refs", the same stance). */
|
|
95
|
-
export function entryRefEdges(fieldSets, schema) {
|
|
113
|
+
export function entryRefEdges(fieldSets, schema, elements) {
|
|
96
114
|
const edges = [];
|
|
97
115
|
for (const fields of fieldSets)
|
|
98
|
-
edges.push(...collectEntryRefs(fields, schema));
|
|
116
|
+
edges.push(...collectEntryRefs(fields, schema, elements));
|
|
99
117
|
return edges;
|
|
100
118
|
}
|
|
101
119
|
/** What a message calls an entry: its title field in that locale, else its id. */
|
|
@@ -148,6 +166,7 @@ export class MemoryEntryStore {
|
|
|
148
166
|
this.paths = options.paths ?? new MemoryPathIndex();
|
|
149
167
|
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
150
168
|
this.schemas = [...collections, ...(options.shared ?? [])];
|
|
169
|
+
this.elements = options.elements;
|
|
151
170
|
}
|
|
152
171
|
schema(collection) {
|
|
153
172
|
return schemaOf(this.schemas, collection);
|
|
@@ -223,7 +242,7 @@ export class MemoryEntryStore {
|
|
|
223
242
|
await this.paths.rewrite(nodes.map((n) => ({ kind: "entry", id: n.id })), entryPathRows(nodes, this.byId, this.schema(collection), this.locales));
|
|
224
243
|
}
|
|
225
244
|
writeRefs(entry) {
|
|
226
|
-
this.refs.rewrite({ sourceKind: "entry", sourceId: entry.id }, entryRefEdges(this.liveFieldSets(entry), this.schema(entry.collection)));
|
|
245
|
+
this.refs.rewrite({ sourceKind: "entry", sourceId: entry.id }, entryRefEdges(this.liveFieldSets(entry), this.schema(entry.collection), this.elements));
|
|
227
246
|
}
|
|
228
247
|
siblings(collection, parentId, except) {
|
|
229
248
|
return [...this.entries.values()]
|
|
@@ -306,13 +325,20 @@ export class MemoryEntryStore {
|
|
|
306
325
|
async list(collection, options) {
|
|
307
326
|
const schema = this.schema(collection);
|
|
308
327
|
let rows = [...this.entries.values()].filter((e) => e.collection === collection);
|
|
309
|
-
if (options?.status) {
|
|
328
|
+
if (options?.status || options?.locale !== undefined) {
|
|
310
329
|
const locale = options.locale ?? this.locales.default;
|
|
311
|
-
rows = rows.filter((e) =>
|
|
330
|
+
rows = rows.filter((e) => {
|
|
331
|
+
const row = e.locales[locale];
|
|
332
|
+
return row !== undefined && (!options.status || row.status === options.status);
|
|
333
|
+
});
|
|
312
334
|
}
|
|
313
335
|
if (options?.parent !== undefined)
|
|
314
336
|
rows = rows.filter((e) => e.parentId === options.parent);
|
|
337
|
+
if (options?.where)
|
|
338
|
+
rows = rows.filter((e) => matchesWhere(e.fields, options.where));
|
|
315
339
|
rows.sort(compareEntries(options?.order ?? collectionOrder(schema), (e) => this.order.get(e.id)));
|
|
340
|
+
if (typeof options?.limit === "number")
|
|
341
|
+
rows = rows.slice(0, options.limit);
|
|
316
342
|
return rows.map((e) => this.snapshot(e));
|
|
317
343
|
}
|
|
318
344
|
async getMany(collection, ids, locale, options) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
type Defaultable = {
|
|
2
|
+
type?: string;
|
|
3
|
+
default?: unknown;
|
|
4
|
+
};
|
|
5
|
+
export declare function defaultsOf(descriptors: Record<string, Defaultable>): Record<string, unknown>;
|
|
6
|
+
/** `values` with every absent key filled from the descriptors' defaults; a new object. */
|
|
7
|
+
export declare function withDefaults(values: Record<string, unknown> | undefined, descriptors: Record<string, Defaultable>): Record<string, unknown>;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function defaultsOf(descriptors) {
|
|
2
|
+
const out = {};
|
|
3
|
+
for (const [key, d] of Object.entries(descriptors)) {
|
|
4
|
+
if (d.default !== undefined)
|
|
5
|
+
out[key] = d.default;
|
|
6
|
+
else if (d.type === "boolean")
|
|
7
|
+
out[key] = false;
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
/** `values` with every absent key filled from the descriptors' defaults; a new object. */
|
|
12
|
+
export function withDefaults(values, descriptors) {
|
|
13
|
+
const out = defaultsOf(descriptors);
|
|
14
|
+
for (const [key, v] of Object.entries(values ?? {}))
|
|
15
|
+
if (v !== undefined)
|
|
16
|
+
out[key] = v;
|
|
17
|
+
return out;
|
|
18
|
+
}
|