smoodly 0.0.7 → 0.0.8
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 +88 -2
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/EditorView.js +64 -8
- package/dist/admin/index.d.ts +3 -2
- package/dist/admin/index.js +1 -0
- package/dist/admin/ops-impl.js +84 -1
- package/dist/admin/ops.d.ts +22 -1
- package/dist/admin/serialize.d.ts +11 -0
- package/dist/admin/serialize.js +8 -0
- package/dist/admin/shared-items.d.ts +9 -0
- package/dist/admin/shared-items.js +61 -0
- package/dist/admin/shell/AdminApp.js +8 -2
- package/dist/admin/shell/SharedForm.d.ts +7 -0
- package/dist/admin/shell/SharedForm.js +146 -0
- package/dist/admin/shell/SharedList.d.ts +6 -0
- package/dist/admin/shell/SharedList.js +62 -0
- package/dist/admin/shell/shared-list.d.ts +14 -0
- package/dist/admin/shell/shared-list.js +17 -0
- package/dist/admin/tree-ops.d.ts +12 -5
- package/dist/admin/tree-ops.js +39 -8
- package/dist/collections.d.ts +4 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +35 -1
- package/dist/entry-store.d.ts +9 -1
- package/dist/entry-store.js +25 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +4 -1
- package/dist/next/page-renderer.d.ts +4 -0
- package/dist/next/page-renderer.js +14 -1
- package/dist/page.js +5 -1
- package/dist/refs.js +8 -0
- package/dist/resolve.d.ts +3 -0
- package/dist/resolve.js +38 -0
- package/dist/revalidate.d.ts +4 -0
- package/dist/revalidate.js +4 -0
- package/dist/shared.d.ts +46 -0
- package/dist/shared.js +36 -0
- package/dist/site.d.ts +10 -0
- package/dist/site.js +28 -1
- package/dist/sql-space.d.ts +1 -1
- package/dist/sql-space.js +4 -9
- package/dist/sql.js +0 -18
- package/dist/supabase-entry-store.d.ts +1 -1
- package/dist/supabase-entry-store.js +23 -5
- package/dist/zones.d.ts +6 -2
- package/dist/zones.js +8 -2
- package/package.json +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// One shared item, one locale (spec 2026-09-07 §6): the entry form's
|
|
4
|
+
// shape without what a singleton never has — no slug, no "new", no
|
|
5
|
+
// delete, no add/remove locale (every supported locale exists by
|
|
6
|
+
// construction). Shared fields above, the locale's own below, and for a
|
|
7
|
+
// visual item a Styles group edited under the reserved `$styles` key.
|
|
8
|
+
// Status is per locale and immediate. "Used on N pages" is the refs
|
|
9
|
+
// index's answer.
|
|
10
|
+
import { useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { STYLES_KEY } from "../../shared.js";
|
|
12
|
+
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
13
|
+
import { relativeTime } from "../ui/format.js";
|
|
14
|
+
import { LocaleSwitcher } from "../ui/LocaleSwitcher.js";
|
|
15
|
+
import { Banner, Breadcrumb, Button, Divider, SectionLabel, Segmented, TopBar } from "../ui/primitives.js";
|
|
16
|
+
import { entryTitle } from "./entries-list.js";
|
|
17
|
+
import { BASE } from "./base.js";
|
|
18
|
+
const STATUSES = ["Draft", "Published"];
|
|
19
|
+
function localeFromUrl(registry) {
|
|
20
|
+
try {
|
|
21
|
+
const l = new URLSearchParams(window.location.search).get("locale");
|
|
22
|
+
if (l && registry.locales.supported.includes(l))
|
|
23
|
+
return l;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// no window — the default is fine
|
|
27
|
+
}
|
|
28
|
+
return registry.locales.default;
|
|
29
|
+
}
|
|
30
|
+
function writeLocaleToUrl(next) {
|
|
31
|
+
try {
|
|
32
|
+
const u = new URL(window.location.href);
|
|
33
|
+
u.searchParams.set("locale", next);
|
|
34
|
+
window.history.replaceState(null, "", u);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// the URL is cosmetic here
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function SharedForm({ ops, registry, name }) {
|
|
41
|
+
const item = registry.shared.find((s) => s.name === name);
|
|
42
|
+
const multilingual = registry.locales.supported.length > 1;
|
|
43
|
+
const [locale, setLocale] = useState(() => localeFromUrl(registry));
|
|
44
|
+
const [record, setRecord] = useState(null);
|
|
45
|
+
const [fields, setFields] = useState(null);
|
|
46
|
+
const [usage, setUsage] = useState([]);
|
|
47
|
+
const [refOptions, setRefOptions] = useState({});
|
|
48
|
+
const [banner, setBanner] = useState(null);
|
|
49
|
+
const [fieldErrors, setFieldErrors] = useState({});
|
|
50
|
+
const [dirty, setDirty] = useState(false);
|
|
51
|
+
const refTargets = useMemo(() => {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [key, d] of Object.entries(item?.fields ?? {})) {
|
|
54
|
+
if ((d.type === "ref" || d.type === "refList") && typeof d.target === "string")
|
|
55
|
+
out[key] = d.target;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}, [item]);
|
|
59
|
+
const localeKeys = useMemo(() => Object.entries(item?.fields ?? {}).filter(([, d]) => d.localized === true).map(([key]) => key), [item]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
let cancelled = false;
|
|
62
|
+
setFields(null);
|
|
63
|
+
setFieldErrors({});
|
|
64
|
+
ops.shared.get(name, locale).then((r) => {
|
|
65
|
+
if (cancelled)
|
|
66
|
+
return;
|
|
67
|
+
if (r.ok) {
|
|
68
|
+
setRecord(r.data.record);
|
|
69
|
+
setFields(r.data.fields);
|
|
70
|
+
setUsage(r.data.usage);
|
|
71
|
+
setDirty(false);
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
setBanner({ tone: "error", text: r.message });
|
|
75
|
+
}).catch(() => { if (!cancelled)
|
|
76
|
+
setBanner({ tone: "error", text: "Couldn't reach the server." }); });
|
|
77
|
+
return () => { cancelled = true; };
|
|
78
|
+
}, [ops, name, locale]);
|
|
79
|
+
// Ref options in the locale being edited — the entry form's rule.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
for (const target of new Set(Object.values(refTargets))) {
|
|
83
|
+
const targetMeta = registry.collections.find((c) => c.name === target);
|
|
84
|
+
ops.entries.list(target).then((r) => {
|
|
85
|
+
if (cancelled || !r.ok)
|
|
86
|
+
return;
|
|
87
|
+
const options = r.data
|
|
88
|
+
.filter((e) => e.locales[locale])
|
|
89
|
+
.map((e) => ({ id: e.id, title: targetMeta ? entryTitle(e, targetMeta, registry, locale) : e.id, status: e.locales[locale].status }));
|
|
90
|
+
setRefOptions((prev) => ({ ...prev, [target]: options }));
|
|
91
|
+
}).catch(console.error);
|
|
92
|
+
}
|
|
93
|
+
return () => { cancelled = true; };
|
|
94
|
+
}, [ops, refTargets, registry, locale]);
|
|
95
|
+
if (!item)
|
|
96
|
+
return _jsx("p", { style: { padding: 32 }, children: "No such shared item." });
|
|
97
|
+
const applyResult = (r, okText) => {
|
|
98
|
+
if (r.ok) {
|
|
99
|
+
setBanner({ tone: "ok", text: okText });
|
|
100
|
+
setFieldErrors({});
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
setBanner({ tone: "error", text: r.message });
|
|
104
|
+
setFieldErrors(Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])));
|
|
105
|
+
return false;
|
|
106
|
+
};
|
|
107
|
+
const save = async () => {
|
|
108
|
+
if (!fields)
|
|
109
|
+
return;
|
|
110
|
+
const r = await ops.shared.save(name, locale, fields);
|
|
111
|
+
if (applyResult(r, "Saved.") && r.ok) {
|
|
112
|
+
setRecord(r.data);
|
|
113
|
+
setDirty(false);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const row = record?.locales[locale];
|
|
117
|
+
const status = row?.status ?? "draft";
|
|
118
|
+
const setPublished = async (next) => {
|
|
119
|
+
if (next === status)
|
|
120
|
+
return;
|
|
121
|
+
const r = await ops.shared.setStatus(name, locale, next);
|
|
122
|
+
if (applyResult(r, next === "published" ? "Published." : "Unpublished.") && r.ok)
|
|
123
|
+
setRecord(r.data);
|
|
124
|
+
};
|
|
125
|
+
const switchLocale = (next) => {
|
|
126
|
+
if (next === locale)
|
|
127
|
+
return;
|
|
128
|
+
if (dirty && !window.confirm("Discard unsaved changes?"))
|
|
129
|
+
return;
|
|
130
|
+
writeLocaleToUrl(next);
|
|
131
|
+
setBanner(null);
|
|
132
|
+
setLocale(next);
|
|
133
|
+
};
|
|
134
|
+
const shared = Object.entries(item.fields).filter(([key]) => !localeKeys.includes(key));
|
|
135
|
+
const own = Object.entries(item.fields).filter(([key]) => localeKeys.includes(key));
|
|
136
|
+
const styles = (fields?.[STYLES_KEY] ?? {});
|
|
137
|
+
const pages = new Set(usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId));
|
|
138
|
+
const widget = ([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields?.[key], error: fieldErrors[key], refOptions: refTargets[key] ? refOptions[refTargets[key]] ?? [] : undefined, onChange: (value) => { setFields((f) => ({ ...(f ?? {}), [key]: value })); setDirty(true); } }, key));
|
|
139
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx(Breadcrumb, { backHref: `${BASE}/shared`, items: [{ label: "Shared content", href: `${BASE}/shared` }, { label: item.title }] }), multilingual && (_jsx(LocaleSwitcher, { registry: registry, present: registry.locales.supported, parentLocales: [], value: locale, adding: false, onSwitch: switchLocale, onAdd: () => undefined }))] }), right: _jsx(Button, { onClick: save, disabled: fields === null, children: "Save" }) }), _jsxs("div", { style: { flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "1fr var(--inspector-w)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 18, padding: "24px 32px", minWidth: 0, overflow: "auto" }, children: [banner && _jsx(Banner, { tone: banner.tone, onDismiss: () => setBanner(null), children: banner.text }), record && (_jsxs("p", { className: "sm-hint", children: [pages.size === 0 ? "Not placed on any page yet." : `Used on ${pages.size} page${pages.size === 1 ? "" : "s"}: `, [...pages].map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), fields !== null ? (_jsxs(_Fragment, { children: [shared.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsx(SectionLabel, { children: "Shared across languages" }), shared.map(widget)] })), own.length > 0 && (_jsxs(_Fragment, { children: [multilingual && _jsxs(SectionLabel, { children: ["In ", locale] }), own.map(widget)] })), item.section !== undefined && Object.keys(item.styles).length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Styles" }), Object.entries(item.styles).map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: styles[key], onChange: (value) => {
|
|
140
|
+
setFields((f) => ({ ...(f ?? {}), [STYLES_KEY]: { ...(f?.[STYLES_KEY] ?? {}), [key]: value } }));
|
|
141
|
+
setDirty(true);
|
|
142
|
+
} }, key)))] }))] })) : !banner && _jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" })] }), _jsxs("aside", { style: { display: "flex", flexDirection: "column", gap: 16, padding: "16px 14px", minHeight: 0, overflow: "auto", borderLeft: "var(--border)" }, children: [_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsxs(SectionLabel, { children: ["Status", multilingual ? ` · ${locale}` : ""] }), _jsx(Segmented, { options: STATUSES, stretch: true, disabled: !row, value: status === "published" ? "Published" : "Draft", onChange: (v) => { void setPublished(v === "Published" ? "published" : "draft"); } })] }), record && row && (_jsxs(_Fragment, { children: [_jsx(Divider, { bleed: 14 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Kind", value: item.section !== undefined ? `Visual · ${item.section}` : "Fields" }), _jsx(InfoRow, { label: "Updated", value: relativeTime(row.updatedAt) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] })] }))] })] })] }));
|
|
143
|
+
}
|
|
144
|
+
function InfoRow({ label, value, mono }) {
|
|
145
|
+
return (_jsxs("div", { style: { display: "flex", justifyContent: "space-between", gap: 10, fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: [_jsx("span", { children: label }), _jsx("span", { style: { color: "var(--text)", fontFamily: mono ? "var(--font-mono)" : undefined, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: value })] }));
|
|
146
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// The Shared content list (spec 2026-09-07 §6): every registered item is
|
|
4
|
+
// a row, edited on its own page. Nothing here is created or deleted by
|
|
5
|
+
// editors — the registry declares the items and ops.shared.list
|
|
6
|
+
// materializes them — so there is no "+ New".
|
|
7
|
+
import { useCallback, useEffect, useState } from "react";
|
|
8
|
+
import { ListView } from "./ListView.js";
|
|
9
|
+
import { sharedRows } from "./shared-list.js";
|
|
10
|
+
import { Segmented, StatusBadge } from "../ui/primitives.js";
|
|
11
|
+
import { relativeTime } from "../ui/format.js";
|
|
12
|
+
import { BASE } from "./base.js";
|
|
13
|
+
import { ErrorPane } from "./ErrorPane.js";
|
|
14
|
+
const LOCALE_KEY = "smoodly.entries.locale"; // the same choice the collection lists remember
|
|
15
|
+
function rememberedLocale(registry) {
|
|
16
|
+
try {
|
|
17
|
+
const stored = window.sessionStorage.getItem(LOCALE_KEY);
|
|
18
|
+
if (stored && registry.locales.supported.includes(stored))
|
|
19
|
+
return stored;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// storage unavailable — the default is fine
|
|
23
|
+
}
|
|
24
|
+
return registry.locales.default;
|
|
25
|
+
}
|
|
26
|
+
export function SharedList({ ops, registry }) {
|
|
27
|
+
const [items, setItems] = useState(null);
|
|
28
|
+
const [error, setError] = useState(null);
|
|
29
|
+
const [locale, setLocale] = useState(() => rememberedLocale(registry));
|
|
30
|
+
const multilingual = registry.locales.supported.length > 1;
|
|
31
|
+
const pickLocale = (next) => {
|
|
32
|
+
setLocale(next);
|
|
33
|
+
try {
|
|
34
|
+
window.sessionStorage.setItem(LOCALE_KEY, next);
|
|
35
|
+
}
|
|
36
|
+
catch { /* see rememberedLocale */ }
|
|
37
|
+
};
|
|
38
|
+
const load = useCallback(() => {
|
|
39
|
+
ops.shared.list()
|
|
40
|
+
.then((r) => (r.ok ? setItems(r.data) : setError(r.message)))
|
|
41
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
42
|
+
}, [ops]);
|
|
43
|
+
useEffect(load, [load]);
|
|
44
|
+
if (error)
|
|
45
|
+
return _jsx(ErrorPane, { message: error });
|
|
46
|
+
const rows = items ? sharedRows(items, registry, locale) : [];
|
|
47
|
+
return (_jsx(ListView, { title: "Shared content", count: items ? rows.length : undefined, actions: multilingual ? _jsx(Segmented, { options: registry.locales.supported, value: locale, onChange: pickLocale }) : undefined, columns: [
|
|
48
|
+
{ label: "Title", width: "minmax(160px,1fr)" },
|
|
49
|
+
{ label: "Kind", width: "90px" },
|
|
50
|
+
{ label: "Updated", width: "minmax(110px,160px)" },
|
|
51
|
+
{ label: "Status", width: "120px" },
|
|
52
|
+
], empty: items ? "No shared content is registered." : "Loading…", rows: rows.map((row) => ({
|
|
53
|
+
key: row.name,
|
|
54
|
+
href: `${BASE}/shared/${encodeURIComponent(row.name)}?locale=${encodeURIComponent(locale)}`,
|
|
55
|
+
cells: [
|
|
56
|
+
_jsx("span", { className: "sm-cell sm-cell--title", children: row.title }, "t"),
|
|
57
|
+
_jsx("span", { className: "sm-cell sm-cell--muted", children: row.flavour }, "k"),
|
|
58
|
+
_jsx("span", { className: "sm-cell sm-cell--muted", children: relativeTime(row.updatedAt) }, "u"),
|
|
59
|
+
_jsx(StatusBadge, { status: row.status }, "s"),
|
|
60
|
+
],
|
|
61
|
+
})) }));
|
|
62
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EntryRecord } from "../../entry-store.ts";
|
|
2
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
+
import { type LocaleStatus } from "./pages-tree.ts";
|
|
4
|
+
export type SharedRow = {
|
|
5
|
+
name: string;
|
|
6
|
+
title: string;
|
|
7
|
+
flavour: "Visual" | "Fields";
|
|
8
|
+
status: LocaleStatus;
|
|
9
|
+
updatedAt: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function sharedRows(items: {
|
|
12
|
+
name: string;
|
|
13
|
+
record: EntryRecord;
|
|
14
|
+
}[], registry: AdminRegistry, locale: string): SharedRow[];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { localeStatusOf } from "./pages-tree.js";
|
|
2
|
+
export function sharedRows(items, registry, locale) {
|
|
3
|
+
const byName = new Map(items.map((i) => [i.name, i.record]));
|
|
4
|
+
return registry.shared.flatMap((item) => {
|
|
5
|
+
const record = byName.get(item.name);
|
|
6
|
+
const row = record?.locales[locale];
|
|
7
|
+
if (!record || !row)
|
|
8
|
+
return [];
|
|
9
|
+
return [{
|
|
10
|
+
name: item.name,
|
|
11
|
+
title: item.title,
|
|
12
|
+
flavour: item.section !== undefined ? "Visual" : "Fields",
|
|
13
|
+
status: localeStatusOf(row),
|
|
14
|
+
updatedAt: row.updatedAt,
|
|
15
|
+
}];
|
|
16
|
+
});
|
|
17
|
+
}
|
package/dist/admin/tree-ops.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PageTree, TreeNode } from "../render.tsx";
|
|
2
2
|
import type { ZonePolicy } from "../zones.ts";
|
|
3
|
-
import type { AdminSection } from "./serialize.ts";
|
|
3
|
+
import type { AdminSection, AdminShared } from "./serialize.ts";
|
|
4
4
|
export declare function zoneOf(tree: PageTree, id: string): string | null;
|
|
5
5
|
export declare function findNode(tree: PageTree, id: string): TreeNode | null;
|
|
6
6
|
export declare function insertNode(tree: PageTree, zone: string, index: number, node: TreeNode): PageTree;
|
|
@@ -9,11 +9,18 @@ export declare function moveNode(tree: PageTree, id: string, dir: -1 | 1): PageT
|
|
|
9
9
|
export declare function patchNode(tree: PageTree, id: string, ns: "fields" | "styles" | "meta", key: string, value: unknown): PageTree;
|
|
10
10
|
export declare function canInsert(policy: ZonePolicy | undefined, count: number): boolean;
|
|
11
11
|
export declare function allowedSections(policy: ZonePolicy | undefined, all: AdminSection[]): AdminSection[];
|
|
12
|
+
/** The visual items the picker offers in a zone: materialized (an id is
|
|
13
|
+
* known) and rendering a section the zone's policy allows. */
|
|
14
|
+
export declare function allowedShared(policy: ZonePolicy | undefined, shared: AdminShared[], sharedIds: Record<string, string>): AdminShared[];
|
|
15
|
+
/** A page places a visual item as this node (spec 2026-09-07 §6): `item`
|
|
16
|
+
* names the registration, `ref` is the item's row id. */
|
|
17
|
+
export declare function placementNode(item: AdminShared, ref: string, id: string): TreeNode;
|
|
12
18
|
export declare function nodeFromSample(section: AdminSection, id: string): TreeNode;
|
|
13
19
|
/** A locked zone holds exactly one node the editor can never insert or
|
|
14
20
|
* remove — so someone has to put it there. This does, from the component's
|
|
15
|
-
* sample, for
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
21
|
+
* sample (or, for a zone bound to a shared item, a placement), for every
|
|
22
|
+
* locked zone that is empty or missing. Runs when the editor loads a draft,
|
|
23
|
+
* which also covers pages that predate a locked zone being added to their
|
|
24
|
+
* schema. Returns the same tree when nothing changed. */
|
|
25
|
+
export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[], sharedIds?: Record<string, string>): PageTree;
|
|
19
26
|
export declare function newNodeId(type: string): string;
|
package/dist/admin/tree-ops.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SHARED_NODE_TYPE } from "../shared.js";
|
|
1
2
|
export function zoneOf(tree, id) {
|
|
2
3
|
for (const [zone, nodes] of Object.entries(tree.zones)) {
|
|
3
4
|
if (nodes.some((n) => n.id === id))
|
|
@@ -60,6 +61,22 @@ export function allowedSections(policy, all) {
|
|
|
60
61
|
const allow = new Set(policy.allow);
|
|
61
62
|
return all.filter((s) => allow.has(s.name));
|
|
62
63
|
}
|
|
64
|
+
/** The visual items the picker offers in a zone: materialized (an id is
|
|
65
|
+
* known) and rendering a section the zone's policy allows. */
|
|
66
|
+
export function allowedShared(policy, shared, sharedIds) {
|
|
67
|
+
if (!policy || policy.kind === "locked")
|
|
68
|
+
return [];
|
|
69
|
+
return shared.filter((s) => {
|
|
70
|
+
if (s.section === undefined || sharedIds[s.name] === undefined)
|
|
71
|
+
return false;
|
|
72
|
+
return policy.allow === "*" || policy.allow.includes(s.section);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** A page places a visual item as this node (spec 2026-09-07 §6): `item`
|
|
76
|
+
* names the registration, `ref` is the item's row id. */
|
|
77
|
+
export function placementNode(item, ref, id) {
|
|
78
|
+
return { id, type: SHARED_NODE_TYPE, fields: { item: item.name, ref } };
|
|
79
|
+
}
|
|
63
80
|
/** The empty value a fresh node's field starts at, by descriptor type.
|
|
64
81
|
* Shape matters: a list-valued field seeded with "" is a type lie that
|
|
65
82
|
* crashes the first section view that maps over it. */
|
|
@@ -89,21 +106,35 @@ export function nodeFromSample(section, id) {
|
|
|
89
106
|
}
|
|
90
107
|
/** A locked zone holds exactly one node the editor can never insert or
|
|
91
108
|
* remove — so someone has to put it there. This does, from the component's
|
|
92
|
-
* sample, for
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
|
|
109
|
+
* sample (or, for a zone bound to a shared item, a placement), for every
|
|
110
|
+
* locked zone that is empty or missing. Runs when the editor loads a draft,
|
|
111
|
+
* which also covers pages that predate a locked zone being added to their
|
|
112
|
+
* schema. Returns the same tree when nothing changed. */
|
|
113
|
+
export function fillLockedZones(tree, policies, sections, sharedIds = {}) {
|
|
96
114
|
let next = null;
|
|
97
115
|
for (const [zone, policy] of Object.entries(policies)) {
|
|
98
116
|
if (policy.kind !== "locked")
|
|
99
117
|
continue;
|
|
100
118
|
if ((tree.zones[zone] ?? []).length > 0)
|
|
101
119
|
continue;
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
120
|
+
let node = null;
|
|
121
|
+
if (policy.shared !== undefined) {
|
|
122
|
+
// Bound to a shared item (spec 2026-09-07 §4): a placement, once
|
|
123
|
+
// the editor has loaded the item's row id; until then leave the
|
|
124
|
+
// zone empty rather than seed a plain section there.
|
|
125
|
+
const ref = sharedIds[policy.shared];
|
|
126
|
+
if (ref === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
node = placementNode({ name: policy.shared }, ref, newNodeId(SHARED_NODE_TYPE));
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
const section = sections.find((s) => s.name === policy.component);
|
|
132
|
+
if (!section)
|
|
133
|
+
continue;
|
|
134
|
+
node = nodeFromSample(section, newNodeId(section.name));
|
|
135
|
+
}
|
|
105
136
|
next ?? (next = { ...tree, zones: { ...tree.zones } });
|
|
106
|
-
next.zones[zone] = [
|
|
137
|
+
next.zones[zone] = [node];
|
|
107
138
|
}
|
|
108
139
|
return next ?? tree;
|
|
109
140
|
}
|
package/dist/collections.d.ts
CHANGED
|
@@ -9,7 +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> = {
|
|
12
|
-
|
|
12
|
+
/** "shared" marks a code-declared singleton (spec 2026-09-07 decision
|
|
13
|
+
* 4): a shared item IS a collection with one entry, so both entry
|
|
14
|
+
* stores accept it in the same list and branch on this. */
|
|
15
|
+
kind: "collection" | "shared";
|
|
13
16
|
name: string;
|
|
14
17
|
title: string;
|
|
15
18
|
titleField?: string;
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Descriptor, FieldBuilder } from "./fields.ts";
|
|
2
2
|
import type { CollectionSchema } from "./collections.ts";
|
|
3
|
+
import type { SharedSchema } from "./shared.ts";
|
|
3
4
|
type Registered = {
|
|
4
5
|
schema: {
|
|
5
6
|
kind: string;
|
|
@@ -19,6 +20,8 @@ export type SmoodlyConfig = {
|
|
|
19
20
|
collections?: CollectionSchema<any>[];
|
|
20
21
|
/** Page shapes and entry pages alike — everything that renders a URL. */
|
|
21
22
|
pages?: Registered[];
|
|
23
|
+
/** Shared content: code-declared singletons (spec 2026-09-07). */
|
|
24
|
+
shared?: SharedSchema[];
|
|
22
25
|
};
|
|
23
26
|
locales: {
|
|
24
27
|
default: string;
|
package/dist/config.js
CHANGED
|
@@ -1,17 +1,50 @@
|
|
|
1
1
|
import { assertSegment, fixedPageSegments, perLocaleSegments } from "./paths.js";
|
|
2
|
+
const RESERVED_NAME = "shared"; // the placement node type and the admin route
|
|
2
3
|
function assertUniqueNames(config) {
|
|
3
4
|
const seen = new Set();
|
|
5
|
+
const sectionNameList = (config.registry.sections ?? []).map((s) => s.schema.name);
|
|
6
|
+
const sectionNames = new Set(sectionNameList);
|
|
4
7
|
const all = [
|
|
5
|
-
...
|
|
8
|
+
...sectionNameList,
|
|
6
9
|
...(config.registry.elements ?? []).map((e) => e.schema.name),
|
|
7
10
|
...(config.registry.collections ?? []).map((c) => c.name),
|
|
8
11
|
...(config.registry.pages ?? []).filter((p) => p.schema.kind !== "entryPage").map((p) => p.schema.name),
|
|
9
12
|
];
|
|
10
13
|
for (const name of all) {
|
|
14
|
+
if (name === RESERVED_NAME)
|
|
15
|
+
throw new Error(`smoodly: "${RESERVED_NAME}" is a reserved name.`);
|
|
11
16
|
if (seen.has(name))
|
|
12
17
|
throw new Error(`smoodly: duplicate registered name "${name}".`);
|
|
13
18
|
seen.add(name);
|
|
14
19
|
}
|
|
20
|
+
// Shared items get their own pass, after every other kind is settled: a
|
|
21
|
+
// shared item's name must be unique among shared items, and must not
|
|
22
|
+
// collide with any collection/element/page/section name above — EXCEPT a
|
|
23
|
+
// visual item may reuse the name of the registered section it renders
|
|
24
|
+
// (spec 2026-09-07 §2's example pairs a "footer" section with a "footer"
|
|
25
|
+
// item). That exemption is narrow: it only excuses a match against the
|
|
26
|
+
// item's own, actually-registered section — never against a collection,
|
|
27
|
+
// element, or page that happens to share the string, and never against a
|
|
28
|
+
// second shared item of the same name.
|
|
29
|
+
const sharedSeen = new Set();
|
|
30
|
+
for (const item of config.registry.shared ?? []) {
|
|
31
|
+
const ownSection = item.name === item.section && sectionNames.has(item.name);
|
|
32
|
+
if (item.name === RESERVED_NAME)
|
|
33
|
+
throw new Error(`smoodly: "${RESERVED_NAME}" is a reserved name.`);
|
|
34
|
+
if (sharedSeen.has(item.name) || (seen.has(item.name) && !ownSection)) {
|
|
35
|
+
throw new Error(`smoodly: duplicate registered name "${item.name}".`);
|
|
36
|
+
}
|
|
37
|
+
sharedSeen.add(item.name);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** A visual item renders through a registered section. */
|
|
41
|
+
function assertSharedSections(config) {
|
|
42
|
+
const sections = new Set((config.registry.sections ?? []).map((s) => s.schema.name));
|
|
43
|
+
for (const item of config.registry.shared ?? []) {
|
|
44
|
+
if (item.section !== undefined && !sections.has(item.section)) {
|
|
45
|
+
throw new Error(`smoodly: shared item "${item.name}" renders section "${item.section}", which is not registered.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
15
48
|
}
|
|
16
49
|
/** Every root-level segment a registration claims, per locale: fixed page
|
|
17
50
|
* slugs and collection paths. Two claims on one (locale, segment) are a
|
|
@@ -91,6 +124,7 @@ function assertLocales(config) {
|
|
|
91
124
|
export function defineConfig(config) {
|
|
92
125
|
assertLocales(config);
|
|
93
126
|
assertUniqueNames(config);
|
|
127
|
+
assertSharedSections(config);
|
|
94
128
|
assertRootSegments(config);
|
|
95
129
|
const depth = config.pages?.tree?.depth ?? 1;
|
|
96
130
|
if (!Number.isInteger(depth) || depth < 1)
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CollectionSchema, EntryOrder } from "./collections.ts";
|
|
2
|
+
import type { SharedSchema } from "./shared.ts";
|
|
2
3
|
import { type RefEdge } from "./refs.ts";
|
|
3
4
|
import { MemoryRefIndex } from "./ref-index.ts";
|
|
4
5
|
import { type PathIndex } from "./path-index.ts";
|
|
@@ -61,6 +62,7 @@ export type AddEntryLocaleOptions = {
|
|
|
61
62
|
export type EntryStoreOptions = {
|
|
62
63
|
paths?: PathIndex;
|
|
63
64
|
locales?: LocaleSet;
|
|
65
|
+
shared?: SharedSchema[];
|
|
64
66
|
};
|
|
65
67
|
export interface EntryStore {
|
|
66
68
|
/** Creates the node and ONE locale row from the merged `fields`: shared
|
|
@@ -118,6 +120,8 @@ export interface EntryStore {
|
|
|
118
120
|
pathsOf(collection: string, id: string): Promise<Record<string, string>>;
|
|
119
121
|
}
|
|
120
122
|
export declare function schemaOf(collections: CollectionSchema<any>[], name: string): CollectionSchema<any>;
|
|
123
|
+
/** A shared item is a collection with exactly one entry (spec 2026-09-07). */
|
|
124
|
+
export declare const isSharedSchema: (schema: CollectionSchema<any>) => boolean;
|
|
121
125
|
export declare const DEFAULT_ENTRY_LOCALES: LocaleSet;
|
|
122
126
|
export declare const entryErrors: {
|
|
123
127
|
notFound: (collection: string, id: string) => Error;
|
|
@@ -135,6 +139,10 @@ export declare const entryErrors: {
|
|
|
135
139
|
lastLocale: (id: string) => Error;
|
|
136
140
|
localeInUse: (locale: string, titles: string[]) => Error;
|
|
137
141
|
moveLocales: (locale: string) => Error;
|
|
142
|
+
singleton: (name: string) => Error;
|
|
143
|
+
owned: (name: string, verb: string) => Error;
|
|
144
|
+
noSlug: (name: string) => Error;
|
|
145
|
+
everyLocale: (name: string) => Error;
|
|
138
146
|
};
|
|
139
147
|
/** The keys a locale row owns: every field with .localized(), except
|
|
140
148
|
* `slug`, which is the row's own column (a segment is an address, per
|
|
@@ -174,7 +182,6 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
|
|
|
174
182
|
*/
|
|
175
183
|
export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
|
|
176
184
|
export declare class MemoryEntryStore implements EntryStore {
|
|
177
|
-
private collections;
|
|
178
185
|
private refs;
|
|
179
186
|
private entries;
|
|
180
187
|
private versions;
|
|
@@ -184,6 +191,7 @@ export declare class MemoryEntryStore implements EntryStore {
|
|
|
184
191
|
private order;
|
|
185
192
|
private paths;
|
|
186
193
|
private locales;
|
|
194
|
+
private schemas;
|
|
187
195
|
constructor(collections: CollectionSchema<any>[], refs?: MemoryRefIndex, options?: EntryStoreOptions);
|
|
188
196
|
private schema;
|
|
189
197
|
private row;
|
package/dist/entry-store.js
CHANGED
|
@@ -20,6 +20,8 @@ export function schemaOf(collections, name) {
|
|
|
20
20
|
throw new Error(`smoodly: no collection named "${name}".`);
|
|
21
21
|
return schema;
|
|
22
22
|
}
|
|
23
|
+
/** A shared item is a collection with exactly one entry (spec 2026-09-07). */
|
|
24
|
+
export const isSharedSchema = (schema) => schema.kind === "shared";
|
|
23
25
|
let counter = 0;
|
|
24
26
|
const uid = (prefix) => `${prefix}-${++counter}-${Date.now().toString(36)}`;
|
|
25
27
|
export const DEFAULT_ENTRY_LOCALES = { default: "en", supported: ["en"] };
|
|
@@ -39,6 +41,11 @@ export const entryErrors = {
|
|
|
39
41
|
lastLocale: (id) => new Error(`smoodly: entry "${id}" must exist in at least one locale — delete the entry instead.`),
|
|
40
42
|
localeInUse: (locale, titles) => new Error(`smoodly: child entries still exist in locale "${locale}" — remove it from ${titles.map((t) => `"${t}"`).join(", ")} first.`),
|
|
41
43
|
moveLocales: (locale) => new Error(`smoodly: the destination parent does not exist in locale "${locale}" — the entry cannot move under it.`),
|
|
44
|
+
// Shared items (spec 2026-09-07 §3): one row, no URL, every locale, code-owned.
|
|
45
|
+
singleton: (name) => new Error(`smoodly: shared item "${name}" already exists — it is a singleton.`),
|
|
46
|
+
owned: (name, verb) => new Error(`smoodly: shared item "${name}" is owned by code — ${verb}.`),
|
|
47
|
+
noSlug: (name) => new Error(`smoodly: shared item "${name}" has no URL — it cannot take a slug.`),
|
|
48
|
+
everyLocale: (name) => new Error(`smoodly: shared item "${name}" exists in every locale — it cannot be removed from one.`),
|
|
42
49
|
};
|
|
43
50
|
/** The keys a locale row owns: every field with .localized(), except
|
|
44
51
|
* `slug`, which is the row's own column (a segment is an address, per
|
|
@@ -128,7 +135,6 @@ export class MemoryEntryStore {
|
|
|
128
135
|
constructor(collections,
|
|
129
136
|
// pass the SAME index to MemoryPageStore so cross-kind questions work
|
|
130
137
|
refs = new MemoryRefIndex(), options = {}) {
|
|
131
|
-
this.collections = collections;
|
|
132
138
|
this.refs = refs;
|
|
133
139
|
this.entries = new Map();
|
|
134
140
|
this.versions = new Map();
|
|
@@ -139,9 +145,10 @@ export class MemoryEntryStore {
|
|
|
139
145
|
this.byId = (id) => this.entries.get(id);
|
|
140
146
|
this.paths = options.paths ?? new MemoryPathIndex();
|
|
141
147
|
this.locales = options.locales ?? DEFAULT_ENTRY_LOCALES;
|
|
148
|
+
this.schemas = [...collections, ...(options.shared ?? [])];
|
|
142
149
|
}
|
|
143
150
|
schema(collection) {
|
|
144
|
-
return schemaOf(this.
|
|
151
|
+
return schemaOf(this.schemas, collection);
|
|
145
152
|
}
|
|
146
153
|
row(collection, id) {
|
|
147
154
|
const entry = this.entries.get(id);
|
|
@@ -253,6 +260,12 @@ export class MemoryEntryStore {
|
|
|
253
260
|
const schema = this.schema(collection);
|
|
254
261
|
assertSupportedLocale(options.locale, this.locales);
|
|
255
262
|
const parentId = options.parentId ?? null;
|
|
263
|
+
if (isSharedSchema(schema)) {
|
|
264
|
+
if ([...this.entries.values()].some((e) => e.collection === collection))
|
|
265
|
+
throw entryErrors.singleton(collection);
|
|
266
|
+
if (slugOf(fields) !== null)
|
|
267
|
+
throw entryErrors.noSlug(collection);
|
|
268
|
+
}
|
|
256
269
|
const parent = this.assertParent(collection, parentId, 1);
|
|
257
270
|
this.assertParentHasLocale(parent, options.locale);
|
|
258
271
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
@@ -322,6 +335,8 @@ export class MemoryEntryStore {
|
|
|
322
335
|
const entry = this.must(collection, id);
|
|
323
336
|
const row = entryLocaleRow(entry, locale);
|
|
324
337
|
const { shared, localized, slug } = splitFields(fields, schema);
|
|
338
|
+
if (isSharedSchema(schema) && slug !== null)
|
|
339
|
+
throw entryErrors.noSlug(collection);
|
|
325
340
|
this.assertSiblingSlug(collection, entry.parentId, locale, slug, id);
|
|
326
341
|
const before = { fields: entry.fields, row: { ...row } };
|
|
327
342
|
entry.fields = shared;
|
|
@@ -387,6 +402,8 @@ export class MemoryEntryStore {
|
|
|
387
402
|
}
|
|
388
403
|
async removeLocale(collection, id, locale) {
|
|
389
404
|
const schema = this.schema(collection);
|
|
405
|
+
if (isSharedSchema(schema))
|
|
406
|
+
throw entryErrors.everyLocale(collection);
|
|
390
407
|
const entry = this.must(collection, id);
|
|
391
408
|
const row = entryLocaleRow(entry, locale);
|
|
392
409
|
if (Object.keys(entry.locales).length === 1)
|
|
@@ -417,6 +434,8 @@ export class MemoryEntryStore {
|
|
|
417
434
|
.map((v) => structuredClone(v));
|
|
418
435
|
}
|
|
419
436
|
async move(collection, id, to) {
|
|
437
|
+
if (isSharedSchema(this.schema(collection)))
|
|
438
|
+
throw entryErrors.owned(collection, "it has no tree position");
|
|
420
439
|
const entry = this.must(collection, id);
|
|
421
440
|
const subtree = this.subtree(entry);
|
|
422
441
|
if (to.parentId !== null && (to.parentId === id || subtree.some((e) => e.id === to.parentId)))
|
|
@@ -454,6 +473,8 @@ export class MemoryEntryStore {
|
|
|
454
473
|
const entry = this.row(collection, id);
|
|
455
474
|
if (!entry)
|
|
456
475
|
return;
|
|
476
|
+
if (isSharedSchema(this.schema(collection)))
|
|
477
|
+
throw entryErrors.owned(collection, "remove the registration to remove it");
|
|
457
478
|
if ([...this.entries.values()].some((e) => e.parentId === id))
|
|
458
479
|
throw entryErrors.hasChildren(collection, id);
|
|
459
480
|
const usage = await this.referencesTo(collection, id);
|
|
@@ -467,7 +488,8 @@ export class MemoryEntryStore {
|
|
|
467
488
|
this.refs.remove({ sourceKind: "entry", sourceId: id });
|
|
468
489
|
}
|
|
469
490
|
async reorder(collection, orderedIds) {
|
|
470
|
-
this.schema(collection)
|
|
491
|
+
if (isSharedSchema(this.schema(collection)))
|
|
492
|
+
throw entryErrors.owned(collection, "it has no order");
|
|
471
493
|
orderedIds.forEach((id, i) => {
|
|
472
494
|
const entry = this.row(collection, id);
|
|
473
495
|
if (entry)
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { collection, toolset } from "./collections.ts";
|
|
|
3
3
|
import { Section } from "./section-wrapper.tsx";
|
|
4
4
|
import { page } from "./render.tsx";
|
|
5
5
|
import { entryPage } from "./entry-page.tsx";
|
|
6
|
+
import { shared } from "./shared.ts";
|
|
6
7
|
export { Zone, renderPage, EMPTY_PAGE_CONTEXT } from "./render.tsx";
|
|
7
8
|
export type { PageTree, TreeNode, ZoneContent, PairedPage, Registry, PageContext, PageContextLink, PageLink, PageViewProps } from "./render.tsx";
|
|
8
9
|
export type { PageSchema } from "./page.ts";
|
|
@@ -24,7 +25,7 @@ export { SupabaseEntryStore } from "./supabase-entry-store.ts";
|
|
|
24
25
|
export { SupabasePathIndex } from "./supabase-path-index.ts";
|
|
25
26
|
export type { EntryStore, EntryRecord, EntryLocale, EntryVersion, EntryUsage, ListOptions, CreateEntryOptions, AddEntryLocaleOptions, EntryStoreOptions, } from "./entry-store.ts";
|
|
26
27
|
export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, } from "./entry-store.ts";
|
|
27
|
-
export { affectedTargets, pageTag, entryTag } from "./revalidate.ts";
|
|
28
|
+
export { affectedTargets, pageTag, entryTag, sharedTag } from "./revalidate.ts";
|
|
28
29
|
export type { AffectedTargets } from "./revalidate.ts";
|
|
29
30
|
export { resolveTree, resolveFields } from "./resolve.ts";
|
|
30
31
|
export type { EntryFetcher, ResolveFieldsOptions } from "./resolve.ts";
|
|
@@ -40,6 +41,8 @@ export type { Props, PairedComponent } from "./pairing.tsx";
|
|
|
40
41
|
export type { CollectionSchema, Toolset, EntryOrder } from "./collections.ts";
|
|
41
42
|
export { collectionOrder, collectionDepth, DEFAULT_ORDER } from "./collections.ts";
|
|
42
43
|
export type { ZonePolicy } from "./zones.ts";
|
|
44
|
+
export { SHARED_NODE_TYPE, STYLES_KEY, sharedLink } from "./shared.ts";
|
|
45
|
+
export type { SharedSchema, SharedLink } from "./shared.ts";
|
|
43
46
|
export declare const smoodly: {
|
|
44
47
|
schema: {
|
|
45
48
|
page: typeof import("./page.ts").pageSchema;
|
|
@@ -110,6 +113,7 @@ export declare const smoodly: {
|
|
|
110
113
|
entryPage: typeof entryPage;
|
|
111
114
|
collection: typeof collection;
|
|
112
115
|
toolset: typeof toolset;
|
|
116
|
+
shared: typeof shared;
|
|
113
117
|
Section: typeof Section;
|
|
114
118
|
};
|
|
115
119
|
export { resolveSmoodlyEnv, smoodlyEnv } from "./env.ts";
|