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,129 @@
|
|
|
1
|
+
// The custom section's reconcile functions (spec 2026-09-14 §6): pure,
|
|
2
|
+
// shared by the admin widget, the validator and the tests. Never silent
|
|
3
|
+
// deletion — a removed column's content folds into the last survivor.
|
|
4
|
+
import { ALIGN_STEPS, GAP_STEPS, MAX_COLUMNS, SPAN_TOTAL } from "./schema.js";
|
|
5
|
+
export const EMPTY_DOC = { type: "doc", content: [{ type: "paragraph" }] };
|
|
6
|
+
export function defaultRow(columns = 1) {
|
|
7
|
+
const n = Math.max(1, Math.min(MAX_COLUMNS, columns));
|
|
8
|
+
return { spans: renormalize(Array(n).fill(1)), gap: "md", align: "top", reverse: false, columns: Array.from({ length: n }, () => structuredClone(EMPTY_DOC)) };
|
|
9
|
+
}
|
|
10
|
+
/** Integers ≥ 1 summing to 12, proportions kept: scale, floor, hand the
|
|
11
|
+
* remainder out by largest fraction (ties to the earlier column), then
|
|
12
|
+
* lift any 0 to 1 from the largest column. */
|
|
13
|
+
export function renormalize(spans) {
|
|
14
|
+
const n = spans.length;
|
|
15
|
+
if (n === 0)
|
|
16
|
+
return [];
|
|
17
|
+
if (n > SPAN_TOTAL)
|
|
18
|
+
return renormalize(spans.slice(0, SPAN_TOTAL));
|
|
19
|
+
const clean = spans.map((s) => (Number.isFinite(s) && s > 0 ? s : 1));
|
|
20
|
+
const total = clean.reduce((a, b) => a + b, 0);
|
|
21
|
+
const exact = clean.map((s) => (s / total) * SPAN_TOTAL);
|
|
22
|
+
const out = exact.map((e) => Math.floor(e));
|
|
23
|
+
let left = SPAN_TOTAL - out.reduce((a, b) => a + b, 0);
|
|
24
|
+
const order = exact.map((e, i) => [e - out[i], i]).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
|
|
25
|
+
for (const [, i] of order) {
|
|
26
|
+
if (left <= 0)
|
|
27
|
+
break;
|
|
28
|
+
out[i]++;
|
|
29
|
+
left--;
|
|
30
|
+
}
|
|
31
|
+
for (let i = 0; i < n; i++) {
|
|
32
|
+
while (out[i] < 1) {
|
|
33
|
+
const largest = out.indexOf(Math.max(...out));
|
|
34
|
+
out[largest]--;
|
|
35
|
+
out[i]++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
export function setColumnCount(row, count) {
|
|
41
|
+
const n = Math.max(1, Math.min(MAX_COLUMNS, count));
|
|
42
|
+
const current = row.columns.length;
|
|
43
|
+
if (n === current)
|
|
44
|
+
return row;
|
|
45
|
+
if (n > current) {
|
|
46
|
+
const spans = [...row.spans];
|
|
47
|
+
const columns = [...row.columns];
|
|
48
|
+
while (spans.length < n) {
|
|
49
|
+
spans.push(spans[spans.length - 1] ?? 1);
|
|
50
|
+
columns.push(structuredClone(EMPTY_DOC));
|
|
51
|
+
}
|
|
52
|
+
return { ...row, spans: renormalize(spans), columns };
|
|
53
|
+
}
|
|
54
|
+
const keep = row.columns.slice(0, n);
|
|
55
|
+
const folded = row.columns.slice(n).reduce((acc, doc) => concatDocs(acc, doc), keep[n - 1]);
|
|
56
|
+
keep[n - 1] = folded;
|
|
57
|
+
return { ...row, spans: renormalize(row.spans.slice(0, n)), columns: keep };
|
|
58
|
+
}
|
|
59
|
+
/** The boundary between columns `handle` and `handle + 1` moved so the left side is `left` twelfths; both sides keep ≥ 1. */
|
|
60
|
+
export function setSpan(spans, handle, left) {
|
|
61
|
+
if (handle < 0 || handle >= spans.length - 1)
|
|
62
|
+
return spans;
|
|
63
|
+
const pair = spans[handle] + spans[handle + 1];
|
|
64
|
+
const l = Math.max(1, Math.min(pair - 1, Math.round(left)));
|
|
65
|
+
const next = [...spans];
|
|
66
|
+
next[handle] = l;
|
|
67
|
+
next[handle + 1] = pair - l;
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
/** The left span a pointer at `x` over a bar `width` px wide asks for, for the handle between `handle` and `handle + 1`. */
|
|
71
|
+
export function spanAtX(x, width, spans, handle) {
|
|
72
|
+
const before = spans.slice(0, handle).reduce((a, b) => a + b, 0);
|
|
73
|
+
const twelfth = Math.max(1, width) / SPAN_TOTAL;
|
|
74
|
+
const pair = spans[handle] + spans[handle + 1];
|
|
75
|
+
const wanted = Math.round(x / twelfth) - before;
|
|
76
|
+
return Math.max(1, Math.min(pair - 1, wanted));
|
|
77
|
+
}
|
|
78
|
+
export function presetsFor(n) {
|
|
79
|
+
if (n === 2)
|
|
80
|
+
return [{ label: "1/2 · 1/2", spans: [6, 6] }, { label: "1/3 · 2/3", spans: [4, 8] }, { label: "2/3 · 1/3", spans: [8, 4] }];
|
|
81
|
+
if (n === 3)
|
|
82
|
+
return [{ label: "1/3 · 1/3 · 1/3", spans: [4, 4, 4] }];
|
|
83
|
+
if (n === 4)
|
|
84
|
+
return [{ label: "1/4 × 4", spans: [3, 3, 3, 3] }];
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const isDoc = (v) => typeof v === "object" && v !== null && v.type === "doc" && Array.isArray(v.content);
|
|
88
|
+
/** A paragraph with no children: what an untouched column holds. Not content, so a fold never appends it. */
|
|
89
|
+
const isBlankNode = (n) => typeof n === "object" && n !== null && n.type === "paragraph"
|
|
90
|
+
&& (!Array.isArray(n.content) || (n.content.length === 0));
|
|
91
|
+
const isBlankDoc = (v) => isDoc(v) && v.content.every(isBlankNode);
|
|
92
|
+
export function concatDocs(a, b) {
|
|
93
|
+
if (!isDoc(a) || isBlankDoc(a))
|
|
94
|
+
return isDoc(b) ? b : structuredClone(EMPTY_DOC);
|
|
95
|
+
if (!isDoc(b) || isBlankDoc(b))
|
|
96
|
+
return a;
|
|
97
|
+
return { type: "doc", content: [...a.content, ...b.content] };
|
|
98
|
+
}
|
|
99
|
+
/** The structural problems the publish validator reports, by dotted path. */
|
|
100
|
+
export function rowsProblems(rows) {
|
|
101
|
+
if (!Array.isArray(rows))
|
|
102
|
+
return [{ field: "rows", message: "Must be a list." }];
|
|
103
|
+
const out = [];
|
|
104
|
+
rows.forEach((row, i) => {
|
|
105
|
+
const r = row;
|
|
106
|
+
const columns = Array.isArray(r?.columns) ? r.columns : [];
|
|
107
|
+
const spans = Array.isArray(r?.spans) ? r.spans : [];
|
|
108
|
+
if (columns.length < 1 || columns.length > MAX_COLUMNS) {
|
|
109
|
+
out.push({ field: `rows.${i}.columns`, message: "Between 1 and 6 columns." });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (spans.length !== columns.length) {
|
|
113
|
+
out.push({ field: `rows.${i}.spans`, message: "One span per column." });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!spans.every((s) => Number.isInteger(s) && s >= 1) || spans.reduce((a, b) => a + b, 0) !== SPAN_TOTAL)
|
|
117
|
+
out.push({ field: `rows.${i}.spans`, message: "Spans must sum to 12." });
|
|
118
|
+
if (r?.gap !== undefined && !GAP_STEPS.includes(r.gap))
|
|
119
|
+
out.push({ field: `rows.${i}.gap`, message: `Must be one of: ${GAP_STEPS.join(", ")}.` });
|
|
120
|
+
if (r?.align !== undefined && !ALIGN_STEPS.includes(r.align))
|
|
121
|
+
out.push({ field: `rows.${i}.align`, message: `Must be one of: ${ALIGN_STEPS.join(", ")}.` });
|
|
122
|
+
});
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
export function columnsLabel(rows) {
|
|
126
|
+
const first = Array.isArray(rows) ? rows[0] : undefined;
|
|
127
|
+
const n = Array.isArray(first?.columns) ? first.columns.length : 0;
|
|
128
|
+
return n === 0 ? "Custom section" : `Custom section · ${n} column${n === 1 ? "" : "s"}`;
|
|
129
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Descriptor } from "../fields.ts";
|
|
2
|
+
import { type Toolset } from "../toolset.ts";
|
|
3
|
+
export declare const CUSTOM_NODE_TYPE = "custom";
|
|
4
|
+
export declare const MAX_COLUMNS = 6;
|
|
5
|
+
export declare const SPAN_TOTAL = 12;
|
|
6
|
+
export declare const DEFAULT_BREAKPOINT = "48rem";
|
|
7
|
+
export declare const GAP_STEPS: readonly ["sm", "md", "lg"];
|
|
8
|
+
export type GapStep = (typeof GAP_STEPS)[number];
|
|
9
|
+
export declare const GAP_VALUES: Record<GapStep, string>;
|
|
10
|
+
export declare const ALIGN_STEPS: readonly ["top", "center", "bottom", "stretch"];
|
|
11
|
+
export type AlignStep = (typeof ALIGN_STEPS)[number];
|
|
12
|
+
/** CSS `align-items` per step — the only keywords the emitter writes. */
|
|
13
|
+
export declare const ALIGN_VALUES: Record<AlignStep, string>;
|
|
14
|
+
export type CustomRow = {
|
|
15
|
+
spans: number[];
|
|
16
|
+
gap: GapStep;
|
|
17
|
+
align: AlignStep;
|
|
18
|
+
reverse: boolean;
|
|
19
|
+
columns: unknown[];
|
|
20
|
+
};
|
|
21
|
+
export type CustomFields = {
|
|
22
|
+
rows: CustomRow[];
|
|
23
|
+
};
|
|
24
|
+
/** The columns' toolset: the developer's, or the richtext default plus the image node. */
|
|
25
|
+
export declare function customToolset(toolset?: Toolset): Toolset;
|
|
26
|
+
export declare function customFields(toolset: Toolset): Record<string, Descriptor>;
|
|
27
|
+
/** What every section map gets for the node type: the walks, the resolver and the validator read it like a section's schema. */
|
|
28
|
+
export declare function customSchema(toolset: Toolset): {
|
|
29
|
+
name: string;
|
|
30
|
+
fields: Record<string, Descriptor>;
|
|
31
|
+
settings: Record<string, Descriptor>;
|
|
32
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { DEFAULT_TOOLSET } from "../toolset.js";
|
|
2
|
+
export const CUSTOM_NODE_TYPE = "custom";
|
|
3
|
+
export const MAX_COLUMNS = 6;
|
|
4
|
+
export const SPAN_TOTAL = 12;
|
|
5
|
+
export const DEFAULT_BREAKPOINT = "48rem";
|
|
6
|
+
export const GAP_STEPS = ["sm", "md", "lg"];
|
|
7
|
+
export const GAP_VALUES = { sm: "1rem", md: "2rem", lg: "3rem" };
|
|
8
|
+
export const ALIGN_STEPS = ["top", "center", "bottom", "stretch"];
|
|
9
|
+
/** CSS `align-items` per step — the only keywords the emitter writes. */
|
|
10
|
+
export const ALIGN_VALUES = { top: "start", center: "center", bottom: "end", stretch: "stretch" };
|
|
11
|
+
/** The columns' toolset: the developer's, or the richtext default plus the image node. */
|
|
12
|
+
export function customToolset(toolset) {
|
|
13
|
+
if (toolset)
|
|
14
|
+
return toolset;
|
|
15
|
+
return { ...DEFAULT_TOOLSET, nodes: [...(DEFAULT_TOOLSET.nodes ?? []), "image"] };
|
|
16
|
+
}
|
|
17
|
+
const radio = (options, labels, fallback) => ({ type: "radio", options: options.map((value) => ({ value, label: labels[value] })), default: fallback });
|
|
18
|
+
export function customFields(toolset) {
|
|
19
|
+
return {
|
|
20
|
+
rows: {
|
|
21
|
+
type: "list", widget: "rows", min: 1,
|
|
22
|
+
item: {
|
|
23
|
+
type: "object",
|
|
24
|
+
fields: {
|
|
25
|
+
spans: { type: "spans" },
|
|
26
|
+
gap: radio(GAP_STEPS, { sm: "S", md: "M", lg: "L" }, "md"),
|
|
27
|
+
align: radio(ALIGN_STEPS, { top: "Top", center: "Center", bottom: "Bottom", stretch: "Stretch" }, "top"),
|
|
28
|
+
reverse: { type: "boolean", default: false },
|
|
29
|
+
// An empty column publishes (it is spacing); the row structure is rowsProblems' job.
|
|
30
|
+
columns: { type: "list", min: 1, max: MAX_COLUMNS, item: { type: "richtext", toolset, optional: true } },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** What every section map gets for the node type: the walks, the resolver and the validator read it like a section's schema. */
|
|
37
|
+
export function customSchema(toolset) {
|
|
38
|
+
return { name: CUSTOM_NODE_TYPE, fields: customFields(toolset), settings: {} };
|
|
39
|
+
}
|
package/dist/entry-page.d.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { ComponentType, ReactElement } from "react";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
3
|
import type { EntryRecord } from "./entry-store.ts";
|
|
4
|
+
import type { LocaleAlternate } from "./paths.ts";
|
|
4
5
|
export type EntryPageSchema = {
|
|
5
6
|
kind: "entryPage";
|
|
6
7
|
name: string;
|
|
7
8
|
collection: string;
|
|
8
9
|
};
|
|
10
|
+
/** What an entry page renders: the entry in the render locale, its path
|
|
11
|
+
* there, and its address in every supported locale (spec 2026-09-15 §1). */
|
|
9
12
|
export type EntryPageProps = {
|
|
10
13
|
entry: EntryRecord;
|
|
11
14
|
path: string;
|
|
15
|
+
locale: string;
|
|
16
|
+
locales: Record<string, LocaleAlternate | null>;
|
|
12
17
|
};
|
|
13
18
|
export type PairedEntryPage = ((props: EntryPageProps) => ReactElement) & {
|
|
14
19
|
schema: EntryPageSchema;
|
package/dist/entry-page.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
export function entryPage(collection, View) {
|
|
3
|
-
const Component = ((props) => _jsx(View, {
|
|
3
|
+
const Component = ((props) => _jsx(View, { ...props }));
|
|
4
4
|
Component.schema = { kind: "entryPage", name: `entry:${collection.name}`, collection: collection.name };
|
|
5
5
|
return Component;
|
|
6
6
|
}
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { GlobalSchema } from "./global.ts";
|
|
3
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";
|
|
@@ -82,7 +82,7 @@ export type AddEntryLocaleOptions = {
|
|
|
82
82
|
export type EntryStoreOptions = {
|
|
83
83
|
paths?: PathIndex;
|
|
84
84
|
locales?: LocaleSet;
|
|
85
|
-
|
|
85
|
+
globals?: GlobalSchema[];
|
|
86
86
|
/** Registered Elements a richtext document may embed (spec 2026-09-08 block nodes §2). */
|
|
87
87
|
elements?: SectionLike[];
|
|
88
88
|
};
|
|
@@ -154,8 +154,8 @@ export interface EntryStore {
|
|
|
154
154
|
pathsOf(collection: string, id: string): Promise<Record<string, string>>;
|
|
155
155
|
}
|
|
156
156
|
export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
|
|
157
|
-
/** A
|
|
158
|
-
export declare const
|
|
157
|
+
/** A global item is a collection with exactly one entry (spec 2026-09-07). */
|
|
158
|
+
export declare const isGlobalSchema: (schema: CollectionSchema<any>) => boolean;
|
|
159
159
|
export declare const DEFAULT_ENTRY_LOCALES: LocaleSet;
|
|
160
160
|
export declare const entryErrors: {
|
|
161
161
|
notFound: (collection: string, id: string) => Error;
|
package/dist/entry-store.js
CHANGED
|
@@ -13,7 +13,7 @@ import { collectionDepth, collectionOrder } from "./collections.js";
|
|
|
13
13
|
import { collectEntryRefs } from "./refs.js";
|
|
14
14
|
import { MemoryRefIndex } from "./ref-index.js";
|
|
15
15
|
import { MemoryPathIndex } from "./path-index.js";
|
|
16
|
-
import {
|
|
16
|
+
import { globalEntryId } from "./global-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
19
|
/** The field types whose value is an array: a scalar `where` on one is
|
|
@@ -40,8 +40,8 @@ export function schemaOf(collections, name) {
|
|
|
40
40
|
throw new Error(`smoodly: no collection named "${name}".`);
|
|
41
41
|
return schema;
|
|
42
42
|
}
|
|
43
|
-
/** A
|
|
44
|
-
export const
|
|
43
|
+
/** A global item is a collection with exactly one entry (spec 2026-09-07). */
|
|
44
|
+
export const isGlobalSchema = (schema) => schema.kind === "global";
|
|
45
45
|
let counter = 0;
|
|
46
46
|
const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
|
|
47
47
|
export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
|
|
@@ -62,11 +62,11 @@ export const entryErrors = {
|
|
|
62
62
|
lastLocale: (id) => new Error(`smoodly: entry "${id}" must exist in at least one locale — delete the entry instead.`),
|
|
63
63
|
localeInUse: (locale, titles) => new Error(`smoodly: child entries still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
|
|
64
64
|
moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the entry cannot move under it.`),
|
|
65
|
-
//
|
|
66
|
-
singleton: (name) => new Error(`smoodly:
|
|
67
|
-
owned: (name, verb) => new Error(`smoodly:
|
|
68
|
-
noSlug: (name) => new Error(`smoodly:
|
|
69
|
-
everyLocale: (name) => new Error(`smoodly:
|
|
65
|
+
// Global items (spec 2026-09-07 §3): one row, no URL, every locale, code-owned.
|
|
66
|
+
singleton: (name) => new Error(`smoodly: global item "${name}" already exists — it is a singleton.`),
|
|
67
|
+
owned: (name, verb) => new Error(`smoodly: global item "${name}" is owned by code — ${verb}.`),
|
|
68
|
+
noSlug: (name) => new Error(`smoodly: global item "${name}" has no URL — it cannot take a slug.`),
|
|
69
|
+
everyLocale: (name) => new Error(`smoodly: global item "${name}" exists in every locale — it cannot be removed from one.`),
|
|
70
70
|
foreignVersion: (collection, id, locale, versionId) => new Error(`smoodly: "${versionId}" is not a version of entry "${id}" in "${collection}", locale "${locale}".`),
|
|
71
71
|
};
|
|
72
72
|
/** The keys a locale row owns: every field with .localized(), except
|
|
@@ -167,7 +167,7 @@ export class MemoryEntryStore {
|
|
|
167
167
|
this.byId = (id) => this.entries.get(id);
|
|
168
168
|
this.paths = options.paths ?? new MemoryPathIndex();
|
|
169
169
|
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
170
|
-
this.schemas = [...collections, ...(options.
|
|
170
|
+
this.schemas = [...collections, ...(options.globals ?? [])];
|
|
171
171
|
this.elements = options.elements;
|
|
172
172
|
}
|
|
173
173
|
schema(collection) {
|
|
@@ -304,7 +304,7 @@ export class MemoryEntryStore {
|
|
|
304
304
|
const schema = this.schema(collection);
|
|
305
305
|
assertSupportedLocale(options.locale, this.locales);
|
|
306
306
|
const parentId = options.parentId ?? null;
|
|
307
|
-
if (
|
|
307
|
+
if (isGlobalSchema(schema)) {
|
|
308
308
|
if ([...this.entries.values()].some((e) => e.collection === collection))
|
|
309
309
|
throw entryErrors.singleton(collection);
|
|
310
310
|
if (slugOf(fields) !== null)
|
|
@@ -316,10 +316,10 @@ export class MemoryEntryStore {
|
|
|
316
316
|
this.assertSiblingSlug(collection, parentId, options.locale, slug);
|
|
317
317
|
const now = Date.now();
|
|
318
318
|
const entry = {
|
|
319
|
-
// A
|
|
319
|
+
// A global item's id is derived, not minted (spec 2026-09-07 §10):
|
|
320
320
|
// the same uuid v5 the Supabase adapter computes, so self-host and
|
|
321
321
|
// cloud agree on the row a materialization would create.
|
|
322
|
-
id:
|
|
322
|
+
id: isGlobalSchema(schema) ? await globalEntryId(DEFAULT_SPACE_ID, collection) : uid("entry"),
|
|
323
323
|
collection, parentId, fields: shared,
|
|
324
324
|
locales: {
|
|
325
325
|
[options.locale]: {
|
|
@@ -393,7 +393,7 @@ export class MemoryEntryStore {
|
|
|
393
393
|
const row = entryLocaleRow(entry, locale);
|
|
394
394
|
this.ensureLiveSnapshot(entry, locale);
|
|
395
395
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
396
|
-
if (
|
|
396
|
+
if (isGlobalSchema(schema) && slug !== null)
|
|
397
397
|
throw entryErrors.noSlug(collection);
|
|
398
398
|
this.assertSiblingSlug(collection, entry.parentId, locale, slug, id);
|
|
399
399
|
const before = { fields: entry.fields, row: { ...row } };
|
|
@@ -479,7 +479,7 @@ export class MemoryEntryStore {
|
|
|
479
479
|
}
|
|
480
480
|
async removeLocale(collection, id, locale) {
|
|
481
481
|
const schema = this.schema(collection);
|
|
482
|
-
if (
|
|
482
|
+
if (isGlobalSchema(schema))
|
|
483
483
|
throw entryErrors.everyLocale(collection);
|
|
484
484
|
const entry = this.must(collection, id);
|
|
485
485
|
const row = entryLocaleRow(entry, locale);
|
|
@@ -533,7 +533,7 @@ export class MemoryEntryStore {
|
|
|
533
533
|
return v ? structuredClone(v) : null;
|
|
534
534
|
}
|
|
535
535
|
async move(collection, id, to) {
|
|
536
|
-
if (
|
|
536
|
+
if (isGlobalSchema(this.schema(collection)))
|
|
537
537
|
throw entryErrors.owned(collection, "it has no tree position");
|
|
538
538
|
const entry = this.must(collection, id);
|
|
539
539
|
const subtree = this.subtree(entry);
|
|
@@ -572,7 +572,7 @@ export class MemoryEntryStore {
|
|
|
572
572
|
const entry = this.row(collection, id);
|
|
573
573
|
if (!entry)
|
|
574
574
|
return;
|
|
575
|
-
if (
|
|
575
|
+
if (isGlobalSchema(this.schema(collection)))
|
|
576
576
|
throw entryErrors.owned(collection, "remove the registration to remove it");
|
|
577
577
|
if ([...this.entries.values()].some((e) => e.parentId === id))
|
|
578
578
|
throw entryErrors.hasChildren(collection, id);
|
|
@@ -587,7 +587,7 @@ export class MemoryEntryStore {
|
|
|
587
587
|
this.refs.remove({ sourceKind: "entry", sourceId: id });
|
|
588
588
|
}
|
|
589
589
|
async reorder(collection, orderedIds) {
|
|
590
|
-
if (
|
|
590
|
+
if (isGlobalSchema(this.schema(collection)))
|
|
591
591
|
throw entryErrors.owned(collection, "it has no order");
|
|
592
592
|
orderedIds.forEach((id, i) => {
|
|
593
593
|
const entry = this.row(collection, id);
|
package/dist/fields.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Toolset } from "./toolset.ts";
|
|
2
2
|
import { type ResolvedFile, type ResolvedImage, type ResolvedVideo } from "./media.ts";
|
|
3
|
+
import { type Condition, when } from "./conditions.ts";
|
|
3
4
|
export type Descriptor = {
|
|
4
5
|
type: string;
|
|
5
6
|
[key: string]: unknown;
|
|
@@ -43,7 +44,8 @@ export type FieldBuilder<Value = unknown, Flags extends BuilderFlags = {}> = {
|
|
|
43
44
|
* - no call — hydrate the entry itself; its refs stay ids
|
|
44
45
|
*/
|
|
45
46
|
resolve(...projection: (string | Record<string, unknown>)[]): FieldBuilder<Value, Flags>;
|
|
46
|
-
|
|
47
|
+
/** Hide the field while the condition holds (spec 2026-09-14 §12): it leaves the form and validation skips it; the stored value is kept. */
|
|
48
|
+
hidden(condition: Condition): FieldBuilder<Value, Flags>;
|
|
47
49
|
/** phantom, type-level only */
|
|
48
50
|
readonly __value?: Value;
|
|
49
51
|
readonly __flags?: Flags;
|
|
@@ -83,7 +85,8 @@ export declare const f: {
|
|
|
83
85
|
richtext: (opts?: {
|
|
84
86
|
toolset?: Toolset;
|
|
85
87
|
}) => FieldBuilder<unknown, {}>;
|
|
86
|
-
|
|
88
|
+
/** A condition for `.hidden()`: `f.when("settings.mediaType").is("video")`. */
|
|
89
|
+
when: typeof when;
|
|
87
90
|
boolean: () => FieldBuilder<boolean, {}>;
|
|
88
91
|
/** One value from a list, shown as a dropdown. */
|
|
89
92
|
select: <const O extends readonly OptionInput[]>(options: O) => FieldBuilder<OptionValue<O[number]>, {}>;
|
package/dist/fields.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// keyed by descriptor type. Chaining is immutable.
|
|
4
4
|
import { DEFAULT_TOOLSET } from "./toolset.js";
|
|
5
5
|
import { parseAspect } from "./media.js";
|
|
6
|
+
import { when } from "./conditions.js";
|
|
6
7
|
/** Normalizes a schema's option list to objects; refuses an empty list,
|
|
7
8
|
* since a choice with nothing to choose is a schema bug (spec 2026-09-10
|
|
8
9
|
* page fields §1). */
|
|
@@ -43,7 +44,7 @@ function builder(descriptor) {
|
|
|
43
44
|
return chain({ resolve: projection[0] });
|
|
44
45
|
return chain({ resolve: Object.fromEntries(projection.map((k) => [k, true])) });
|
|
45
46
|
},
|
|
46
|
-
|
|
47
|
+
hidden: (condition) => chain({ hidden: condition }),
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
export const describePath = (path) => (path === "" ? "the item" : `"${path}"`);
|
|
@@ -97,7 +98,8 @@ export const f = {
|
|
|
97
98
|
video: () => builder({ type: "video" }),
|
|
98
99
|
file: () => builder({ type: "file" }),
|
|
99
100
|
richtext: (opts) => builder({ type: "richtext", toolset: opts?.toolset ?? DEFAULT_TOOLSET }),
|
|
100
|
-
|
|
101
|
+
/** A condition for `.hidden()`: `f.when("settings.mediaType").is("video")`. */
|
|
102
|
+
when,
|
|
101
103
|
boolean: () => builder({ type: "boolean" }),
|
|
102
104
|
/** One value from a list, shown as a dropdown. */
|
|
103
105
|
select: (options) => builder({ type: "select", options: normalizeOptions(options) }),
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
/** RFC 4122 §4.3 uuid v5: sha1(namespace bytes ‖ name bytes), version 5,
|
|
2
2
|
* variant 10xx, first 16 bytes of the digest. */
|
|
3
|
-
export declare function
|
|
3
|
+
export declare function globalEntryId(space: string, name: string): Promise<string>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// The row id of a
|
|
1
|
+
// The row id of a global item (spec 2026-09-07 §10 "§3 row id"): uuid v5
|
|
2
2
|
// of (space id as the namespace, item name as the name). Deterministic in
|
|
3
3
|
// both adapters, so a second concurrent materialization of the same item
|
|
4
4
|
// hits the `entries` primary key instead of adding an unremovable
|
|
5
|
-
// duplicate — `delete` refuses every
|
|
5
|
+
// duplicate — `delete` refuses every global row. The space is part of it
|
|
6
6
|
// because the primary key is `id` alone and, in the cloud, many spaces
|
|
7
7
|
// share one table: a name-only id would make space B's footer collide
|
|
8
8
|
// with space A's.
|
|
@@ -16,7 +16,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
16
16
|
/** The namespace's 16 raw bytes — RFC 4122 hashes the bytes, not the text. */
|
|
17
17
|
function uuidBytes(uuid) {
|
|
18
18
|
if (!UUID_RE.test(uuid))
|
|
19
|
-
throw new Error(`smoodly: "${uuid}" is not a uuid — a
|
|
19
|
+
throw new Error(`smoodly: "${uuid}" is not a uuid — a global item's namespace is the space id.`);
|
|
20
20
|
const hex = uuid.replace(/-/g, "");
|
|
21
21
|
const bytes = new Uint8Array(16);
|
|
22
22
|
for (let i = 0; i < 16; i++)
|
|
@@ -25,7 +25,7 @@ function uuidBytes(uuid) {
|
|
|
25
25
|
}
|
|
26
26
|
/** RFC 4122 §4.3 uuid v5: sha1(namespace bytes ‖ name bytes), version 5,
|
|
27
27
|
* variant 10xx, first 16 bytes of the digest. */
|
|
28
|
-
export async function
|
|
28
|
+
export async function globalEntryId(space, name) {
|
|
29
29
|
const input = new Uint8Array([...uuidBytes(space), ...new TextEncoder().encode(name)]);
|
|
30
30
|
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-1", input));
|
|
31
31
|
const bytes = digest.subarray(0, 16);
|
package/dist/global.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CollectionSchema } from "./collections.ts";
|
|
2
|
+
import type { Descriptor } from "./fields.ts";
|
|
3
|
+
import type { PairedComponent } from "./pairing.tsx";
|
|
4
|
+
import type { BuilderMap, NamespaceValues, SectionSchema } from "./schema.ts";
|
|
5
|
+
import type { StyleProps } from "./styles.ts";
|
|
6
|
+
/** The reserved tree node type of a placement. */
|
|
7
|
+
export declare const GLOBAL_NODE_TYPE = "global";
|
|
8
|
+
/** The reserved keys a visual item's settings, styles steps and css live under in its row (spec 2026-09-14 §8). */
|
|
9
|
+
export declare const SETTINGS_KEY = "$settings";
|
|
10
|
+
export declare const STYLES_KEY = "$styles";
|
|
11
|
+
export declare const CSS_KEY = "$css";
|
|
12
|
+
/** The node id a layout-rendered visual item carries (spec 2026-09-13 §1):
|
|
13
|
+
* `getGlobal` stamps it into the styles' `__smd`, the section wrapper emits it,
|
|
14
|
+
* the canvas selects by it. A placement keeps its own tree node id
|
|
15
|
+
* (`global-xxxxxxxx`, no colon). */
|
|
16
|
+
export declare const GLOBAL_NODE_PREFIX = "global:";
|
|
17
|
+
export declare const globalNodeId: (name: string) => string;
|
|
18
|
+
/** The item a layout node id names, or null for any other id. */
|
|
19
|
+
export declare function globalNameOf(id: string): string | null;
|
|
20
|
+
export type GlobalSchema<V = Record<string, unknown>, N extends string = string> = CollectionSchema<any, N> & {
|
|
21
|
+
kind: "global";
|
|
22
|
+
/** The registered section a visual item renders through; absent = an object item. */
|
|
23
|
+
section?: string;
|
|
24
|
+
/** The section's settings descriptors (empty for an object item). */
|
|
25
|
+
settings: Record<string, Descriptor>;
|
|
26
|
+
sample?: Record<string, unknown>;
|
|
27
|
+
/** phantom: what getGlobal returns */
|
|
28
|
+
readonly __value?: V;
|
|
29
|
+
};
|
|
30
|
+
export type GlobalLink = {
|
|
31
|
+
item: string;
|
|
32
|
+
ref: string;
|
|
33
|
+
};
|
|
34
|
+
/** The placement a tree node is, or null for any other node. Accepts a
|
|
35
|
+
* bare `{ type, fields }` shape or a full tree node — `id` (and any
|
|
36
|
+
* other tree-node property) is ignored. */
|
|
37
|
+
export declare function globalLink(node: {
|
|
38
|
+
id?: string;
|
|
39
|
+
type: string;
|
|
40
|
+
fields?: Record<string, unknown>;
|
|
41
|
+
}): GlobalLink | null;
|
|
42
|
+
type GlobalInput<N extends string = string> = {
|
|
43
|
+
name: N;
|
|
44
|
+
title: string;
|
|
45
|
+
};
|
|
46
|
+
export declare function globalItem<F extends BuilderMap, const N extends string = string>(input: GlobalInput<N> & {
|
|
47
|
+
fields: F;
|
|
48
|
+
section?: undefined;
|
|
49
|
+
}): GlobalSchema<NamespaceValues<F>, N>;
|
|
50
|
+
export declare function globalItem<F extends BuilderMap, St extends BuilderMap, const N extends string = string>(input: GlobalInput<N> & {
|
|
51
|
+
section: PairedComponent<SectionSchema<F, St>, F, St>;
|
|
52
|
+
fields?: undefined;
|
|
53
|
+
}): GlobalSchema<NamespaceValues<F> & {
|
|
54
|
+
settings: NamespaceValues<St>;
|
|
55
|
+
styles: StyleProps;
|
|
56
|
+
}, N>;
|
|
57
|
+
export {};
|
package/dist/global.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The reserved tree node type of a placement. */
|
|
2
|
+
export const GLOBAL_NODE_TYPE = "global";
|
|
3
|
+
/** The reserved keys a visual item's settings, styles steps and css live under in its row (spec 2026-09-14 §8). */
|
|
4
|
+
export const SETTINGS_KEY = "$settings";
|
|
5
|
+
export const STYLES_KEY = "$styles";
|
|
6
|
+
export const CSS_KEY = "$css";
|
|
7
|
+
const RESERVED_KEYS = [SETTINGS_KEY, STYLES_KEY, CSS_KEY];
|
|
8
|
+
/** The node id a layout-rendered visual item carries (spec 2026-09-13 §1):
|
|
9
|
+
* `getGlobal` stamps it into the styles' `__smd`, the section wrapper emits it,
|
|
10
|
+
* the canvas selects by it. A placement keeps its own tree node id
|
|
11
|
+
* (`global-xxxxxxxx`, no colon). */
|
|
12
|
+
export const GLOBAL_NODE_PREFIX = "global:";
|
|
13
|
+
export const globalNodeId = (name) => `${GLOBAL_NODE_PREFIX}${name}`;
|
|
14
|
+
/** The item a layout node id names, or null for any other id. */
|
|
15
|
+
export function globalNameOf(id) {
|
|
16
|
+
return id.startsWith(GLOBAL_NODE_PREFIX) && id.length > GLOBAL_NODE_PREFIX.length ? id.slice(GLOBAL_NODE_PREFIX.length) : null;
|
|
17
|
+
}
|
|
18
|
+
/** The placement a tree node is, or null for any other node. Accepts a
|
|
19
|
+
* bare `{ type, fields }` shape or a full tree node — `id` (and any
|
|
20
|
+
* other tree-node property) is ignored. */
|
|
21
|
+
export function globalLink(node) {
|
|
22
|
+
if (node.type !== GLOBAL_NODE_TYPE)
|
|
23
|
+
return null;
|
|
24
|
+
const { item, ref } = node.fields ?? {};
|
|
25
|
+
return typeof item === "string" && typeof ref === "string" ? { item, ref } : null;
|
|
26
|
+
}
|
|
27
|
+
/** `smoodly.global()`: named `globalItem` here so nothing shadows the `global` idiom. */
|
|
28
|
+
export function globalItem(input) {
|
|
29
|
+
if (!input?.name)
|
|
30
|
+
throw new Error("smoodly: a global item requires a name.");
|
|
31
|
+
if ((input.fields === undefined) === (input.section === undefined)) {
|
|
32
|
+
throw new Error(`smoodly: global item "${input.name}" must declare exactly one of \`section\` or \`fields\`.`);
|
|
33
|
+
}
|
|
34
|
+
const fields = input.section
|
|
35
|
+
? input.section.schema.fields
|
|
36
|
+
: Object.fromEntries(Object.entries(input.fields).map(([k, b]) => [k, b.descriptor]));
|
|
37
|
+
for (const key of RESERVED_KEYS) {
|
|
38
|
+
if (Object.prototype.hasOwnProperty.call(fields, key)) {
|
|
39
|
+
throw new Error(`smoodly: global item "${input.name}" has a field named "${key}" — that key is reserved for a visual item's ${key === SETTINGS_KEY ? "settings" : key === STYLES_KEY ? "styles" : "css"}.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
kind: "global",
|
|
44
|
+
name: input.name,
|
|
45
|
+
title: input.title,
|
|
46
|
+
fields,
|
|
47
|
+
settings: input.section ? input.section.schema.settings : {},
|
|
48
|
+
...(input.section ? { section: input.section.schema.name } : {}),
|
|
49
|
+
...(input.section?.schema.sample ? { sample: input.section.schema.sample } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|