smoodly 0.0.16 → 0.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -21
- package/dist/admin/client-ops.js +1 -1
- package/dist/admin/custom-section.d.ts +10 -0
- package/dist/admin/custom-section.js +10 -0
- package/dist/admin/editor/CanvasOverlay.d.ts +4 -4
- package/dist/admin/editor/CanvasOverlay.js +5 -5
- package/dist/admin/editor/EditorView.js +140 -108
- package/dist/admin/editor/GlobalInspector.d.ts +24 -0
- package/dist/admin/editor/{SharedInspector.js → GlobalInspector.js} +4 -4
- package/dist/admin/editor/InsertPicker.d.ts +6 -5
- package/dist/admin/editor/InsertPicker.js +6 -4
- package/dist/admin/editor/InspectorFields.d.ts +21 -0
- package/dist/admin/editor/InspectorFields.js +22 -0
- package/dist/admin/editor/Outline.d.ts +0 -1
- package/dist/admin/editor/Outline.js +3 -3
- package/dist/admin/editor/{shared-edit.d.ts → global-edit.d.ts} +10 -10
- package/dist/admin/editor/{shared-edit.js → global-edit.js} +4 -4
- package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
- package/dist/admin/editor/{useSharedEdit.js → useGlobalEdit.js} +4 -4
- package/dist/admin/forms/FieldWidget.d.ts +14 -1
- package/dist/admin/forms/FieldWidget.js +38 -11
- package/dist/admin/forms/ListField.d.ts +9 -1
- package/dist/admin/forms/ListField.js +16 -8
- package/dist/admin/forms/RichtextElementCard.js +11 -5
- package/dist/admin/forms/RowsWidget.d.ts +13 -0
- package/dist/admin/forms/RowsWidget.js +26 -0
- package/dist/admin/forms/SpanBar.d.ts +5 -0
- package/dist/admin/forms/SpanBar.js +37 -0
- package/dist/admin/forms/richtext-insert.d.ts +2 -2
- package/dist/admin/forms/richtext-insert.js +3 -3
- package/dist/admin/forms/richtext-nodes.js +1 -1
- package/dist/admin/forms/tabs.d.ts +15 -9
- package/dist/admin/forms/tabs.js +19 -23
- package/dist/admin/forms/useRichtextEditor.js +8 -1
- package/dist/admin/global-items.d.ts +9 -0
- package/dist/admin/{shared-items.js → global-items.js} +17 -16
- package/dist/admin/index.d.ts +3 -3
- package/dist/admin/index.js +1 -1
- package/dist/admin/ops-impl.js +65 -54
- package/dist/admin/ops.d.ts +6 -6
- package/dist/admin/serialize.d.ts +18 -6
- package/dist/admin/serialize.js +27 -4
- package/dist/admin/shell/AdminApp.js +6 -6
- package/dist/admin/shell/GlobalFields.d.ts +24 -0
- package/dist/admin/shell/GlobalFields.js +19 -0
- package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
- package/dist/admin/shell/{SharedForm.js → GlobalForm.js} +36 -26
- package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
- package/dist/admin/shell/{SharedList.js → GlobalList.js} +9 -9
- package/dist/admin/shell/IconRail.js +13 -6
- package/dist/admin/shell/PagesList.js +1 -6
- package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
- package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
- package/dist/admin/tree-ops.d.ts +10 -8
- package/dist/admin/tree-ops.js +28 -16
- package/dist/admin/ui/HistoryModal.js +1 -1
- package/dist/admin/ui/primitives.d.ts +3 -1
- package/dist/admin/ui/primitives.js +2 -2
- package/dist/admin/ui/theme.js +54 -14
- package/dist/admin/validate.d.ts +27 -2
- package/dist/admin/validate.js +47 -10
- package/dist/collections.d.ts +3 -3
- package/dist/collections.js +1 -1
- package/dist/conditions.d.ts +33 -0
- package/dist/conditions.js +79 -0
- package/dist/config.d.ts +26 -19
- package/dist/config.js +26 -25
- package/dist/css.d.ts +7 -0
- package/dist/css.js +57 -0
- package/dist/custom/CustomSection.d.ts +26 -0
- package/dist/custom/CustomSection.js +20 -0
- package/dist/custom/css.d.ts +3 -0
- package/dist/custom/css.js +28 -0
- package/dist/custom/index.d.ts +26 -0
- package/dist/custom/index.js +16 -0
- package/dist/custom/rows.d.ts +28 -0
- package/dist/custom/rows.js +129 -0
- package/dist/custom/schema.d.ts +32 -0
- package/dist/custom/schema.js +39 -0
- package/dist/entry-store.d.ts +4 -4
- package/dist/entry-store.js +17 -17
- package/dist/fields.d.ts +5 -2
- package/dist/fields.js +4 -2
- package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
- package/dist/{shared-id.js → global-id.js} +4 -4
- package/dist/global.d.ts +57 -0
- package/dist/global.js +51 -0
- package/dist/index.d.ts +28 -18
- package/dist/index.js +10 -5
- package/dist/media.js +1 -1
- package/dist/next/index.d.ts +1 -1
- package/dist/next/page-renderer.d.ts +4 -2
- package/dist/next/page-renderer.js +9 -9
- package/dist/next/queries.d.ts +6 -6
- package/dist/next/queries.js +7 -7
- package/dist/page.js +2 -2
- package/dist/pairing.d.ts +13 -7
- package/dist/pairing.js +4 -4
- package/dist/refs.d.ts +2 -2
- package/dist/refs.js +4 -4
- package/dist/render.d.ts +13 -1
- package/dist/render.js +10 -5
- package/dist/resolve.d.ts +11 -6
- package/dist/resolve.js +22 -12
- package/dist/revalidate.d.ts +4 -4
- package/dist/revalidate.js +4 -4
- package/dist/richtext-render.js +2 -2
- package/dist/richtext-walk.d.ts +5 -5
- package/dist/richtext-walk.js +4 -4
- package/dist/schema.d.ts +15 -10
- package/dist/schema.js +19 -7
- package/dist/section-wrapper.d.ts +18 -6
- package/dist/section-wrapper.js +24 -17
- package/dist/site.d.ts +15 -5
- package/dist/site.js +24 -10
- package/dist/sql-space.js +1 -1
- package/dist/sql.js +2 -2
- package/dist/store.d.ts +7 -0
- package/dist/store.js +6 -0
- package/dist/styles.d.ts +27 -0
- package/dist/styles.js +60 -0
- package/dist/supabase-entry-store.js +14 -14
- package/dist/supabase-store.js +2 -1
- package/dist/zones.d.ts +9 -7
- package/dist/zones.js +5 -5
- package/package.json +1 -1
- package/dist/admin/editor/SharedInspector.d.ts +0 -23
- package/dist/admin/editor/useSharedEdit.d.ts +0 -6
- package/dist/admin/shared-items.d.ts +0 -9
- package/dist/admin/shell/SharedFields.d.ts +0 -21
- package/dist/admin/shell/SharedFields.js +0 -15
- package/dist/shared.d.ts +0 -53
- package/dist/shared.js +0 -45
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import type { AdminField } from "../serialize.ts";
|
|
2
|
-
export type TabName = "Content" | "
|
|
2
|
+
export type TabName = "Content" | "Settings";
|
|
3
|
+
export type GroupTitle = "Spacing & layout" | "Advanced";
|
|
3
4
|
export type TabEntry = {
|
|
4
5
|
key: string;
|
|
5
|
-
ns: "fields" | "styles" | "
|
|
6
|
+
ns: "fields" | "settings" | "styles" | "css";
|
|
6
7
|
descriptor: AdminField;
|
|
7
8
|
};
|
|
8
|
-
|
|
9
|
+
export type Group = {
|
|
10
|
+
title?: GroupTitle;
|
|
11
|
+
entries: TabEntry[];
|
|
12
|
+
};
|
|
13
|
+
/** A section, an element (no styles, no css) or a global item. */
|
|
9
14
|
export type FieldsHolder = {
|
|
10
15
|
fields: Record<string, AdminField>;
|
|
16
|
+
settings?: Record<string, AdminField>;
|
|
11
17
|
styles?: Record<string, AdminField>;
|
|
12
|
-
|
|
18
|
+
css?: boolean;
|
|
13
19
|
};
|
|
14
|
-
export declare
|
|
20
|
+
export declare const CSS_DESCRIPTOR: AdminField;
|
|
21
|
+
export declare function tabsFor(holder: FieldsHolder): {
|
|
15
22
|
tab: TabName;
|
|
16
|
-
|
|
23
|
+
groups: Group[];
|
|
17
24
|
}[];
|
|
18
|
-
/** The tab
|
|
19
|
-
|
|
20
|
-
export declare function tabOf(section: FieldsHolder, path: string): TabName | undefined;
|
|
25
|
+
/** The tab a dotted path belongs to, by its top-level key — a canvas click reveals it there. */
|
|
26
|
+
export declare function tabOf(holder: FieldsHolder, path: string): TabName | undefined;
|
package/dist/admin/forms/tabs.js
CHANGED
|
@@ -1,26 +1,22 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
};
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const tab = override ?? (MEDIA_TYPES.has(descriptor.type) ? "Media" : TAB_BY_NS[ns]);
|
|
14
|
-
buckets[tab].push({ key, ns, descriptor });
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
return ["Content", "Styles", "Media", "Meta"]
|
|
18
|
-
.filter((tab) => buckets[tab].length > 0)
|
|
19
|
-
.map((tab) => ({ tab, entries: buckets[tab] }));
|
|
1
|
+
export const CSS_DESCRIPTOR = { type: "css" };
|
|
2
|
+
const entries = (ns, map) => Object.entries(map ?? {}).map(([key, descriptor]) => ({ key, ns, descriptor }));
|
|
3
|
+
export function tabsFor(holder) {
|
|
4
|
+
const content = [{ entries: entries("fields", holder.fields) }];
|
|
5
|
+
const settings = [
|
|
6
|
+
{ entries: entries("settings", holder.settings) },
|
|
7
|
+
{ title: "Spacing & layout", entries: entries("styles", holder.styles) },
|
|
8
|
+
{ title: "Advanced", entries: holder.css === true ? [{ key: "css", ns: "css", descriptor: CSS_DESCRIPTOR }] : [] },
|
|
9
|
+
];
|
|
10
|
+
return [["Content", content], ["Settings", settings]]
|
|
11
|
+
.map(([tab, groups]) => ({ tab, groups: groups.filter((g) => g.entries.length > 0) }))
|
|
12
|
+
.filter((t) => t.groups.length > 0);
|
|
20
13
|
}
|
|
21
|
-
/** The tab
|
|
22
|
-
|
|
23
|
-
export function tabOf(section, path) {
|
|
14
|
+
/** The tab a dotted path belongs to, by its top-level key — a canvas click reveals it there. */
|
|
15
|
+
export function tabOf(holder, path) {
|
|
24
16
|
const key = path.split(".")[0];
|
|
25
|
-
|
|
17
|
+
if (key in holder.fields)
|
|
18
|
+
return "Content";
|
|
19
|
+
if (key in (holder.settings ?? {}) || key in (holder.styles ?? {}) || (key === "css" && holder.css === true))
|
|
20
|
+
return "Settings";
|
|
21
|
+
return undefined;
|
|
26
22
|
}
|
|
@@ -135,7 +135,14 @@ export function useRichtextEditor({ value, toolset, onChange, contentClass, onEs
|
|
|
135
135
|
if (!editor || value === emitted.current)
|
|
136
136
|
return;
|
|
137
137
|
emitted.current = value;
|
|
138
|
-
|
|
138
|
+
// TipTap's React node views (the image and element cards) flush React synchronously inside
|
|
139
|
+
// setContent, which React forbids from a lifecycle effect ("flushSync was called from inside a
|
|
140
|
+
// lifecycle method"). One microtask later React is idle; a widget unmounted meanwhile is skipped.
|
|
141
|
+
const next = isDoc(value) ? value : EMPTY;
|
|
142
|
+
queueMicrotask(() => {
|
|
143
|
+
if (!editor.isDestroyed)
|
|
144
|
+
editor.commands.setContent(next, { emitUpdate: false });
|
|
145
|
+
});
|
|
139
146
|
}, [editor, value]);
|
|
140
147
|
const active = useEditorState({
|
|
141
148
|
editor,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
+
import type { Descriptor } from "../fields.ts";
|
|
3
|
+
import type { EntryRecord, EntryStore } from "../entry-store.ts";
|
|
4
|
+
import { type GlobalSchema } from "../global.ts";
|
|
5
|
+
/** The merged fields a fresh item starts from: sampled field values, and
|
|
6
|
+
* for a visual item `$settings` = the section's settings defaults with the
|
|
7
|
+
* sample's settings values on top. Unsampled fields stay absent. */
|
|
8
|
+
export declare function initialGlobalFields(item: GlobalSchema, settingsDescriptors: Record<string, Descriptor>): Record<string, unknown>;
|
|
9
|
+
export declare function ensureGlobalItems(entries: EntryStore, config: ResolvedConfig): Promise<Record<string, EntryRecord>>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SETTINGS_KEY } from "../global.js";
|
|
2
|
+
import { sectionSettings } from "./serialize.js";
|
|
2
3
|
/** The merged fields a fresh item starts from: sampled field values, and
|
|
3
|
-
* for a visual item `$
|
|
4
|
-
* sample's
|
|
5
|
-
export function
|
|
4
|
+
* for a visual item `$settings` = the section's settings defaults with the
|
|
5
|
+
* sample's settings values on top. Unsampled fields stay absent. */
|
|
6
|
+
export function initialGlobalFields(item, settingsDescriptors) {
|
|
6
7
|
const out = {};
|
|
7
8
|
for (const key of Object.keys(item.fields)) {
|
|
8
9
|
const sampled = item.sample?.[key];
|
|
@@ -10,25 +11,25 @@ export function initialSharedFields(item, resolvedStyles) {
|
|
|
10
11
|
out[key] = sampled;
|
|
11
12
|
}
|
|
12
13
|
if (item.section !== undefined) {
|
|
13
|
-
const
|
|
14
|
-
for (const [key, d] of Object.entries(
|
|
14
|
+
const settings = {};
|
|
15
|
+
for (const [key, d] of Object.entries(settingsDescriptors)) {
|
|
15
16
|
const value = item.sample?.[key] ?? d.default;
|
|
16
17
|
if (value !== undefined)
|
|
17
|
-
|
|
18
|
+
settings[key] = value;
|
|
18
19
|
}
|
|
19
|
-
out[
|
|
20
|
+
out[SETTINGS_KEY] = settings;
|
|
20
21
|
}
|
|
21
22
|
return out;
|
|
22
23
|
}
|
|
23
|
-
export async function
|
|
24
|
+
export async function ensureGlobalItems(entries, config) {
|
|
24
25
|
const { default: def, supported } = config.locales;
|
|
25
26
|
const out = {};
|
|
26
|
-
for (const item of config.registry.
|
|
27
|
+
for (const item of config.registry.globals ?? []) {
|
|
27
28
|
let [record] = await entries.list(item.name);
|
|
28
29
|
if (!record) {
|
|
29
30
|
try {
|
|
30
|
-
const
|
|
31
|
-
record = await entries.create(item.name,
|
|
31
|
+
const settings = item.section !== undefined ? sectionSettings(config, item.section) : {};
|
|
32
|
+
record = await entries.create(item.name, initialGlobalFields(item, settings), { locale: def });
|
|
32
33
|
}
|
|
33
34
|
catch (e) {
|
|
34
35
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -37,7 +38,7 @@ export async function ensureSharedItems(entries, config) {
|
|
|
37
38
|
if (message.includes("it is a singleton"))
|
|
38
39
|
[record] = await entries.list(item.name);
|
|
39
40
|
else
|
|
40
|
-
console.warn(`smoodly: could not materialize
|
|
41
|
+
console.warn(`smoodly: could not materialize global item "${item.name}": ${message}`);
|
|
41
42
|
if (!record)
|
|
42
43
|
continue;
|
|
43
44
|
}
|
|
@@ -51,11 +52,11 @@ export async function ensureSharedItems(entries, config) {
|
|
|
51
52
|
catch (e) {
|
|
52
53
|
const message = e instanceof Error ? e.message : String(e);
|
|
53
54
|
if (!message.includes("already exists in locale")) {
|
|
54
|
-
console.warn(`smoodly: could not materialize
|
|
55
|
+
console.warn(`smoodly: could not materialize global item "${item.name}" in "${locale}": ${message}`);
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
|
-
// Every
|
|
59
|
+
// Every global item is versioned (decided 2026-09-13). A row published before that has
|
|
59
60
|
// no pointers, so its next save would go live: publish it once more here, which snapshots
|
|
60
61
|
// what is live and points both pointers at it. Idempotent — pointed rows are skipped.
|
|
61
62
|
for (const locale of supported) {
|
|
@@ -66,7 +67,7 @@ export async function ensureSharedItems(entries, config) {
|
|
|
66
67
|
record = await entries.setStatus(item.name, record.id, locale, "published");
|
|
67
68
|
}
|
|
68
69
|
catch (e) {
|
|
69
|
-
console.warn(`smoodly: could not version
|
|
70
|
+
console.warn(`smoodly: could not version global item "${item.name}" in "${locale}": ${e instanceof Error ? e.message : String(e)}`);
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
out[item.name] = record;
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult,
|
|
1
|
+
export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, GlobalListItem, GlobalGetResult, AssetUsageItem } from "./ops.ts";
|
|
2
2
|
export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
|
|
3
3
|
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
|
|
4
|
-
export {
|
|
4
|
+
export { ensureGlobalItems, initialGlobalFields } from "./global-items.ts";
|
|
5
5
|
export { makeClientOps } from "./client-ops.ts";
|
|
6
6
|
export { gateAdminOp, type AdminAuth } from "./auth.ts";
|
|
7
7
|
export { supabaseAdminAuth } from "./supabase-auth.ts";
|
|
8
8
|
export { serializeAdminConfig } from "./serialize.ts";
|
|
9
|
-
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate,
|
|
9
|
+
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
|
|
10
10
|
export * from "./tree-ops.ts";
|
|
11
11
|
export { validateFields, type FieldError } from "./validate.ts";
|
package/dist/admin/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createAdminOps, runAdminOp } from "./ops-impl.js";
|
|
2
2
|
export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes } from "./fixed-nodes.js";
|
|
3
|
-
export {
|
|
3
|
+
export { ensureGlobalItems, initialGlobalFields } from "./global-items.js";
|
|
4
4
|
export { makeClientOps } from "./client-ops.js";
|
|
5
5
|
export { gateAdminOp } from "./auth.js";
|
|
6
6
|
export { supabaseAdminAuth } from "./supabase-auth.js";
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
|
|
2
2
|
import { ASSET_COLLECTION } from "../media.js";
|
|
3
3
|
import { BASE } from "./shell/base.js";
|
|
4
|
-
import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag,
|
|
4
|
+
import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
|
|
5
5
|
import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
|
|
6
|
-
import {
|
|
7
|
-
import { validateFields } from "./validate.js";
|
|
6
|
+
import { ensureGlobalItems } from "./global-items.js";
|
|
7
|
+
import { validateCustomNode, validateFields, validateNode } from "./validate.js";
|
|
8
|
+
import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
|
|
9
|
+
import { SETTINGS_KEY, CSS_KEY } from "../global.js";
|
|
8
10
|
import { withDefaults } from "../field-defaults.js";
|
|
9
11
|
import { elementSchemas } from "../richtext-walk.js";
|
|
10
12
|
const ok = (data) => ({ ok: true, data });
|
|
@@ -96,11 +98,12 @@ export function createAdminOps(deps) {
|
|
|
96
98
|
const by = deps.actor?.email ?? undefined;
|
|
97
99
|
const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
|
|
98
100
|
// Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
|
|
99
|
-
const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {},
|
|
100
|
-
|
|
101
|
-
const
|
|
101
|
+
const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, settings: e.schema.settings })));
|
|
102
|
+
// The custom section (spec 2026-09-14 §6) validates like a section, over its own schema.
|
|
103
|
+
const sectionFields = (type) => (type === CUSTOM_NODE_TYPE ? customSchema(config.custom.toolset) : config.registry.sections?.find((s) => s.schema.name === type)?.schema);
|
|
104
|
+
const globalItem = (name) => config.registry.globals?.find((s) => s.name === name) ?? null;
|
|
102
105
|
/** One row per registration; null when materialization could not create it. */
|
|
103
|
-
const
|
|
106
|
+
const globalRecord = async (name) => (await entries.list(name))[0] ?? null;
|
|
104
107
|
const fanOut = async (entryId) => {
|
|
105
108
|
if (!effects?.revalidate)
|
|
106
109
|
return;
|
|
@@ -109,28 +112,28 @@ export function createAdminOps(deps) {
|
|
|
109
112
|
effects.revalidate(entryTag(id));
|
|
110
113
|
for (const id of pageIds)
|
|
111
114
|
effects.revalidate(pageTag(id));
|
|
112
|
-
// A
|
|
115
|
+
// A global item's row is an entry like any other: when the item
|
|
113
116
|
// itself changes OR when something it REFERENCES does, the unit
|
|
114
|
-
// `
|
|
117
|
+
// `getSmoodlyGlobal` caches under `global:<name>` is stale too — and
|
|
115
118
|
// that unit is a layout, on every page. `affectedTargets` returns row
|
|
116
119
|
// ids, so resolve name → row id here. Once per fan-out, not memoized
|
|
117
120
|
// per ops instance: a row can be materialized between two calls, and
|
|
118
121
|
// an entry write is rare enough that one `entries.list` per
|
|
119
122
|
// registration beats reasoning about a stale cache. Last, so the
|
|
120
123
|
// existing entry-then-page order is untouched.
|
|
121
|
-
const registered = config.registry.
|
|
124
|
+
const registered = config.registry.globals ?? [];
|
|
122
125
|
if (registered.length === 0)
|
|
123
126
|
return;
|
|
124
127
|
const touched = new Set(entryIds);
|
|
125
128
|
for (const item of registered) {
|
|
126
129
|
const row = (await entries.list(item.name))[0];
|
|
127
130
|
if (row && touched.has(row.id))
|
|
128
|
-
effects.revalidate(
|
|
131
|
+
effects.revalidate(globalTag(item.name));
|
|
129
132
|
}
|
|
130
133
|
};
|
|
131
134
|
/** An asset's metadata (alt, title, caption, credit) is read wherever media
|
|
132
135
|
* resolves (spec 2026-09-08 §9): the entry and page units that reference the
|
|
133
|
-
* asset, directly or through an entry, the
|
|
136
|
+
* asset, directly or through an entry, the global items among those rows,
|
|
134
137
|
* and the list units of every referencing entry's collection. The walk is
|
|
135
138
|
* the entry fan-out's, seeded with the asset's id — which is no entry, so
|
|
136
139
|
* it gets no tag of its own. */
|
|
@@ -144,10 +147,10 @@ export function createAdminOps(deps) {
|
|
|
144
147
|
for (const id of pageIds)
|
|
145
148
|
effects.revalidate(pageTag(id));
|
|
146
149
|
const touched = new Set(users);
|
|
147
|
-
for (const item of config.registry.
|
|
150
|
+
for (const item of config.registry.globals ?? []) {
|
|
148
151
|
const row = (await entries.list(item.name))[0];
|
|
149
152
|
if (row && touched.has(row.id))
|
|
150
|
-
effects.revalidate(
|
|
153
|
+
effects.revalidate(globalTag(item.name));
|
|
151
154
|
}
|
|
152
155
|
for (const c of config.registry.collections ?? []) {
|
|
153
156
|
for (const id of users) {
|
|
@@ -206,9 +209,9 @@ export function createAdminOps(deps) {
|
|
|
206
209
|
return { title: sourceId, href: null };
|
|
207
210
|
return { title: record.locales[def]?.title ?? Object.values(record.locales)[0]?.title ?? sourceId, href: `${BASE}/pages/${sourceId}` };
|
|
208
211
|
}
|
|
209
|
-
for (const item of config.registry.
|
|
212
|
+
for (const item of config.registry.globals ?? []) {
|
|
210
213
|
if (await entries.get(item.name, sourceId))
|
|
211
|
-
return { title: item.title, href: `${BASE}/
|
|
214
|
+
return { title: item.title, href: `${BASE}/globals/${encodeURIComponent(item.name)}` };
|
|
212
215
|
}
|
|
213
216
|
for (const c of config.registry.collections ?? []) {
|
|
214
217
|
const record = await entries.get(c.name, sourceId);
|
|
@@ -323,7 +326,11 @@ export function createAdminOps(deps) {
|
|
|
323
326
|
const schema = sectionFields(node.type);
|
|
324
327
|
if (!schema?.fields)
|
|
325
328
|
continue;
|
|
326
|
-
|
|
329
|
+
// Fields, settings (prefixed) and the css snippet (spec 2026-09-14 §3, §4); a custom section adds its row structure (§6).
|
|
330
|
+
const errors = node.type === CUSTOM_NODE_TYPE
|
|
331
|
+
? validateCustomNode(node, schema.fields, elementDescs)
|
|
332
|
+
: validateNode(node, { fields: schema.fields, settings: schema.settings }, elementDescs);
|
|
333
|
+
for (const fe of errors) {
|
|
327
334
|
fieldErrors.push({ field: `${node.id}.${fe.field}`, message: fe.message });
|
|
328
335
|
}
|
|
329
336
|
}
|
|
@@ -642,22 +649,22 @@ export function createAdminOps(deps) {
|
|
|
642
649
|
return ok(entry);
|
|
643
650
|
}),
|
|
644
651
|
},
|
|
645
|
-
|
|
652
|
+
globals: {
|
|
646
653
|
list: () => guard(async () => {
|
|
647
|
-
const records = await
|
|
648
|
-
return ok((config.registry.
|
|
654
|
+
const records = await ensureGlobalItems(entries, config);
|
|
655
|
+
return ok((config.registry.globals ?? []).flatMap((s) => (records[s.name] ? [{ name: s.name, record: records[s.name] }] : [])));
|
|
649
656
|
}),
|
|
650
657
|
get: (name, locale) => guard(async () => {
|
|
651
658
|
const unsupported = supportedLocale(locale);
|
|
652
659
|
if (unsupported)
|
|
653
660
|
return unsupported;
|
|
654
|
-
const item =
|
|
661
|
+
const item = globalItem(name);
|
|
655
662
|
if (!item)
|
|
656
|
-
return err("not_found", `smoodly: no
|
|
657
|
-
await
|
|
658
|
-
const record = await
|
|
663
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
664
|
+
await ensureGlobalItems(entries, config);
|
|
665
|
+
const record = await globalRecord(name);
|
|
659
666
|
if (!record?.locales[locale])
|
|
660
|
-
return err("not_found", `smoodly:
|
|
667
|
+
return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
|
|
661
668
|
const usage = await entries.referencesTo(name, record.id);
|
|
662
669
|
return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
|
|
663
670
|
}),
|
|
@@ -665,14 +672,14 @@ export function createAdminOps(deps) {
|
|
|
665
672
|
const unsupported = supportedLocale(locale);
|
|
666
673
|
if (unsupported)
|
|
667
674
|
return unsupported;
|
|
668
|
-
const item =
|
|
675
|
+
const item = globalItem(name);
|
|
669
676
|
if (!item)
|
|
670
|
-
return err("not_found", `smoodly: no
|
|
671
|
-
const record = await
|
|
677
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
678
|
+
const record = await globalRecord(name);
|
|
672
679
|
if (!record)
|
|
673
|
-
return err("not_found", `smoodly:
|
|
680
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
674
681
|
const saved = await entries.update(name, record.id, locale, fields, by);
|
|
675
|
-
// fanOut expires `
|
|
682
|
+
// fanOut expires `global:<name>` itself — this row IS the item's.
|
|
676
683
|
await fanOut(record.id);
|
|
677
684
|
return ok(saved);
|
|
678
685
|
}),
|
|
@@ -680,17 +687,21 @@ export function createAdminOps(deps) {
|
|
|
680
687
|
const unsupported = supportedLocale(locale);
|
|
681
688
|
if (unsupported)
|
|
682
689
|
return unsupported;
|
|
683
|
-
const item =
|
|
690
|
+
const item = globalItem(name);
|
|
684
691
|
if (!item)
|
|
685
|
-
return err("not_found", `smoodly: no
|
|
686
|
-
const record = await
|
|
692
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
693
|
+
const record = await globalRecord(name);
|
|
687
694
|
if (!record)
|
|
688
|
-
return err("not_found", `smoodly:
|
|
695
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
689
696
|
if (status === "published") {
|
|
690
697
|
// A draft may be incomplete; what goes live may not (spec 2026-09-14 §2).
|
|
691
698
|
if (!record.locales[locale])
|
|
692
|
-
return err("not_found", `smoodly:
|
|
693
|
-
|
|
699
|
+
return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
|
|
700
|
+
// A visual item validates like a tree node: its fields, its section's settings under `$settings`
|
|
701
|
+
// (prefixed `settings.`), its `$css` (spec 2026-09-14 §3, §4).
|
|
702
|
+
const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...fields } = entryFieldsIn(record, locale);
|
|
703
|
+
const settingsDescs = item.section !== undefined ? sectionFields(item.section)?.settings : undefined;
|
|
704
|
+
const fieldErrors = validateNode({ fields, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: settingsDescs }, elementDescs);
|
|
694
705
|
if (fieldErrors.length > 0)
|
|
695
706
|
return err("validation", "Some fields need attention.", fieldErrors);
|
|
696
707
|
}
|
|
@@ -703,11 +714,11 @@ export function createAdminOps(deps) {
|
|
|
703
714
|
const unsupported = supportedLocale(locale);
|
|
704
715
|
if (unsupported)
|
|
705
716
|
return unsupported;
|
|
706
|
-
if (!
|
|
707
|
-
return err("not_found", `smoodly: no
|
|
708
|
-
const record = await
|
|
717
|
+
if (!globalItem(name))
|
|
718
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
719
|
+
const record = await globalRecord(name);
|
|
709
720
|
if (!record)
|
|
710
|
-
return err("not_found", `smoodly:
|
|
721
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
711
722
|
const restored = await entries.discardDraft(name, record.id, locale, by);
|
|
712
723
|
await fanOut(record.id);
|
|
713
724
|
return ok(restored);
|
|
@@ -716,22 +727,22 @@ export function createAdminOps(deps) {
|
|
|
716
727
|
const unsupported = supportedLocale(locale);
|
|
717
728
|
if (unsupported)
|
|
718
729
|
return unsupported;
|
|
719
|
-
if (!
|
|
720
|
-
return err("not_found", `smoodly: no
|
|
721
|
-
const record = await
|
|
730
|
+
if (!globalItem(name))
|
|
731
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
732
|
+
const record = await globalRecord(name);
|
|
722
733
|
if (!record)
|
|
723
|
-
return err("not_found", `smoodly:
|
|
734
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
724
735
|
return ok((await entries.listVersions(name, record.id, locale)).map(summary));
|
|
725
736
|
}),
|
|
726
737
|
getVersion: (name, versionId) => guard(async () => {
|
|
727
|
-
if (!
|
|
728
|
-
return err("not_found", `smoodly: no
|
|
729
|
-
const record = await
|
|
738
|
+
if (!globalItem(name))
|
|
739
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
740
|
+
const record = await globalRecord(name);
|
|
730
741
|
if (!record)
|
|
731
|
-
return err("not_found", `smoodly:
|
|
742
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
732
743
|
const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
|
|
733
744
|
if (!version || version.entryId !== record.id) {
|
|
734
|
-
return err("not_found", `smoodly: no version "${String(versionId)}" of
|
|
745
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}".`);
|
|
735
746
|
}
|
|
736
747
|
return ok({ ...summary(version), fields: version.fields });
|
|
737
748
|
}),
|
|
@@ -739,14 +750,14 @@ export function createAdminOps(deps) {
|
|
|
739
750
|
const unsupported = supportedLocale(locale);
|
|
740
751
|
if (unsupported)
|
|
741
752
|
return unsupported;
|
|
742
|
-
if (!
|
|
743
|
-
return err("not_found", `smoodly: no
|
|
744
|
-
const record = await
|
|
753
|
+
if (!globalItem(name))
|
|
754
|
+
return err("not_found", `smoodly: no global item named "${name}".`);
|
|
755
|
+
const record = await globalRecord(name);
|
|
745
756
|
if (!record)
|
|
746
|
-
return err("not_found", `smoodly:
|
|
757
|
+
return err("not_found", `smoodly: global item "${name}" is not materialized — open Globals first.`);
|
|
747
758
|
const version = typeof versionId === "string" ? await entries.getVersion(name, versionId) : null;
|
|
748
759
|
if (!version || version.entryId !== record.id || version.locale !== locale) {
|
|
749
|
-
return err("not_found", `smoodly: no version "${String(versionId)}" of
|
|
760
|
+
return err("not_found", `smoodly: no version "${String(versionId)}" of global item "${name}" in ${locale}.`);
|
|
750
761
|
}
|
|
751
762
|
const restored = await entries.restore(name, record.id, locale, versionId, by);
|
|
752
763
|
await fanOut(record.id);
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -60,16 +60,16 @@ export type EntryGetResult = {
|
|
|
60
60
|
/** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
|
|
61
61
|
parentLocales: string[];
|
|
62
62
|
};
|
|
63
|
-
export type
|
|
63
|
+
export type GlobalListItem = {
|
|
64
64
|
name: string;
|
|
65
65
|
record: EntryRecord;
|
|
66
66
|
};
|
|
67
|
-
export type
|
|
67
|
+
export type GlobalGetResult = {
|
|
68
68
|
name: string;
|
|
69
69
|
/** The node with ALL its locale rows (every supported one, by construction). */
|
|
70
70
|
record: EntryRecord;
|
|
71
71
|
locale: string;
|
|
72
|
-
/** That locale's merged draft fields — a visual item's
|
|
72
|
+
/** That locale's merged draft fields — a visual item's settings under `$settings`. */
|
|
73
73
|
fields: Record<string, unknown>;
|
|
74
74
|
/** Who links it: "used on N pages". */
|
|
75
75
|
usage: EntryUsage[];
|
|
@@ -156,10 +156,10 @@ export type AdminOps = {
|
|
|
156
156
|
/** The snapshot becomes the draft (§6); `not_found` for a foreign version. Fans out like a save. */
|
|
157
157
|
restore(collection: string, id: string, locale: string, versionId: string): Promise<OpResult<EntryRecord>>;
|
|
158
158
|
};
|
|
159
|
-
|
|
159
|
+
globals: {
|
|
160
160
|
/** Materializes every registered item in every supported locale, then lists them in registry order. */
|
|
161
|
-
list(): Promise<OpResult<
|
|
162
|
-
get(name: string, locale: string): Promise<OpResult<
|
|
161
|
+
list(): Promise<OpResult<GlobalListItem[]>>;
|
|
162
|
+
get(name: string, locale: string): Promise<OpResult<GlobalGetResult>>;
|
|
163
163
|
save(name: string, locale: string, fields: Record<string, unknown>): Promise<OpResult<EntryRecord>>;
|
|
164
164
|
setStatus(name: string, locale: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
|
|
165
165
|
/** The item's live row back to its published snapshot (§4); `blocked` before the first publish. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedConfig } from "../config.ts";
|
|
2
|
+
import type { Descriptor } from "../fields.ts";
|
|
2
3
|
import type { ZonePolicy } from "../zones.ts";
|
|
3
4
|
export type AdminField = Record<string, unknown> & {
|
|
4
5
|
type: string;
|
|
@@ -14,8 +15,11 @@ export type AdminSection = {
|
|
|
14
15
|
icon?: string;
|
|
15
16
|
description?: string;
|
|
16
17
|
fields: Record<string, AdminField>;
|
|
18
|
+
settings: Record<string, AdminField>;
|
|
19
|
+
/** Smoodly's styles set after the schema's toggle (spec 2026-09-14 §2); `{}` for `styles: false`. */
|
|
17
20
|
styles: Record<string, AdminField>;
|
|
18
|
-
|
|
21
|
+
/** The custom-CSS box exists for this section: the project flag, unless the schema says `css: false` (spec 2026-09-14 §4). */
|
|
22
|
+
css: boolean;
|
|
19
23
|
sample?: Record<string, unknown>;
|
|
20
24
|
};
|
|
21
25
|
export type AdminCollection = {
|
|
@@ -43,17 +47,19 @@ export type AdminPageTemplate = {
|
|
|
43
47
|
/** `meta: { seo: true }` — the SEO & Social tab exists for this template. */
|
|
44
48
|
seo: boolean;
|
|
45
49
|
};
|
|
46
|
-
export type
|
|
50
|
+
export type AdminGlobal = {
|
|
47
51
|
name: string;
|
|
48
52
|
title: string;
|
|
49
53
|
/** The section a visual item renders through; absent = an object item. */
|
|
50
54
|
section?: string;
|
|
51
55
|
fields: Record<string, AdminField>;
|
|
52
|
-
/**
|
|
56
|
+
/** The section's settings, styles set and css flag for a visual item; `{}` / `{}` / `false` for an object item. */
|
|
57
|
+
settings: Record<string, AdminField>;
|
|
53
58
|
styles: Record<string, AdminField>;
|
|
59
|
+
css: boolean;
|
|
54
60
|
};
|
|
55
|
-
/** A registered Element (spec 2026-09-08 block nodes §1): a section shape plus its contexts. */
|
|
56
|
-
export type AdminElement = AdminSection & {
|
|
61
|
+
/** A registered Element (spec 2026-09-08 block nodes §1): a section shape plus its contexts — no styles, no css. */
|
|
62
|
+
export type AdminElement = Omit<AdminSection, "styles" | "css"> & {
|
|
57
63
|
contexts: string[];
|
|
58
64
|
};
|
|
59
65
|
export type AdminRegistry = {
|
|
@@ -61,11 +67,15 @@ export type AdminRegistry = {
|
|
|
61
67
|
elements: AdminElement[];
|
|
62
68
|
collections: AdminCollection[];
|
|
63
69
|
pages: AdminPageTemplate[];
|
|
64
|
-
|
|
70
|
+
globals: AdminGlobal[];
|
|
71
|
+
/** The custom section (spec 2026-09-14 §6) as one synthetic section: its rows descriptor, the full styles set, the project css flag. */
|
|
72
|
+
custom: AdminSection;
|
|
65
73
|
locales: {
|
|
66
74
|
default: string;
|
|
67
75
|
supported: string[];
|
|
68
76
|
};
|
|
77
|
+
/** The project's custom-CSS flag (spec 2026-09-14 §4). */
|
|
78
|
+
css: boolean;
|
|
69
79
|
/** Which slug the site root serves. */
|
|
70
80
|
homePageSlug: string;
|
|
71
81
|
/** How deep editors may nest pages; 1 = flat. */
|
|
@@ -76,4 +86,6 @@ export type AdminRegistry = {
|
|
|
76
86
|
accept: string[];
|
|
77
87
|
};
|
|
78
88
|
};
|
|
89
|
+
/** A registered section's settings descriptors — what a visual global's form shows (spec 2026-09-14 §3). */
|
|
90
|
+
export declare function sectionSettings(config: ResolvedConfig, name: string): Record<string, Descriptor>;
|
|
79
91
|
export declare function serializeAdminConfig(config: ResolvedConfig): AdminRegistry;
|