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.
Files changed (47) hide show
  1. package/README.md +88 -2
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/editor/EditorView.js +64 -8
  4. package/dist/admin/index.d.ts +3 -2
  5. package/dist/admin/index.js +1 -0
  6. package/dist/admin/ops-impl.js +84 -1
  7. package/dist/admin/ops.d.ts +22 -1
  8. package/dist/admin/serialize.d.ts +11 -0
  9. package/dist/admin/serialize.js +8 -0
  10. package/dist/admin/shared-items.d.ts +9 -0
  11. package/dist/admin/shared-items.js +61 -0
  12. package/dist/admin/shell/AdminApp.js +8 -2
  13. package/dist/admin/shell/SharedForm.d.ts +7 -0
  14. package/dist/admin/shell/SharedForm.js +146 -0
  15. package/dist/admin/shell/SharedList.d.ts +6 -0
  16. package/dist/admin/shell/SharedList.js +62 -0
  17. package/dist/admin/shell/shared-list.d.ts +14 -0
  18. package/dist/admin/shell/shared-list.js +17 -0
  19. package/dist/admin/tree-ops.d.ts +12 -5
  20. package/dist/admin/tree-ops.js +39 -8
  21. package/dist/collections.d.ts +4 -1
  22. package/dist/config.d.ts +3 -0
  23. package/dist/config.js +35 -1
  24. package/dist/entry-store.d.ts +9 -1
  25. package/dist/entry-store.js +25 -3
  26. package/dist/index.d.ts +5 -1
  27. package/dist/index.js +4 -1
  28. package/dist/next/page-renderer.d.ts +4 -0
  29. package/dist/next/page-renderer.js +14 -1
  30. package/dist/page.js +5 -1
  31. package/dist/refs.js +8 -0
  32. package/dist/resolve.d.ts +3 -0
  33. package/dist/resolve.js +38 -0
  34. package/dist/revalidate.d.ts +4 -0
  35. package/dist/revalidate.js +4 -0
  36. package/dist/shared.d.ts +46 -0
  37. package/dist/shared.js +36 -0
  38. package/dist/site.d.ts +10 -0
  39. package/dist/site.js +28 -1
  40. package/dist/sql-space.d.ts +1 -1
  41. package/dist/sql-space.js +4 -9
  42. package/dist/sql.js +0 -18
  43. package/dist/supabase-entry-store.d.ts +1 -1
  44. package/dist/supabase-entry-store.js +23 -5
  45. package/dist/zones.d.ts +6 -2
  46. package/dist/zones.js +8 -2
  47. package/package.json +1 -1
@@ -0,0 +1,7 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import type { AdminRegistry } from "../serialize.ts";
3
+ export declare function SharedForm({ ops, registry, name }: {
4
+ ops: AdminOps;
5
+ registry: AdminRegistry;
6
+ name: string;
7
+ }): import("react").JSX.Element;
@@ -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,6 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import type { AdminRegistry } from "../serialize.ts";
3
+ export declare function SharedList({ ops, registry }: {
4
+ ops: AdminOps;
5
+ registry: AdminRegistry;
6
+ }): import("react").JSX.Element;
@@ -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
+ }
@@ -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 every locked zone that is empty or missing. Runs when the
16
- * editor loads a draft, which also covers pages that predate a locked zone
17
- * being added to their schema. Returns the same tree when nothing changed. */
18
- export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[]): PageTree;
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;
@@ -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 every locked zone that is empty or missing. Runs when the
93
- * editor loads a draft, which also covers pages that predate a locked zone
94
- * being added to their schema. Returns the same tree when nothing changed. */
95
- export function fillLockedZones(tree, policies, sections) {
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
- const section = sections.find((s) => s.name === policy.component);
103
- if (!section)
104
- continue;
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] = [nodeFromSample(section, newNodeId(section.name))];
137
+ next.zones[zone] = [node];
107
138
  }
108
139
  return next ?? tree;
109
140
  }
@@ -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
- kind: "collection";
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
- ...(config.registry.sections ?? []).map((s) => s.schema.name),
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)
@@ -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;
@@ -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.collections, collection);
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";